跨端: 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
This commit is contained in:
@ -71,3 +71,57 @@ test('★ 契约层必须保持"无 @ohos 依赖"(否则这些判据跑不了
|
||||
'**正确修法**:把平台调用留在 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(那不是线上形状)。');
|
||||
});
|
||||
|
||||
@ -79,7 +79,7 @@ const SUITE = [
|
||||
['test/build-stamp.test.mjs', [], 7],
|
||||
['test/packaging.test.mjs', [], 5],
|
||||
['test/align-refs.test.mjs', [], 3],
|
||||
['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6],
|
||||
['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 12],
|
||||
['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 10],
|
||||
['test/debt-visibility.test.mjs', [], 1],
|
||||
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2],
|
||||
@ -270,6 +270,8 @@ for (const [file, flags, expected] of SUITE) {
|
||||
const marker = /RESULT pass=(\d+) fail=(\d+)/.exec(out);
|
||||
const nodeTest = /^# pass (\d+)/m.exec(out);
|
||||
const ran = marker ? Number(marker[1]) : (nodeTest ? Number(nodeTest[1]) : null);
|
||||
// 累计总数(见汇总末尾的 `RESULT files=…`)—— 解析成功就加,无论该文件红绿
|
||||
if (ran !== null) totalTests += ran;
|
||||
if (ran === null) {
|
||||
/*
|
||||
* 报错**自带修法**(pi 2026-09-14):这条契约的受众不只是读过规范的人 ——
|
||||
|
||||
Reference in New Issue
Block a user