定时任务在凌晨 01:00 推送的是「昨日」数据,但截图对象键原先直接取执行时刻的北京日期, 导致归档目录与通知里的业务日期不一致(daily-inventory/2026-09-26/... 装的是 09-25 的数据)。 现改为由定时任务显式传入业务日期,目录与文件名统一用业务日期、时间仍用执行时刻: daily-inventory/2026-09-25/daily-inventory-20260925-010000.png 另补充验收要求 8 的测试: · buildScreenshotNames 归档命名(含业务日期与执行日期不同的场景) · markNotified / hasNotified 幂等:同业务日同用户只成功一次,跨日期互不影响 · appendRunRecord 保留执行时间、截图地址、通知对象与失败原因
334 lines
13 KiB
JavaScript
334 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const {
|
|
DEFAULT_NOTIFY_TIME,
|
|
MAX_RECIPIENTS,
|
|
buildIdempotencyKey,
|
|
normalizeFlag,
|
|
normalizeNotifyTime,
|
|
normalizeRecipients,
|
|
} = require('../src/services/dailyInventoryNotificationSettings');
|
|
const {
|
|
ACTION_TITLE,
|
|
NOTIFICATION_TITLE,
|
|
buildNotificationContent,
|
|
minutesOfDay,
|
|
resolveBizDate,
|
|
withRetry,
|
|
} = require('../src/jobs/dailyInventoryNotificationJob');
|
|
const {
|
|
buildScreenshotNames,
|
|
chinaTimeParts,
|
|
} = require('../src/services/dailyInventoryScreenshot');
|
|
|
|
// ── 收件人名单规范化 ────────────────────────────────────────────
|
|
test('normalizeRecipients 去空值、去空白、保持顺序', () => {
|
|
assert.deepEqual(normalizeRecipients(['张三', ' ', '李四', '', null, undefined]), ['张三', '李四']);
|
|
});
|
|
|
|
test('normalizeRecipients 去重且忽略首尾空格', () => {
|
|
assert.deepEqual(normalizeRecipients([' 张三 ', '张三', '李四', ' 李四']), ['张三', '李四']);
|
|
});
|
|
|
|
test('normalizeRecipients 支持逗号/中文逗号/分号/换行分隔的字符串', () => {
|
|
assert.deepEqual(
|
|
normalizeRecipients('张三,李四,王五;赵六\n孙七'),
|
|
['张三', '李四', '王五', '赵六', '孙七']
|
|
);
|
|
});
|
|
|
|
test('normalizeRecipients 对非法输入返回空数组', () => {
|
|
assert.deepEqual(normalizeRecipients(null), []);
|
|
assert.deepEqual(normalizeRecipients(undefined), []);
|
|
assert.deepEqual(normalizeRecipients(12345), []);
|
|
});
|
|
|
|
test('normalizeRecipients 超量时截断到上限', () => {
|
|
const list = Array.from({ length: MAX_RECIPIENTS + 5 }, (_, i) => `用户${i}`);
|
|
const result = normalizeRecipients(list);
|
|
assert.equal(result.length, MAX_RECIPIENTS);
|
|
assert.equal(result[0], '用户0');
|
|
});
|
|
|
|
// ── 通知时间校验 ────────────────────────────────────────────────
|
|
test('normalizeNotifyTime 接受合法 HH:mm 并补零', () => {
|
|
assert.equal(normalizeNotifyTime('1:05'), '01:05');
|
|
assert.equal(normalizeNotifyTime('23:59'), '23:59');
|
|
assert.equal(normalizeNotifyTime('00:00'), '00:00');
|
|
});
|
|
|
|
test('normalizeNotifyTime 支持 HH:mm:ss 并截断到分钟', () => {
|
|
assert.equal(normalizeNotifyTime('01:00:30'), '01:00');
|
|
});
|
|
|
|
test('normalizeNotifyTime 非法值回退默认 01:00', () => {
|
|
assert.equal(normalizeNotifyTime('24:00'), DEFAULT_NOTIFY_TIME);
|
|
assert.equal(normalizeNotifyTime('12:60'), DEFAULT_NOTIFY_TIME);
|
|
assert.equal(normalizeNotifyTime('abc'), DEFAULT_NOTIFY_TIME);
|
|
assert.equal(normalizeNotifyTime(''), DEFAULT_NOTIFY_TIME);
|
|
assert.equal(normalizeNotifyTime(null), DEFAULT_NOTIFY_TIME);
|
|
});
|
|
|
|
test('normalizeFlag 只接受明确的布尔语义,未知值回退默认', () => {
|
|
assert.equal(normalizeFlag('true', false), true);
|
|
assert.equal(normalizeFlag('off', true), false);
|
|
assert.equal(normalizeFlag('maybe', true), true);
|
|
assert.equal(normalizeFlag(null, false), false);
|
|
});
|
|
|
|
// ── 幂等键 ──────────────────────────────────────────────────────
|
|
test('buildIdempotencyKey 正式推送按业务日期+收件人稳定生成', () => {
|
|
const a = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
|
|
const b = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
|
|
const c = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '李四', mode: 'scheduled' });
|
|
assert.equal(a, b);
|
|
assert.notEqual(a, c);
|
|
});
|
|
|
|
test('buildIdempotencyKey 测试推送带随机数,不占用正式幂等记录', () => {
|
|
const formal = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
|
|
const t1 = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'test', nonce: 'n1' });
|
|
const t2 = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'test', nonce: 'n2' });
|
|
assert.notEqual(t1, t2);
|
|
assert.notEqual(t1, formal);
|
|
});
|
|
|
|
// ── 通知正文 ────────────────────────────────────────────────────
|
|
test('buildNotificationContent 包含数据日期、生成时间、图片地址与页面链接', () => {
|
|
const content = buildNotificationContent({
|
|
bizDate: '2026-09-25',
|
|
takenAt: '2026/09/25 01:00:00',
|
|
imageUrl: 'https://cdn.example.com/daily-inventory/2026-09-25/daily-inventory-20260925-010000.png',
|
|
pageUrl: 'https://ck.yydd.ai/planning/operation/daily-inventory',
|
|
});
|
|
assert.match(content, /2026-09-25/);
|
|
assert.match(content, /2026\/09\/25 01:00:00/);
|
|
assert.match(content, /https:\/\/cdn\.example\.com\//);
|
|
assert.match(content, /https:\/\/ck\.yydd\.ai\/planning\/operation\/daily-inventory/);
|
|
assert.match(content, new RegExp(ACTION_TITLE));
|
|
assert.equal(NOTIFICATION_TITLE, '每日进销存数据');
|
|
});
|
|
|
|
// ── 业务日期与调度时刻 ──────────────────────────────────────────
|
|
test('resolveBizDate 取北京时间昨日(与页面 T-1 口径一致)', () => {
|
|
assert.equal(resolveBizDate(new Date('2026-09-25T01:00:00+08:00')), '2026-09-24');
|
|
assert.equal(resolveBizDate(new Date('2026-09-25T23:30:00+08:00')), '2026-09-24');
|
|
});
|
|
|
|
test('minutesOfDay 正确换算,非法值返回 null', () => {
|
|
assert.equal(minutesOfDay('00:00'), 0);
|
|
assert.equal(minutesOfDay('01:00'), 60);
|
|
assert.equal(minutesOfDay('23:59'), 1439);
|
|
assert.equal(minutesOfDay('24:00'), null);
|
|
assert.equal(minutesOfDay('abc'), null);
|
|
assert.equal(minutesOfDay(null), null);
|
|
});
|
|
|
|
test('chinaTimeParts 生成对象键所需的日期与时间片段', () => {
|
|
const parts = chinaTimeParts(new Date('2026-09-25T01:00:00+08:00'));
|
|
assert.equal(parts.bizDate, '2026-09-25');
|
|
assert.equal(parts.dateKey, '20260925');
|
|
assert.equal(parts.timeKey, '010000');
|
|
assert.equal(
|
|
`daily-inventory/${parts.bizDate}/daily-inventory-${parts.dateKey}-${parts.timeKey}.png`,
|
|
'daily-inventory/2026-09-25/daily-inventory-20260925-010000.png'
|
|
);
|
|
});
|
|
|
|
test('buildScreenshotNames 用业务日期归档目录与文件名,时间取执行时刻', () => {
|
|
const names = buildScreenshotNames({ bizDate: '2026-09-25', timeKey: '010000' });
|
|
assert.equal(names.fileName, 'daily-inventory-20260925-010000.png');
|
|
assert.equal(
|
|
names.objectKey,
|
|
'daily-inventory/2026-09-25/daily-inventory-20260925-010000.png'
|
|
);
|
|
});
|
|
|
|
test('buildScreenshotNames 业务日期与执行日期不同时以业务日期为准', () => {
|
|
// 凌晨 01:00 推送的是「昨日」数据,归档必须落在业务日而不是执行日
|
|
const names = buildScreenshotNames({ bizDate: '2026-09-25', timeKey: '010000' });
|
|
assert.ok(!names.objectKey.includes('2026-09-26'));
|
|
assert.ok(names.objectKey.startsWith('daily-inventory/2026-09-25/'));
|
|
});
|
|
|
|
test('buildScreenshotNames 业务日期缺失时回退空目录占位而不抛错', () => {
|
|
const names = buildScreenshotNames({ bizDate: '', timeKey: '010000' });
|
|
assert.equal(names.fileName, 'daily-inventory--010000.png');
|
|
});
|
|
|
|
// ── 幂等记录:同一业务日期 + 同一用户只成功通知一次 ────────────
|
|
async function withStubbedState(runner) {
|
|
const database = require('../src/config/database');
|
|
const settings = require('../src/services/dailyInventoryNotificationSettings');
|
|
const original = database.local;
|
|
const store = new Map();
|
|
|
|
database.local = {
|
|
async query(sql, params) {
|
|
const text = String(sql);
|
|
if (text.startsWith('SELECT config_value')) {
|
|
const value = store.get(params[0]);
|
|
return value === undefined ? [] : [{ configValue: value }];
|
|
}
|
|
if (text.startsWith('SELECT id FROM system_config')) {
|
|
const value = store.get(params[0]);
|
|
return value === undefined ? [] : [{ id: 1 }];
|
|
}
|
|
// UPDATE system_config SET config_value = ? ...
|
|
store.set(params[params.length - 1], params[0]);
|
|
return { affectedRows: 1 };
|
|
},
|
|
async insert(_table, data) {
|
|
store.set(data.config_key, data.config_value);
|
|
return { insertId: 1 };
|
|
},
|
|
};
|
|
|
|
try {
|
|
return await runner(settings);
|
|
} finally {
|
|
database.local = original;
|
|
}
|
|
}
|
|
|
|
test('markNotified 后同一业务日期同一用户命中幂等,跨日期互不影响', async () => {
|
|
await withStubbedState(async (settings) => {
|
|
assert.equal(await settings.hasNotified('2026-09-25', '苏适'), false);
|
|
await settings.markNotified('2026-09-25', '苏适', { deliveryId: 'd-1' });
|
|
assert.equal(await settings.hasNotified('2026-09-25', '苏适'), true);
|
|
// 同一天其他用户不受影响
|
|
assert.equal(await settings.hasNotified('2026-09-25', '李四'), false);
|
|
// 同一用户换一天应当重新通知
|
|
assert.equal(await settings.hasNotified('2026-09-26', '苏适'), false);
|
|
});
|
|
});
|
|
|
|
test('appendRunRecord 保留执行时间、截图地址、通知对象与失败原因', async () => {
|
|
await withStubbedState(async (settings) => {
|
|
await settings.appendRunRecord({
|
|
runAt: '2026-09-25T01:00:00.000Z',
|
|
bizDate: '2026-09-25',
|
|
mode: 'scheduled',
|
|
success: false,
|
|
error: '所有收件人均通知失败',
|
|
screenshotUrl: 'https://scqny.jiansun.vip/daily-inventory/2026-09-25/x.png',
|
|
recipients: [{ recipient: '苏适', status: 'failed', error: '通知中心 502' }],
|
|
});
|
|
const runs = await settings.listRunRecords(5);
|
|
assert.equal(runs.length, 1);
|
|
assert.equal(runs[0].bizDate, '2026-09-25');
|
|
assert.equal(runs[0].success, false);
|
|
assert.equal(runs[0].error, '所有收件人均通知失败');
|
|
assert.equal(runs[0].recipients[0].recipient, '苏适');
|
|
assert.ok(runs[0].screenshotUrl.startsWith('https://'));
|
|
});
|
|
});
|
|
|
|
// ── 重试与退避 ──────────────────────────────────────────────────
|
|
test('withRetry 第二次成功后返回结果', async () => {
|
|
let calls = 0;
|
|
const result = await withRetry(
|
|
async () => {
|
|
calls += 1;
|
|
if (calls < 2) throw new Error('临时失败');
|
|
return 'ok';
|
|
},
|
|
{ label: '单元测试', attempts: 3, baseDelayMs: 1 }
|
|
);
|
|
assert.equal(result, 'ok');
|
|
assert.equal(calls, 2);
|
|
});
|
|
|
|
test('withRetry 超过次数后抛出最后一次错误', async () => {
|
|
let calls = 0;
|
|
await assert.rejects(
|
|
withRetry(
|
|
async () => {
|
|
calls += 1;
|
|
throw new Error(`失败${calls}`);
|
|
},
|
|
{ label: '单元测试', attempts: 3, baseDelayMs: 1 }
|
|
),
|
|
/失败3/
|
|
);
|
|
assert.equal(calls, 3);
|
|
});
|
|
|
|
// ── 截图身份:必须使用系统库中真实存在的服务账号 ──────────────
|
|
test('ensureScreenshotServiceUser 账号不存在时创建,存在时复用 id', async () => {
|
|
const database = require('../src/config/database');
|
|
const screenshot = require('../src/services/dailyInventoryScreenshot');
|
|
const original = database.local;
|
|
const calls = { select: 0, insert: 0, update: 0 };
|
|
let existing = null;
|
|
|
|
database.local = {
|
|
async query(sql, params) {
|
|
if (String(sql).startsWith('SELECT id, role, status FROM users')) {
|
|
calls.select += 1;
|
|
return existing ? [existing] : [];
|
|
}
|
|
calls.update += 1;
|
|
return { affectedRows: 1 };
|
|
},
|
|
async insert(_table, data) {
|
|
calls.insert += 1;
|
|
assert.equal(data.role, 'admin');
|
|
assert.equal(data.status, 'active');
|
|
assert.equal(data.auth_source, 'service');
|
|
assert.ok(String(data.password_hash || '').length > 20, '密码哈希不能为空');
|
|
existing = { id: 4242, role: data.role, status: data.status };
|
|
return { insertId: 4242 };
|
|
},
|
|
};
|
|
|
|
try {
|
|
screenshot.resetScreenshotServiceUserCache();
|
|
const created = await screenshot.ensureScreenshotServiceUser();
|
|
assert.equal(created, 4242);
|
|
assert.equal(calls.insert, 1);
|
|
assert.equal(calls.select, 1);
|
|
|
|
// 第二次调用命中缓存,不再查库
|
|
assert.equal(await screenshot.ensureScreenshotServiceUser(), 4242);
|
|
assert.equal(calls.select, 1);
|
|
} finally {
|
|
database.local = original;
|
|
screenshot.resetScreenshotServiceUserCache();
|
|
}
|
|
});
|
|
|
|
test('signScreenshotToken 的 sub 指向服务账号 id 且通过签名校验', async () => {
|
|
const database = require('../src/config/database');
|
|
const screenshot = require('../src/services/dailyInventoryScreenshot');
|
|
const jwt = require('jsonwebtoken');
|
|
const original = database.local;
|
|
const originalSecret = process.env.CUBEJS_SECRET;
|
|
process.env.CUBEJS_SECRET = originalSecret || 'test-secret-for-unit';
|
|
|
|
database.local = {
|
|
async query() {
|
|
return [{ id: 99, role: 'admin', status: 'active' }];
|
|
},
|
|
async insert() {
|
|
throw new Error('不应在账号已存在时插入');
|
|
},
|
|
};
|
|
|
|
try {
|
|
screenshot.resetScreenshotServiceUserCache();
|
|
const token = await screenshot.signScreenshotToken();
|
|
const payload = jwt.verify(token, process.env.CUBEJS_SECRET || 'test-secret');
|
|
assert.equal(payload.sub, '99');
|
|
assert.equal(payload.role, 'admin');
|
|
assert.equal(payload.authSource, 'service');
|
|
} finally {
|
|
database.local = original;
|
|
process.env.CUBEJS_SECRET = originalSecret;
|
|
screenshot.resetScreenshotServiceUserCache();
|
|
}
|
|
});
|