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
169 lines
7.1 KiB
JavaScript
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 }); }
|
|
});
|