cangku/backend/test/warehouseLogisticsExceptionNotify.test.js
yanhui fe195114e4 feat(wle): 钉钉推送明细按仓聚集,并给每个仓保底 1 条
业务要求:推送明细「按照仓库来,相同仓库排序在一起」,方便各仓在群里只看自己那一段。

改动:
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 本提交。
2026-10-22 14:02:43 +08:00

498 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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();
}
});