在「仓储中心物流转运异常」页新增「异常通知」:每天 09:10(可改)把当天核验批次
的异常情况以**文本消息**推到钉钉群,0 条也推(用来确认任务没挂)。
为什么是 text 而不是 Excel 附件:钉钉群自定义机器人 webhook 只支持
text/markdown/link/actionCard,发文件必须走企业内部应用(AppKey/AppSecret/
robotCode/openConversationId 四项凭据 + 应用权限 + 机器人进群)。业务确认
「群里看文本就够,要明细点链接去页面导出」,于是走最轻的 webhook。
后端
- 新增 services/dingtalkRobotWebhookSender.js:POST /robot/send + msgtype=text
· 加签 urlEncode(base64(HmacSHA256(secret, "timestamp\nsecret"))),timestamp 毫秒
· text.content 按 UTF-8 **字节**截断到 20000(钉钉硬上限),不切半个汉字
· webhook 严格匹配 ^/robot/send$ 且必须有 access_token;errcode/errmsg 原样抛
- 新增 services/warehouseLogisticsExceptionNotifySettings.js:
· webhookUrl 存在 system_config(完整回显,业务要核对);加签密钥走
resilientSecretStore,接口只回 configured + 掩码,留空=沿用已存
· sendTime 非法退回 09:10;maxDetailRows 0~50 默认 30
· 状态 lastSentDate(按推送日去重)等存 system_config
- 新增 jobs/warehouseLogisticsExceptionNotifyJob.js:
· buildNotifyText:抬头【仓储物流转运异常】→ 发货/包裹/异常/异常率 → 分类型
→ 剔除数 → 按仓(只列有异常的仓)→ 明细前 N 条 → 页面链接(带 date)
· 明细行「仓|内部单号|快递|异常类型|发货时间|待处理」,字段内换行压平
· 超长从明细末尾逐条砍并标记 truncated(truncated 只表示被字节上限砍过,
不表示「按配置只列前 N 条」)
· 每分钟 tick 比对北京时间 HH:mm == 配置 sendTime,改时间不用重启
- api:GET/PUT /notify/settings、GET /notify/state、POST /notify/run
(dryRun 预览不校验凭据;probe 只发一句测试文本、不查业务数据)
前端
- 「导出」右侧新增「异常通知」(铃铛)弹窗:开关、推送时间、Webhook、加签密钥、
明细条数、@所有人、页面链接、上次推送结果、三段帮助说明
- 「测试通道」「预览正文」「保存」「立即推送到群」
- 预览改成弹窗内受控区块:本项目跑 React 19 + antd v5,Modal.info 静态方法
拿不到 context 会**静默不渲染**(只留一行 warning,看着像后端没返回)
同步与兼容
- 新配置首次读取为空 → ready=false,页面提示「Webhook 地址还没配」,不影响页面其它功能
- 定时任务只在 enabled=true 时生效;本地 ENABLE_BACKGROUND_JOBS=false 不跑,用页面按钮验
验证
- backend/test/warehouseLogisticsExceptionNotify.test.js 15/15:正文结构与数字、
0 条平安消息、条数上限、字节精简、Webhook 校验、加签 crypto 手算对齐、errcode 透传
- 后端全量 465 项 / 463 过(2 项既有环境失败:Windows 权限位、负缓存需隧道故障态)
- 前端 eslint 0、build 成功
- 真数据干跑:144 条异常 / 列 30 条 / 3768 字节 / 20s,按仓包裹数合计 144 对得上
- playwright 真渲染:弹窗字段 Webhook/加签密钥/明细条数/@所有人/页面链接,
旧字段 AppKey/AppSecret/robotCode/群会话 ID 零残留;预览内联渲染出正文
风险与回退
- 钉钉侧失败只会记 lastState.lastError 并在页面展示,不影响页面取数
- 回退:删除 index.js 里的 startWarehouseLogisticsExceptionNotifyScheduler 注册即可停推送
395 lines
16 KiB
JavaScript
395 lines
16 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('明细行:仓|内部单号|快递|异常类型|发货时间|待处理,字段里换行要压平', () => {
|
||
const line = job.buildDetailLine({
|
||
warehouseName: '崔桥工厂仓',
|
||
oId: '9845001',
|
||
carrier: '京东快递',
|
||
exceptionLabel: '已揽收未中转',
|
||
sendTime: '2026-10-21 16:00:00',
|
||
pendingHours: 17.25,
|
||
lastLogisticInfo: '第一行\n第二行',
|
||
}, 3);
|
||
|
||
assert.equal(line, '3. 崔桥工厂仓|9845001|京东快递|已揽收未中转|10-21 16:00|17.3h');
|
||
|
||
// 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常)
|
||
assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁');
|
||
// 空值兜底:不能让消息里出现「undefined|null」
|
||
assert.equal(
|
||
job.buildDetailLine({}, 1),
|
||
'1. 未知仓|无单号|未知快递|异常|—|—'
|
||
);
|
||
});
|
||
|
||
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();
|
||
}
|
||
});
|