pi 邮件 `2518e1a3` 的服务端事实:注销只认注册者本人、token 字符串不是凭证, **同一个 token 换账号登录是"转移"而不是并存** ⇒ "我登记过没有"的答案**随账号而变**。 原来的 `shouldReportToken(lastReported, current)` 只按 token 存标记 ⇒ 换账号后 (同一 token)会**错误地跳过上报**,而那个账号其实还没登记过这个 token。 改成 `reportMarker(accountKey, token) = accountKey|token` + `shouldReportToken(lastMarker, accountKey, token)`: **账号变了标记必然不同** —— 于是"换账号必然重新上报"是**性质**,不靠人记得 reset。 判据 13 条全绿(新增"换账号后必然重新上报 / 切回来也要重新上报",并断言两次 marker 不相等)。
144 lines
9.6 KiB
JavaScript
144 lines
9.6 KiB
JavaScript
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 不上报;同账号同 token 不上报;变了才上报', () => {
|
||
assert.equal(C.shouldReportToken('', 'acct1', ''), false, '取不到 token 就不许上报(静默跳过)');
|
||
const m = C.reportMarker('acct1', 'abc');
|
||
assert.equal(C.shouldReportToken(m, 'acct1', 'abc'), false, '没变就不许重复上报(否则每次启动打一次接口)');
|
||
assert.equal(C.shouldReportToken(m, 'acct1', 'xyz'), true, 'token 变了要上报');
|
||
assert.equal(C.shouldReportToken('', 'acct1', 'xyz'), true, '第一次拿到要上报');
|
||
});
|
||
|
||
/*
|
||
★ 账号必须绑进标记(pi 邮件 `2518e1a3` 的服务端事实):同一个 token **换账号登录是"转移"不是并存**,
|
||
所以"我登记过没有"的答案**随账号而变**。只按 token 存标记 ⇒ 换账号后**错误跳过上报**,
|
||
而那个账号其实没登记过。这条判据钉的是"换账号必然重新上报"这个性质。
|
||
*/
|
||
test('★ 换账号后必然重新上报(token 转移不是并存 —— 别把登记状态缓存成长期结论)', () => {
|
||
const token = 'AAAABBBBCCCC123456';
|
||
const m1 = C.reportMarker('acct1', token);
|
||
assert.equal(C.shouldReportToken(m1, 'acct1', token), false, '同账号同 token:不重复上报');
|
||
assert.equal(C.shouldReportToken(m1, 'acct2', token), true,
|
||
'换账号 + 同一个 token ⇒ 必须重新上报(服务端是转移,acct2 其实没登记过)');
|
||
assert.notEqual(m1, C.reportMarker('acct2', token), '标记的形状就保证账号变了必然不同(不靠人记得 reset)');
|
||
assert.equal(C.shouldReportToken(C.reportMarker('acct2', token), 'acct1', token), 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(那不是线上形状)。');
|
||
});
|