Files
ark-stronghold-protocol/test/skland/store.test.js
T
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

169 lines
7.1 KiB
JavaScript

// The per-player credential store (server/skland/store.js): node:sqlite in WAL mode, one row per player.
//
// The store is what survives a restart, so the round-trip of every column and the 解绑 semantics are pinned here.
// The file-backed cases also assert the WAL side files exist, which is what makes the SQLite path a "high-performance
// local database" rather than a map that happens to be persisted.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { createSklandStore } from '../../server/skland/store.js';
const tempDir = () => mkdtempSync(join(tmpdir(), 'sp-skland-'));
test('a player row starts unbound and ensure() is idempotent', () => {
const store = createSklandStore({ file: ':memory:' });
try {
store.ensure('p1');
store.ensure('p1');
const row = store.get('p1');
assert.equal(store.count(), 1);
assert.equal(row.player_id, 'p1');
assert.equal(row.status, 'unbound');
assert.equal(row.cred, null);
assert.equal(row.binding, null);
assert.ok(row.created_at > 0);
} finally { store.close(); }
});
test('get() of an unknown player is null; update() creates the row', () => {
const store = createSklandStore({ file: ':memory:' });
try {
assert.equal(store.get('nobody'), null);
store.update('p2', { status: 'bound', cred: 'C', cred_token: 'CT' });
assert.equal(store.get('p2').status, 'bound');
assert.equal(store.count(), 1, 'update() creates exactly one row');
} finally { store.close(); }
});
test('the whole login chain round-trips, including the binding JSON', () => {
const store = createSklandStore({ file: ':memory:' });
try {
const binding = { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士' }] }] };
store.update('p1', {
access_token: 'AT', device_token: 'DT', hg_id: 'HG', cred: 'C', cred_token: 'CT',
user_id: 'U', d_id: 'D', nickname: '博士', binding, status: 'bound',
});
const row = store.get('p1');
assert.equal(row.access_token, 'AT');
assert.equal(row.device_token, 'DT');
assert.equal(row.hg_id, 'HG');
assert.equal(row.cred, 'C');
assert.equal(row.cred_token, 'CT');
assert.equal(row.d_id, 'D');
assert.equal(row.nickname, '博士');
assert.deepEqual(row.binding, binding, 'binding is stored as JSON and parsed back');
} finally { store.close(); }
});
test('update() merges only the named fields', () => {
const store = createSklandStore({ file: ':memory:' });
try {
store.update('p1', { cred: 'C', status: 'bound', nickname: 'N' });
store.update('p1', { cred_token: 'CT' });
const row = store.get('p1');
assert.equal(row.cred, 'C');
assert.equal(row.nickname, 'N');
assert.equal(row.status, 'bound');
assert.equal(row.cred_token, 'CT');
} finally { store.close(); }
});
test('unbind() forgets every credential and the binding source but keeps the row', () => {
const store = createSklandStore({ file: ':memory:' });
try {
store.update('p1', {
access_token: 'AT', device_token: 'DT', hg_id: 'HG', cred: 'C', cred_token: 'CT',
user_id: 'U', d_id: 'D', nickname: 'N', binding: { list: [] }, binding_source: 'hypergryph', status: 'bound',
});
store.unbind('p1');
const row = store.get('p1');
assert.equal(row.status, 'unbound');
for (const field of ['access_token', 'hg_id', 'cred', 'cred_token', 'user_id', 'd_id', 'nickname']) {
assert.equal(row[field], null, `${field} is cleared`);
}
assert.equal(row.binding, null);
assert.equal(row.device_token, null, 'the device token is cleared with the rest of the credentials');
assert.equal(row.binding_source, 'skland', 'unbound rows return to the legacy default');
assert.equal(row.player_id, 'p1', 'the row stays so a rebind is cheap');
} finally { store.close(); }
});
test('a legacy database migrates without losing QR bindings and persists the official account source', () => {
const dir = tempDir();
const file = join(dir, 'legacy.db');
try {
const initial = createSklandStore({ file });
initial.update('old-player', { cred: 'OLD-CRED', device_token: 'OLD-DEVICE', status: 'bound', binding: { list: [] } });
initial.close();
const legacy = new DatabaseSync(file);
legacy.exec('ALTER TABLE skland_players DROP COLUMN binding_source');
legacy.close();
const migrated = createSklandStore({ file });
assert.equal(migrated.get('old-player').cred, 'OLD-CRED');
assert.equal(migrated.get('old-player').device_token, 'OLD-DEVICE');
assert.equal(migrated.get('old-player').binding_source, 'skland');
migrated.update('password-player', { access_token: 'HG-TOKEN', binding_source: 'hypergryph', status: 'bound', binding: { list: [] } });
migrated.close();
const reopened = createSklandStore({ file });
assert.equal(reopened.get('password-player').binding_source, 'hypergryph');
assert.equal(reopened.get('password-player').access_token, 'HG-TOKEN');
assert.equal(reopened.get('password-player').cred, null);
assert.equal(reopened.get('old-player').status, 'bound');
reopened.close();
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('remove() drops the row entirely', () => {
const store = createSklandStore({ file: ':memory:' });
try {
store.update('p1', { status: 'bound' });
store.remove('p1');
assert.equal(store.get('p1'), null);
assert.equal(store.count(), 0);
} finally { store.close(); }
});
test('list() returns every row, newest update first', () => {
const store = createSklandStore({ file: ':memory:' });
try {
store.update('a', { status: 'bound' });
store.update('b', { status: 'unbound' });
const ids = store.list().map((r) => r.player_id).sort();
assert.deepEqual(ids, ['a', 'b']);
} finally { store.close(); }
});
test('a file-backed store persists across instances and uses WAL', () => {
const dir = tempDir();
const file = join(dir, 'skland.db');
try {
const first = createSklandStore({ file });
first.update('p1', { cred: 'C', status: 'bound', binding: { list: [{ appCode: 'arknights', bindingList: [] }] } });
assert.equal(first.journalMode(), 'wal', 'WAL mode is on (readers do not block the writer)');
first.close();
assert.ok(existsSync(file), 'the database file exists on disk');
const second = createSklandStore({ file });
const row = second.get('p1');
assert.equal(row.cred, 'C', 'the credentials survive a restart');
assert.equal(row.status, 'bound');
assert.deepEqual(row.binding.list[0].appCode, 'arknights');
second.close();
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('the parent directory of the database file is created', () => {
const dir = tempDir();
const file = join(dir, 'nested', 'deeper', 'skland.db');
try {
const store = createSklandStore({ file });
store.update('p1', { status: 'bound' });
assert.ok(existsSync(file));
store.close();
} finally { rmSync(dir, { recursive: true, force: true }); }
});