Files
herryyun 7c25c89992
CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

69 lines
4.2 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createBindingApi, ensureBindingSession, preferredRole, roleKey } from '../../public/js/skland/api.js';
test('the title client puts its game token only in an authorization header and never sends a player id', async () => {
const calls = [];
const request = createBindingApi({ token: 'private-game-token', fetchImpl: async (url, init) => {
calls.push({ url, init }); return { ok: true, status: 200, json: async () => ({ ok: true }) };
} });
await request('status'); await request('password/login', { phone: '13800000000', password: 'local-input' });
assert.equal(calls[0].url, '/skland/session/status');
assert.equal(calls[0].init.headers.authorization, 'Bearer private-game-token');
assert.equal(calls[0].init.method, 'GET');
assert.equal(calls[1].init.method, 'POST');
assert.deepEqual(JSON.parse(calls[1].init.body), { phone: '13800000000', password: 'local-input' });
assert.ok(calls.every(({ url, init }) => !url.includes('private-game-token') && !init.body?.includes('private-game-token')));
});
test('the title client distinguishes disabled module, expired session, network and malformed responses', async () => {
for (const [status, code] of [[404, 'off'], [401, 'session']]) {
await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => ({ status }) })('status'), { code });
}
await assert.rejects(createBindingApi()('status'), { code: 'session' });
await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => { throw new Error('offline'); } })('status'), { code: 'network' });
await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => ({ status: 200, ok: true, json: async () => { throw new Error(); } }) })('status'), { code: 'response' });
});
test('binding starts hello without entering the game, reuses an online identity, and cleans up on failure', async () => {
const listeners = new Map();
let current = null, connects = 0, sentName;
const identity = { init: async () => {}, getToken: () => current };
const net = {
status: 'connected',
on: (event, fn) => { listeners.set(event, fn); return () => listeners.delete(event); },
setName: (name) => { sentName = name; },
connect: () => { connects++; setTimeout(() => { current = 'T'; listeners.get('welcome')?.({ token: 'T' }); }, 0); },
};
assert.equal(await ensureBindingSession({ net, identity, name: '博士代号' }), 'T');
assert.equal(sentName, '博士代号'); assert.equal(listeners.size, 0);
net.status = 'online';
assert.equal(await ensureBindingSession({ net, identity, name: '另一个名字' }), 'T');
assert.equal(connects, 1);
net.status = 'connected'; net.connect = () => {};
await assert.rejects(ensureBindingSession({ net, identity, name: '博士', timeoutMs: 1 }), { code: 'network' });
assert.equal(listeners.size, 0);
});
test('nickname selection uses the default role, preserves a selected channel, and supports no roles', () => {
const a = { uid: '123', channelName: '官服', gameId: 1, nickName: '官服博士' };
const b = { uid: '123', channelName: 'B服', gameId: 2, nickName: 'B服博士', isDefault: true };
assert.equal(preferredRole([a, b]), b);
assert.equal(preferredRole([a, b], roleKey(a)), a);
assert.equal(preferredRole([a, b], 'removed-role'), b);
assert.equal(preferredRole([a]), a);
assert.equal(preferredRole([]), null);
assert.equal(preferredRole([{ uid: 'missing-name', isDefault: true }, { ...b, nickName: ' ' }, a]), a);
assert.equal(preferredRole([{ uid: 'missing-name' }]), null);
assert.equal(preferredRole([{ uid: null, channelName: '官服', nickName: '缺少身份' }]), null);
assert.equal(roleKey(a), roleKey({ ...a, uid: 123, gameId: null, nickName: '改名以后' }), 'nickname and optional gameId never change identity');
});
test('an expired recovery is distinguishable from a transport error and never becomes a successful entry', async () => {
const request = createBindingApi({ token: 'T', fetchImpl: async () => ({ status: 409, ok: false,
json: async () => ({ ok: false, code: 'resume_expired', error: 'expired' }),
}) });
await assert.rejects(request('resume', { id: 'stale' }), { code: 'resume_expired' });
});