Files
MailUI4Agents/client/electron/test/harmony-push.test.mjs
JianFeeeee ed0ad2508e 跨端: feat(推送客户端): 按线上形状补契约层(DELETE 也带 body / 未知字段 400 / provider 无白名单 / 错误是 {"error"})
pi 2026-09-14 给的线上形状(从 handler/push.go 读的,不是猜的)逐条落成可判的:

- **请求体只放已知键**:`PUSH_BODY_KEYS` 登记四个键,**未知字段服务端直接 400**(不是静默忽略)
  ⇒ 拼错会立刻可见;可选字段为空就不放(空串虽合法,但不放更不容易踩校验)。
- **provider 只做形状校验、没有白名单**(`^[a-z0-9_-]{1,32}$`):判据断言 `apns`/`fcm` 也合法 ——
  客户端**不许**硬编码"只有 hms 合法"去先拦一道(服务端没实现的通道不该变成客户端的 400)。
  这正是"校验的范围必须等于它真正知道的事"的又一落点。
- **token**:空非法、512 上限;形状不合法 ⇒ `buildTokenBody` 返回 undefined,调用侧**静默跳过**,
  不去打一次注定 400 的请求。
- **错误体是 `{"error"}` 不是 `{"message"}`**:判据专门断言 `{"message":"x"}` 解析出 undefined
  (用错键会把"没有消息"当有消息)。
- **注销:`deleted:false`(本来没登记)不是失败** ⇒ 它**不改变分类**,所以**不再是入参**
  (一个不影响结果的入参只会让人误以为它影响结果),判据断 `classifyUnregister.length === 2` 钉住这点。
- **`ApiClient.del` 补可选 JSON body**:DELETE 端点是 JSON body 形状(不是 query、不是 path 参数),
  原来只有 `path` ⇒ 注销会无效。不传 body 时行为与旧版完全一致(向后兼容)。
EOF
2026-09-15 11:43:22 +08:00

128 lines
8.4 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.

import test from 'node:test';
import assert from 'node:assert/strict';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { code } from './lib/read.mjs';
/*
推送客户端契约层(pi 2026-09-14)。四条不变量里**三条是纯逻辑**,所以不需要设备就能钉:
静默失败、`enabled:false` 是正常态、按 `provider+tail` 比、通知按 `mail_id` 去重。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const HARMONY = join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'model');
const C = await import(pathToFileURL(join(HARMONY, 'PushContract.ts')).href);
test('★ 上报决策:空 token 不上报;与上次相同不上报;变了才上报', () => {
assert.equal(C.shouldReportToken('', ''), false, '取不到 token 就不许上报(静默跳过)');
assert.equal(C.shouldReportToken('abc', 'abc'), false, '没变就不许重复上报(否则每次启动打一次接口)');
assert.equal(C.shouldReportToken('abc', 'xyz'), true, '变了要上报');
assert.equal(C.shouldReportToken('', 'xyz'), true, '第一次拿到要上报');
});
test('★ "登记过没有"只按 provider + tail 比 —— 比全文是"看起来更严、其实永远为假"的写法', () => {
const token = 'AAAABBBBCCCC123456';
const list = [{ provider: 'hms', token_tail: '123456' }];
assert.equal(C.tokenTail(token), '123456', 'tail 取尾 6 位(与服务端同口径)');
assert.equal(C.isRegistered(list, 'hms', token), true, '尾 6 位相同就算登记过');
// 反证:为什么不能比全文 —— 服务端只回 tail,全文永远不等于 tail
assert.notEqual(token, list[0].token_tail, 'GET 只回尾 6 位');
assert.equal(C.isRegistered(list, 'apns', token), false, 'provider 维度必须参与比较');
assert.equal(C.isRegistered(list, 'hms', 'ZZZZZZZZZZZZ123456'), true,
'同尾 6 位即视为同一条 —— 这是服务端给的信息量的上界,不是我们的选择');
assert.equal(C.tokenTail('abc'), 'abc', '短于 6 位时取全文(不补零、不截空)');
});
test('★ 上报结果分类:三种里没有一种是"提示失败"(enabled:false 是正常态)', () => {
assert.equal(C.classifyRegister(true, false), 'ok-enabled');
assert.equal(C.classifyRegister(false, false), 'ok-disabled', 'enabled:false 是正常态 ⇒ 不重试、不提示');
assert.equal(C.classifyRegister(true, true), 'silent-skip', '失败归 silent-skip,即使服务端 enabled=true');
assert.equal(C.classifyRegister(false, true), 'silent-skip');
});
test('★ 通知 data:不满足约定形状就静默忽略(不"尽力打开某个页面")', () => {
const ok = C.parseNotificationData(JSON.stringify({ type: 'new_mail', mail_id: 'm1', session_id: 's1', action: 'open_mail' }));
assert.equal(ok.mail_id, 'm1');
assert.equal(ok.session_id, 's1');
assert.equal(C.parseNotificationData(''), undefined, '空串');
assert.equal(C.parseNotificationData('{不是 json'), undefined, '坏 JSON 不许抛(推送是可选通道)');
assert.equal(C.parseNotificationData(JSON.stringify({ action: 'open_mail' })), undefined, '缺 mail_id');
assert.equal(C.parseNotificationData(JSON.stringify({ mail_id: 'm1', action: 'other' })), undefined, '动作不是 open_mail');
assert.equal(C.parseNotificationData(JSON.stringify({ mail_id: '', action: 'open_mail' })), undefined, '空 mail_id');
});
test('★ 去重台账:服务端无幂等键 ⇒ 重复保护落客户端;且有界', () => {
const led = new C.NotificationLedger(3);
assert.equal(led.shouldHandle('m1'), true, '第一次该处理');
assert.equal(led.shouldHandle('m1'), false, '重复必须丢弃(服务端至多一次、无幂等键)');
assert.equal(led.shouldHandle('m2'), true);
assert.equal(led.shouldHandle(''), false, '空 id 不处理');
assert.equal(led.size(), 2);
led.shouldHandle('m3'); led.shouldHandle('m4');
assert.equal(led.size(), 3, '有界(常驻 pane 不许无限长)');
assert.equal(led.shouldHandle('m1'), true, '最旧的被挤出后可再处理(有界台账的代价,明写在这里)');
});
test('★ 契约层必须保持"无 @ohos 依赖"(否则这些判据跑不了,会退化成必须上设备)', () => {
// 扫 code()(去注释):第一次跑这条时它咬到了**解释这条规则的那行注释** —— 与扫描口径同族的现成例子
const src = code(join(HARMONY, 'PushContract.ts'));
assert.ok(!/@ohos|@kit\./.test(src),
'PushContract.ts 里出现了 @ohos/@kit 依赖 ⇒ 判据将无法用 node 直接跑。' +
'**正确修法**:把平台调用留在 PushService.ets,纯决策留在这里(与 Calendar/Wallpaper 同模式)。' +
'**最常见的错误修法**:把这条断言删掉,让契约层的判据跟着一起失效。');
});
/*
★ 线上形状(pi 2026-09-14,从 handler/push.go 读的):请求体字段名必须逐字一致
(**未知字段直接 400**,不是静默忽略),provider 只做形状校验、**没有白名单**,
错误是 `{"error"}` 不是 `{"message"}`。这些都能在纯逻辑侧判。
*/
test('★ 请求体只放已知键(未知字段服务端直接 400,拼错会立刻可见)', () => {
const b = C.buildTokenBody('hms', 'tok123', '我的手机', 's1');
assert.deepEqual(Object.keys(b).sort(), ['device_name', 'provider', 'session_id', 'token']);
for (const k of Object.keys(b)) assert.ok(C.PUSH_BODY_KEYS.includes(k), `不许出现未登记的键:${k}`);
const minimal = C.buildTokenBody('hms', 'tok123', '', '');
assert.deepEqual(Object.keys(minimal).sort(), ['provider', 'token'], '可选字段为空就不放(空串虽合法,但不放更不容易踩校验)');
});
test('★ provider 只做形状校验、没有白名单 —— 不许硬编码"只有 hms 合法"', () => {
assert.equal(C.isValidProvider('hms'), true);
assert.equal(C.isValidProvider('apns'), true, '服务端没实现的通道也不该变成客户端的 400');
assert.equal(C.isValidProvider('fcm'), true);
assert.equal(C.isValidProvider('HMS'), false, '大写非法(形状规则是小写字母/数字/下划线/连字符)');
assert.equal(C.isValidProvider(''), false, '空非法');
assert.equal(C.isValidProvider('a'.repeat(33)), false, '最长 32');
assert.equal(C.isValidProvider('a'.repeat(32)), true);
assert.equal(C.isValidProvider('has space'), false);
});
test('★ token 形状:空非法、512 上限;形状不合法就不去打注定 400 的请求', () => {
assert.equal(C.isValidToken(''), false);
assert.equal(C.isValidToken('x'.repeat(512)), true);
assert.equal(C.isValidToken('x'.repeat(513)), false);
assert.equal(C.buildTokenBody('hms', '', '', ''), undefined, '空 token ⇒ 组不出请求体(调用侧静默跳过)');
assert.equal(C.buildTokenBody('HMS', 'tok', '', ''), undefined, 'provider 形状非法 ⇒ 同样组不出');
});
test('★ 错误体是 {"error"} 不是 {"message"}(形状不对就不当错误消息用)', () => {
assert.equal(C.parseErrorBody('{"error":"token 非法"}'), 'token 非法');
assert.equal(C.parseErrorBody('{"message":"x"}'), undefined, '{"message"} 是另一种形状 —— 不能当错误消息');
assert.equal(C.parseErrorBody('not json'), undefined);
assert.equal(C.parseErrorBody(''), undefined);
assert.equal(C.parseErrorBody('{"error":""}'), undefined, '空消息等于没有消息');
});
test('★ 注销:deleted:false(本来没登记)不是失败;deleted 不参与分类', () => {
assert.equal(C.classifyUnregister(false, false), 'ok-disabled', '没配凭证 + 没登记过 ⇒ 正常态');
assert.equal(C.classifyUnregister(true, false), 'ok-enabled');
assert.equal(C.classifyUnregister(false, true), 'silent-skip', '真失败才归 silent-skip');
assert.equal(C.classifyUnregister.length, 2, 'deleted 不影响结果 ⇒ 它不该是入参(否则读代码的人会以为它影响结果)');
});
test('★ DELETE 也带 JSON body(不是 query / 不是 path 参数)—— ApiClient 必须支持', () => {
const api = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'api', 'ApiClient.ets'));
assert.match(api, /async del<T>\(path: string, bodyObj\?: Object\)/,
'DELETE 端点要 JSON body,而`del`只有 path ⇒ 推送的注销调用会 400/无效。' +
'**正确修法**:给 del 加可选 body(向后兼容)。**最常见的错误修法**:把 token 拼进 URL(那不是线上形状)。');
});