// 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 }); } });