Files
MailUI4Agents/client/electron/test/harmony-push.test.mjs
JianFeeeee 30886271ea 跨端: docs(引用锚): 把日期锚换成邮件 ID(pi 邮件 b1e23663:日期也是锚,写错一天下一个人找不到那封信)
pi 指出的锚错:`PushContract.ts` 里那段线上形状是 pi **09-15** 发的(邮件 `004983bb`),我写成了 09-14。
照他的推理("引用别人的工作状态时,哈希和'谁在读'一样会漂")往下再走一步:
**日期本身就是会漂的锚 —— 邮件 ID 不会。** 所以不是把 09-14 改成 09-15 就完事,
而是把这一族引用改成**可检索的标识**:

- `PushContract.ts`:契约来源 → `1f9ff3b4`;线上形状 → `004983bb`;
- `harmony-push.test.mjs`:同上两处;
- `Calendar.ts`:表头共用分叉、"今天"的调用侧 → `f60521de`;
- `ALIGN-REFS.json`:参照物版本登记 → `6e14b410`;圆角策略追认 → `90372ca1`;AGC 包名实测 → `1f9ff3b4`。

共 10 处。判据不依赖这些注释文本,改完 `harmony-push` 12/12、`harmony-calendar` 10/10、`align-refs` 3/3 仍绿;
`ALIGN-REFS.json` 仍是合法 JSON。

**没改的 4 处**(`NavItems.ts` 1 处、`Wallpaper.ts` 3 处):它们写于 09-14 那批往来,日期本身没被指出错,
而那几封的邮件 ID 我这边没有(跨过一次上下文压缩)——**宁可留着有争议的日期,也不编一个 ID**。
2026-09-15 11:51:39 +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 邮件 `1f9ff3b4`)。四条不变量里**三条是纯逻辑**,所以不需要设备就能钉:
静默失败、`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 邮件 `004983bb`,从 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(那不是线上形状)。');
});