业务要求:推送明细「按照仓库来,相同仓库排序在一起」,方便各仓在群里只看自己那一段。
改动:
1. 明细按仓分桶后连续输出(groupRowsByWarehouse / bucketRowsByWarehouse)
- 组间:按该仓异常包裹数降序,与上面「按仓(异常包裹)」块顺序一致;数量相同按首次出现顺序
- 组内:保持服务层原顺序(已按 pendingHours 降序,压得最久的排最前),不重新排序
- 仓名缺失的行统一归到「未知仓」一组,不会被拆成散行
2. 取前 N 条改为按仓配额(pickDetailRows)
- 真数据验证时发现:140 条异常里山东青岗集仓占大头,直接 slice(0,40) 会让
40 条明细全是同一个仓、其它 6 个仓一条都看不到
- 规则:仓数 >= 额度时每仓 1 条(砍掉异常最少的仓);否则每仓先保底 1 条,
剩余额度按各仓剩余条数比例分(最大余数法,总数正好等于额度)
3. 明细仍是 4 段「仓|快递单号|快递|异常类型」,编号全局连续,不带时间/时长
验证:
- 单测 19/19 通过(新增 4 条:同仓相邻+组内不乱序、未知仓归组、大仓不吃满额度、额度少于仓数)
- 本地真数据干跑(140 条异常 / 列 40 条 / 4219 字节):明细覆盖 7 个仓,
同仓全部相邻、无任何仓被拆散;明细段数集合 [4]
各仓条数:青岗集 31 / 崔桥 2 / 洛宁 2 / 睢宁 2 / 山东品牌 1 / 新苏北 1 / 崔桥品牌 1
- backend check-syntax:194 文件通过
风险与回退:只改推送正文的组装顺序与取数配额,不动查询、不动页面列表;
页面仍按时长降序展示全部明细。回退只需 revert 本提交。
498 lines
22 KiB
JavaScript
498 lines
22 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 「仓储中心物流转运异常」每天推文本到钉钉群 —— 正文组装与通道回归测试
|
||
* ────────────────────────────────────────────────────────────────────────────
|
||
* 这里只测四件容易悄悄坏掉的事:
|
||
* ① 群消息正文的数字与明细(业务照着这条消息干活,数字错了没人能发现);
|
||
* ② 0 条 / 明细条数上限 / 超长精简(钉钉 text 上限 20000 字节,超了整条被拒);
|
||
* ③ webhook 地址与加签(sign 算错就是 310000,肉眼看不出来);
|
||
* ④ 钉钉报错必须原样透出 errcode/errmsg(业务排错全靠这个码)。
|
||
* 不测数据库与真实网络:settings / job 的 DB 分支在接口层验证。
|
||
*/
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const crypto = require('crypto');
|
||
|
||
const job = require('../src/jobs/warehouseLogisticsExceptionNotifyJob');
|
||
const sender = require('../src/services/dingtalkRobotWebhookSender');
|
||
const settings = require('../src/services/warehouseLogisticsExceptionNotifySettings');
|
||
|
||
function sampleData(overrides = {}) {
|
||
return {
|
||
batch: {
|
||
label: '10-22 09:00 核验 · 10-21 发货',
|
||
verifyDate: '2026-10-22',
|
||
shipDate: '2026-10-21',
|
||
deadline: '2026-10-22 09:00:00',
|
||
},
|
||
summary: {
|
||
shippedOrders: 9886,
|
||
packageCount: 9886,
|
||
exceptionOrders: 143,
|
||
exceptionPackages: 144,
|
||
exceptionRate: 1.4,
|
||
centerCount: 9,
|
||
byType: { PICKED_NO_TRANSIT: 137, NOT_PICKED: 7 },
|
||
},
|
||
centers: [
|
||
{ key: 'sn', name: '睢宁仓', shippedOrders: 1200, exceptionOrders: 1, exceptionRate: 0.1 },
|
||
{ key: 'cq', name: '崔桥工厂仓', shippedOrders: 3000, exceptionOrders: 2, exceptionRate: 0.1 },
|
||
{ key: 'hl', name: '横林二仓', shippedOrders: 800, exceptionOrders: 0, exceptionRate: 0 },
|
||
],
|
||
types: [
|
||
{ key: 'PICKED_NO_TRANSIT', label: '已揽收未中转', desc: '揽收后没有下一步中转记录' },
|
||
{ key: 'NOT_PICKED', label: '未揽收', desc: '快递还没来揽' },
|
||
],
|
||
exclusions: {
|
||
carriers: ['唯品顺丰', '顺丰', '京喜京东'],
|
||
conditionalCarriers: [{ keyword: '睢宁中通', keepCenters: ['睢宁仓'] }],
|
||
orderStatusKeywords: ['取消', '作废'],
|
||
orderLabelKeywords: ['发货后取消'],
|
||
orderTypeLabels: ['刷单', '转空包'],
|
||
carrierPackages: 12,
|
||
voidedPackages: 3,
|
||
nonRealPackages: 5,
|
||
},
|
||
rows: [
|
||
{
|
||
centerKey: 'cq',
|
||
warehouseName: '崔桥工厂仓',
|
||
oId: '9845001',
|
||
soId: '3318',
|
||
shopName: '京东自营',
|
||
carrier: '京东快递',
|
||
trackingNo: 'JD1',
|
||
logisticStatus: '已揽收',
|
||
exceptionLabel: '已揽收未中转',
|
||
exceptionDesc: '揽收后没有下一步中转记录',
|
||
sendTime: '2026-10-21 16:00:00',
|
||
lastLogisticTime: '2026-10-21 21:00:00',
|
||
pendingHours: 17,
|
||
lastLogisticInfo: '已揽收',
|
||
},
|
||
{
|
||
centerKey: 'sn',
|
||
warehouseName: '睢宁仓',
|
||
oId: '9844248',
|
||
soId: '3316',
|
||
shopName: '天猫旗舰店',
|
||
carrier: '睢宁德邦',
|
||
trackingNo: '',
|
||
logisticStatus: '',
|
||
exceptionLabel: '未揽收',
|
||
exceptionDesc: '快递还没来揽',
|
||
sendTime: '2026-10-21 14:22:10',
|
||
lastLogisticTime: '',
|
||
pendingHours: null,
|
||
lastLogisticInfo: '',
|
||
},
|
||
],
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
test('群消息正文:抬头带「异常」关键词、数字与分类型、按仓只列有异常的仓', () => {
|
||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30, pageUrl: 'https://ck.yydd.ai:8443/p' });
|
||
const text = built.text;
|
||
|
||
// 钉钉「自定义关键词」安全设置一般配「异常」,抬头必须含这两个字
|
||
assert.match(text, /【仓储物流转运异常】/);
|
||
assert.match(text, /10-22 09:00 核验 · 10-21 发货/);
|
||
assert.match(text, /已发货 9886 单 \/ 9886 个包裹,异常 144 个包裹(1\.4%)/);
|
||
// 分类型按数量降序,且必须是可读标签(不是 PICKED_NO_TRANSIT 这种 key)
|
||
assert.match(text, /类型:已揽收未中转 137 · 未揽收 7/);
|
||
// 剔除数量 = 12 + 3 + 5
|
||
assert.match(text, /已剔除 20 个包裹/);
|
||
// 按仓只列有异常的:睢宁 1、崔桥 1;横林二仓 0 条不能出现
|
||
assert.match(text, /按仓(异常包裹):/);
|
||
assert.match(text, /睢宁仓 1 个(发货 1200 单)/);
|
||
assert.match(text, /崔桥工厂仓 1 个(发货 3000 单)/);
|
||
assert.ok(!text.includes('横林二仓'), '没有异常的仓不该占行');
|
||
// 页码链接带核验日
|
||
assert.match(text, /查看全部 \/ 导出 Excel:https:\/\/ck\.yydd\.ai:8443\/p\?date=2026-10-22/);
|
||
|
||
assert.equal(built.rowCount, 144);
|
||
assert.equal(built.listedCount, 2);
|
||
assert.equal(built.truncated, false);
|
||
});
|
||
|
||
test('明细行:仓|快递单号|快递|异常类型(4 段,不带发货时间和待处理时长),字段里换行要压平', () => {
|
||
const line = job.buildDetailLine({
|
||
warehouseName: '崔桥工厂仓',
|
||
oId: '9845001',
|
||
trackingNo: 'JDVA00123456789',
|
||
carrier: '京东快递',
|
||
exceptionLabel: '已揽收未中转',
|
||
sendTime: '2026-10-21 16:00:00',
|
||
pendingHours: 17.25,
|
||
lastLogisticInfo: '第一行\n第二行',
|
||
}, 3);
|
||
|
||
assert.equal(line, '3. 崔桥工厂仓|JDVA00123456789|京东快递|已揽收未中转');
|
||
// 业务要求:第二段是快递单号,不是内部单号
|
||
assert.ok(!line.includes('9845001'), '明细行不该再显示内部单号');
|
||
// 业务要求:明细不带发货时间(哪怕传了 sendTime 也不能出现在消息里)
|
||
assert.ok(!line.includes('10-21'), '明细行不该再带发货时间');
|
||
assert.ok(!line.includes('16:00'), '明细行不该再带发货时间');
|
||
// 业务要求:末尾的待处理小时数也去掉(哪怕传了 pendingHours 也不能出现在消息里)
|
||
assert.ok(!line.includes('17.3h'), '明细行不该再带待处理时长');
|
||
assert.equal(line.split('|').length, 4, '明细必须正好 4 段');
|
||
|
||
// 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常)
|
||
assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁');
|
||
// 未揽收/无物流单没有快递单号 → 退回内部单号(否则群里这条无法定位)
|
||
assert.equal(
|
||
job.buildDetailLine({ oId: '9845002', carrier: '中通' }, 1),
|
||
'1. 未知仓|9845002|中通|异常'
|
||
);
|
||
// 空值兜底:不能让消息里出现「undefined|null」
|
||
assert.equal(
|
||
job.buildDetailLine({}, 1),
|
||
'1. 未知仓|无单号|未知快递|异常'
|
||
);
|
||
});
|
||
|
||
test('明细按仓聚集:同仓相邻、组间按异常数降序、组内保持原顺序(时长降序)', () => {
|
||
const rows = [
|
||
{ warehouseName: '睢宁仓', trackingNo: 'SN1', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
|
||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ1', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||
{ warehouseName: '睢宁仓', trackingNo: 'SN2', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
|
||
{ warehouseName: '山东青岗集仓', trackingNo: 'SD1', carrier: '山东中通', exceptionLabel: '已揽收未中转' },
|
||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ2', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ3', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||
];
|
||
|
||
const grouped = job.groupRowsByWarehouse(rows);
|
||
// 崔桥 3 条 > 睢宁 2 条 > 青岗集 1 条,组间按异常数降序
|
||
assert.deepEqual(grouped.map((r) => r.trackingNo), ['CQ1', 'CQ2', 'CQ3', 'SN1', 'SN2', 'SD1']);
|
||
// 不丢行、不改行
|
||
assert.equal(grouped.length, rows.length);
|
||
|
||
const text = job.buildNotifyText(sampleData({ rows }), { maxDetailRows: 30 }).text;
|
||
// 只取「明细前 N 条」块里的行(上面「按仓」块也是 `1. xxx` 格式,别混进来)
|
||
const detailBlock = text.split('明细前')[1] || '';
|
||
const detailLines = detailBlock.split('\n').filter((line) => /^\d+\. /.test(line));
|
||
const warehouses = detailLines.map((line) => line.split('|')[0].replace(/^\d+\. /, ''));
|
||
|
||
// 同仓必须相邻:切成连续段后不应出现重复仓名
|
||
const runs = warehouses.filter((name, i) => name !== warehouses[i - 1]);
|
||
assert.equal(new Set(runs).size, runs.length, `同仓没有排在一起:${warehouses.join(' / ')}`);
|
||
assert.deepEqual(warehouses, ['崔桥工厂仓', '崔桥工厂仓', '崔桥工厂仓', '睢宁仓', '睢宁仓', '山东青岗集仓']);
|
||
// 编号仍然全局连续,不能每组从 1 开始(否则群里看到重复序号)
|
||
assert.deepEqual(detailLines.map((line) => line.match(/^(\d+)\./)[1]), ['1', '2', '3', '4', '5', '6']);
|
||
});
|
||
|
||
test('明细按仓聚集:仓名缺失的行归到「未知仓」一组,不会被拆散', () => {
|
||
const rows = [
|
||
{ warehouseName: '', trackingNo: 'A1' },
|
||
{ warehouseName: '睢宁仓', trackingNo: 'B1' },
|
||
{ trackingNo: 'A2' },
|
||
];
|
||
const grouped = job.groupRowsByWarehouse(rows);
|
||
// 未知仓 2 条(含 undefined)> 睢宁 1 条
|
||
assert.deepEqual(grouped.map((r) => r.trackingNo), ['A1', 'A2', 'B1']);
|
||
});
|
||
|
||
test('明细额度按仓分配:大仓不能吃满,每仓至少 1 条', () => {
|
||
// 真数据就是这样:青岗集一家占了大头(10-22 实测 140 条里前 40 条全是它)
|
||
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
|
||
warehouseName: name,
|
||
trackingNo: `${name}-${i}`,
|
||
}));
|
||
const rows = [
|
||
...make('青岗集仓', 100),
|
||
...make('崔桥工厂仓', 20),
|
||
...make('睢宁仓', 8),
|
||
...make('新苏北仓', 6),
|
||
...make('横林二仓', 6),
|
||
];
|
||
|
||
const picked = job.pickDetailRows(rows, 40);
|
||
assert.equal(picked.length, 40, '额度必须正好用满(140 条 > 40)');
|
||
|
||
const counts = picked.reduce((acc, row) => {
|
||
acc[row.warehouseName] = (acc[row.warehouseName] || 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
// 每个仓都要露面,否则群里那个仓的人看不到自己的单
|
||
for (const name of ['青岗集仓', '崔桥工厂仓', '睢宁仓', '新苏北仓', '横林二仓']) {
|
||
assert.ok(counts[name] >= 1, `${name} 一条明细都没分到`);
|
||
}
|
||
// 大仓仍然拿得多,但不能吃满额度
|
||
assert.ok(counts['青岗集仓'] < 40, '大仓不该独占全部额度');
|
||
assert.ok(counts['青岗集仓'] > counts['崔桥工厂仓'], '异常多的仓应分到更多明细');
|
||
|
||
// 同仓仍然相邻
|
||
const names = picked.map((row) => row.warehouseName);
|
||
const runs = names.filter((name, i) => name !== names[i - 1]);
|
||
assert.equal(runs.length, new Set(runs).size, `同仓被拆散:${names.join(',')}`);
|
||
});
|
||
|
||
test('明细额度比仓数还少时:按异常数从多到少,每仓只取 1 条', () => {
|
||
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
|
||
warehouseName: name,
|
||
trackingNo: `${name}-${i}`,
|
||
}));
|
||
const rows = [...make('大仓', 10), ...make('中仓', 5), ...make('小仓', 2)];
|
||
const picked = job.pickDetailRows(rows, 2);
|
||
assert.deepEqual(picked.map((row) => row.trackingNo), ['大仓-0', '中仓-0']);
|
||
// 额度 0 / 超额度的边界
|
||
assert.deepEqual(job.pickDetailRows(rows, 0), []);
|
||
assert.equal(job.pickDetailRows(rows, 99).length, 17);
|
||
});
|
||
|
||
test('0 条异常也要发一条平安消息(用来确认任务没挂)', () => {
|
||
const data = sampleData({
|
||
rows: [],
|
||
summary: {
|
||
...sampleData().summary,
|
||
exceptionOrders: 0,
|
||
exceptionPackages: 0,
|
||
exceptionRate: 0,
|
||
byType: {},
|
||
centerCount: 0,
|
||
},
|
||
exclusions: {},
|
||
});
|
||
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||
assert.match(built.text, /本批无转运异常,全部正常/);
|
||
assert.match(built.text, /异常 0 个包裹(/);
|
||
assert.ok(!built.text.includes('明细前'), '0 条时不该出现明细标题');
|
||
assert.equal(built.rowCount, 0);
|
||
assert.equal(built.listedCount, 0);
|
||
});
|
||
|
||
test('明细条数受配置控制:0 = 只发汇总,超量只列前 N 条并提示剩余', () => {
|
||
const zero = job.buildNotifyText(sampleData(), { maxDetailRows: 0 });
|
||
assert.ok(!/明细前 \d+ 条/.test(zero.text), 'maxDetailRows=0 时不要明细块');
|
||
assert.match(zero.text, /明细共 144 条,消息里不放,请点下面链接查看/);
|
||
|
||
const one = job.buildNotifyText(sampleData(), { maxDetailRows: 1 });
|
||
assert.equal(one.listedCount, 1);
|
||
assert.ok(one.text.includes('1. 崔桥工厂仓'), '只列第一条');
|
||
assert.ok(!one.text.includes('2. 睢宁仓'), '第二条应被截掉');
|
||
assert.match(one.text, /其余 143 条见页面/);
|
||
});
|
||
|
||
test('明细太多时按字节精简:不超 20000 字节上限,且不切断半个汉字', () => {
|
||
// 造 60 条很长的明细(仓名 144 字 ≈ 432 字节/行),必然超过 text 上限
|
||
const longName = '崔桥工厂半成品包装项目仓'.repeat(12);
|
||
const rows = Array.from({ length: 60 }, (_item, index) => ({
|
||
centerKey: 'cq',
|
||
warehouseName: longName,
|
||
oId: `9845${String(index).padStart(3, '0')}`,
|
||
carrier: '中通快递',
|
||
exceptionLabel: '已揽收未中转',
|
||
sendTime: '2026-10-21 16:00:00',
|
||
pendingHours: 17,
|
||
}));
|
||
const data = sampleData({
|
||
rows,
|
||
summary: { ...sampleData().summary, exceptionPackages: rows.length, exceptionOrders: rows.length },
|
||
});
|
||
|
||
const built = job.buildNotifyText(data, { maxDetailRows: 50 });
|
||
assert.ok(built.bytes <= sender.MAX_CONTENT_BYTES, `实得 ${built.bytes} 字节,超了上限`);
|
||
assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, '应留出安全余量');
|
||
assert.equal(built.truncated, true, '应标记为已精简');
|
||
assert.ok(built.listedCount < 50, '应自动少列几条');
|
||
assert.ok(!built.text.includes('\uFFFD'), '不能出现被截断产生的乱码');
|
||
// 削掉明细后仍保留抬头和链接
|
||
assert.match(built.text, /【仓储物流转运异常】/);
|
||
assert.match(built.text, /查看全部/);
|
||
});
|
||
|
||
test('按字节截断工具:中文按 3 字节算,不切半个字', () => {
|
||
assert.deepEqual(sender.truncateBytes('abc', 10), { text: 'abc', truncated: false });
|
||
// 「中」= 3 字节:10 字节最多放 3 个字(9 字节)
|
||
const cut = sender.truncateBytes('中文字符', 10);
|
||
assert.equal(cut.text, '中文字');
|
||
assert.equal(cut.truncated, true);
|
||
assert.ok(Buffer.byteLength(cut.text, 'utf8') <= 10);
|
||
});
|
||
|
||
test('时区:容器是 UTC,推送时刻仍按北京时间判(09:10 不能变成 01:10)', () => {
|
||
// 2026-10-22T01:10Z = 北京时间 09:10
|
||
assert.equal(job.chinaHourMinute(new Date('2026-10-22T01:10:00Z')), '09:10');
|
||
assert.equal(job.chinaDateKey(new Date('2026-10-22T01:10:00Z')), '2026-10-22');
|
||
// 北京时间 00:30(UTC 前一天 16:30)→ 日期必须是新的一天
|
||
assert.equal(job.chinaDateKey(new Date('2026-10-21T16:30:00Z')), '2026-10-22');
|
||
assert.equal(job.chinaHourMinute(new Date('2026-10-21T16:30:00Z')), '00:30');
|
||
});
|
||
|
||
test('推送时间 / 明细条数:非法输入一律退回默认值,不能把定时任务配挂', () => {
|
||
assert.equal(settings.normalizeSendTime('08:05'), '08:05');
|
||
assert.equal(settings.normalizeSendTime('9:10'), '09:10');
|
||
assert.equal(settings.normalizeSendTime('25:00'), '09:10');
|
||
assert.equal(settings.normalizeSendTime(''), '09:10');
|
||
assert.equal(settings.normalizeSendTime(undefined), '09:10');
|
||
|
||
assert.equal(settings.normalizeMaxDetailRows(undefined), 30);
|
||
assert.equal(settings.normalizeMaxDetailRows(-5), 0);
|
||
assert.equal(settings.normalizeMaxDetailRows(999), 50);
|
||
assert.equal(settings.normalizeMaxDetailRows('12'), 12);
|
||
assert.equal(settings.normalizeMaxDetailRows('abc'), 30);
|
||
});
|
||
|
||
test('配置校验:只要有 Webhook 就算配好;加签密钥只回掩码', () => {
|
||
assert.deepEqual(settings.missingFields({ webhookUrl: '' }), ['Webhook 地址']);
|
||
assert.deepEqual(settings.missingFields({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }), []);
|
||
assert.equal(settings.isWarehouseLogisticsExceptionNotifyReady({}), false);
|
||
assert.equal(
|
||
settings.isWarehouseLogisticsExceptionNotifyReady({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }),
|
||
true,
|
||
'安全设置用「关键词」时不需要加签密钥'
|
||
);
|
||
|
||
const admin = settings.toAdminConfig({
|
||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t',
|
||
webhookSecret: 'SEC0123456789abcdef',
|
||
enabled: true,
|
||
});
|
||
assert.equal(admin.webhookSecretConfigured, true);
|
||
assert.ok(!JSON.stringify(admin).includes('SEC0123456789abcdef'), '明文加签密钥不能出现在返回体里');
|
||
assert.match(admin.webhookSecretPreview, /\*+/);
|
||
// Webhook 要完整回显:业务需要核对 access_token
|
||
assert.match(admin.webhookUrl, /access_token=t/);
|
||
});
|
||
|
||
test('Webhook 地址校验:必须是 robot/send 且带 access_token', () => {
|
||
const ok = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
|
||
assert.equal(sender.normalizeWebhookUrl(ok), ok);
|
||
|
||
assert.throws(() => sender.normalizeWebhookUrl(''), /请填写/);
|
||
assert.throws(() => sender.normalizeWebhookUrl('oapi.dingtalk.com/robot/send'), /不是合法 URL/);
|
||
// 常见的复制错误:贴成了企业内部应用的地址 / 少复制了路径
|
||
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/robot/send'), /缺少 access_token/);
|
||
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/topapi/robot/send?access_token=a'), /不像群机器人/);
|
||
});
|
||
|
||
test('加签:sign = urlEncode(base64(HmacSHA256(secret, `${ts}\\n${secret}`)))', () => {
|
||
const webhook = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
|
||
const secret = 'SECtestsecret';
|
||
const ts = 1700000000000;
|
||
|
||
const signed = new URL(sender.buildSignedUrl(webhook, secret, ts));
|
||
assert.equal(signed.searchParams.get('timestamp'), String(ts));
|
||
assert.equal(signed.searchParams.get('access_token'), 'abc123', '原参数要保留');
|
||
|
||
// 手工算一遍(顺序写错就是 310000,这条断言就是防这个)
|
||
const expected = crypto
|
||
.createHmac('sha256', secret)
|
||
.update(`${ts}\n${secret}`, 'utf8')
|
||
.digest('base64');
|
||
// searchParams 会自动 decode,所以这里拿到的是原始 base64(含 + / =)
|
||
assert.equal(signed.searchParams.get('sign'), expected);
|
||
|
||
// 没配密钥(安全设置用关键词/IP):只追加 timestamp,不带 sign
|
||
const plain = new URL(sender.buildSignedUrl(webhook, '', ts));
|
||
assert.equal(plain.searchParams.get('sign'), null);
|
||
assert.equal(plain.searchParams.get('timestamp'), String(ts));
|
||
});
|
||
|
||
// ── 钉钉通道:stub fetch 验请求形状与错误透传 ────────────────────────────────
|
||
|
||
function stubFetch(handler) {
|
||
const calls = [];
|
||
const original = global.fetch;
|
||
global.fetch = async (url, options = {}) => {
|
||
calls.push({ url: String(url), options });
|
||
const body = handler.body === undefined ? {} : handler.body;
|
||
return new Response(JSON.stringify(body), { status: handler.status || 200 });
|
||
};
|
||
return {
|
||
calls,
|
||
restore: () => { global.fetch = original; },
|
||
};
|
||
}
|
||
|
||
test('发文本:msgtype=text,加签参数拼在 URL 上,@所有人开关透传', async () => {
|
||
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
|
||
try {
|
||
const result = await sender.sendTextToWebhook({
|
||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=abc123',
|
||
secret: 'SECtestsecret',
|
||
content: '【仓储物流转运异常】测试',
|
||
atAll: true,
|
||
});
|
||
|
||
assert.equal(result.delivered, true);
|
||
assert.equal(result.errmsg, 'ok');
|
||
|
||
const call = stub.calls[0];
|
||
assert.equal(call.options.method, 'POST');
|
||
assert.match(call.options.headers['Content-Type'], /application\/json/);
|
||
// 加签后 URL 必须带 timestamp + sign
|
||
const url = new URL(call.url);
|
||
assert.ok(url.searchParams.get('timestamp'), '缺少 timestamp');
|
||
assert.ok(url.searchParams.get('sign'), '缺少 sign');
|
||
|
||
const body = JSON.parse(call.options.body);
|
||
assert.equal(body.msgtype, 'text');
|
||
assert.equal(body.text.content, '【仓储物流转运异常】测试');
|
||
assert.equal(body.at.isAtAll, true);
|
||
assert.deepEqual(body.at.atMobiles, []);
|
||
} finally {
|
||
stub.restore();
|
||
}
|
||
});
|
||
|
||
test('发文本:空内容不发请求(避免钉钉 400 把错误码埋掉)', async () => {
|
||
let touched = 0;
|
||
const original = global.fetch;
|
||
global.fetch = async () => { touched += 1; throw new Error('不应该发请求'); };
|
||
try {
|
||
await assert.rejects(
|
||
() => sender.sendTextToWebhook({
|
||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
|
||
content: ' ',
|
||
}),
|
||
/内容为空/
|
||
);
|
||
assert.equal(touched, 0);
|
||
} finally {
|
||
global.fetch = original;
|
||
}
|
||
});
|
||
|
||
test('发文本:钉钉 errcode 原样透出(310000 关键词/加签不匹配是最常见的坑)', async () => {
|
||
const stub = stubFetch({ body: { errcode: 310000, errmsg: 'sign not match' } });
|
||
try {
|
||
await assert.rejects(
|
||
() => sender.sendTextToWebhook({
|
||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
|
||
secret: 'SECwrong',
|
||
content: '【仓储物流转运异常】测试',
|
||
}),
|
||
(error) => {
|
||
assert.equal(error.code, 310000);
|
||
assert.match(error.message, /310000/);
|
||
assert.match(error.message, /sign not match/);
|
||
return true;
|
||
}
|
||
);
|
||
} finally {
|
||
stub.restore();
|
||
}
|
||
});
|
||
|
||
test('发文本:超长正文自动截断后仍会发出去(不是直接抛错)', async () => {
|
||
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
|
||
try {
|
||
const huge = '异常'.repeat(12000); // 24000 字 ≈ 72000 字节,远超上限
|
||
const result = await sender.sendTextToWebhook({
|
||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
|
||
content: huge,
|
||
});
|
||
assert.equal(result.truncated, true);
|
||
assert.ok(result.bytes <= sender.MAX_CONTENT_BYTES);
|
||
|
||
const body = JSON.parse(stub.calls[0].options.body);
|
||
assert.ok(Buffer.byteLength(body.text.content, 'utf8') <= sender.MAX_CONTENT_BYTES);
|
||
} finally {
|
||
stub.restore();
|
||
}
|
||
});
|