Files
wownerogue/test/identityResolution.test.js
T
jwinterm 41676fa072 refactor: unify identity resolution on stable user_id (anon_token)
There were two identity systems: SessionManager keyed on the stable, UNIQUE
anon_token, and getOrCreateUser keyed on the mutable, non-unique socket_id.
The latter could create a duplicate "orphan" user row (or resolve the wrong
row) when the socket_id lookup raced the session's socket_id update —
desyncing credits and payouts between the two rows for one human.

- getOrCreateUser now resolves through SessionManager's stable identity first
  (anon_token -> users.id), re-selecting a fresh row by stable id, and only
  falls back to the legacy socket_id lookup when no session exists. The
  last-resort create now warns, since it indicates a missing session.
- gameModeManager.sessionManager is wired in SocketHandlers so the
  session-first path is active in production.
- anon_token is already UNIQUE (migration 003), so the stable-identity
  constraint already exists; no new migration needed.

Re-keying the in-memory activeGames map by user_id is deferred (invasive;
already mitigated by stamping dbUserId on the game in the disconnect fix).

Adds identityResolution.test.js proving no duplicate row is created when a
session exists. Full suite: 17 suites / 136 tests.
2026-05-31 10:15:08 -04:00

68 lines
3.0 KiB
JavaScript

/**
* Identity resolution tests (Phase 2.1).
*
* getOrCreateUser must resolve through the stable anon_token identity (via SessionManager)
* rather than the mutable, non-unique socket_id — and must NOT create a duplicate "orphan"
* user row when a session already exists.
*/
const GameModeManager = require('../src/game/gameModeManager');
const createMockPaymentConfig = () => ({
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: {
direct: { enabled: true, price: 100000000000n },
credits: { enabled: true, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] }
},
payouts: { rules: { direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } }, credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n } } },
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
});
function buildGmm() {
const calls = { byId: 0, bySocketSelect: 0, insert: 0 };
const db = {
query: jest.fn(async (text, params = []) => {
if (/SELECT \* FROM users WHERE id = \$1/i.test(text)) { calls.byId++; return { rows: [{ id: params[0], credits: 5, socket_id: 'sockNEW' }] }; }
if (/UPDATE users SET last_active/i.test(text)) return { rows: [] };
if (/SELECT \* FROM users WHERE socket_id = \$1/i.test(text)) { calls.bySocketSelect++; return { rows: [] }; }
if (/INSERT INTO users/i.test(text)) { calls.insert++; return { rows: [{ id: 99, socket_id: params[0] }] }; }
return { rows: [] };
})
};
const gmm = new GameModeManager(db, { processPayout: jest.fn() }, { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, createMockPaymentConfig());
return { gmm, db, calls };
}
describe('getOrCreateUser identity resolution', () => {
test('resolves through the session stable id and does not create a duplicate', async () => {
const { gmm, calls } = buildGmm();
// Session resolves the socket to stable user id 1 (cache populated at connect).
gmm.sessionManager = { getBySocket: jest.fn().mockResolvedValue({ id: 1 }) };
const user = await gmm.getOrCreateUser('sockNEW');
expect(user.id).toBe(1);
expect(gmm.sessionManager.getBySocket).toHaveBeenCalledWith('sockNEW');
expect(calls.byId).toBe(1); // resolved by stable id
expect(calls.insert).toBe(0); // NO duplicate orphan row created
expect(calls.bySocketSelect).toBe(0); // did not fall back to socket_id lookup
});
test('falls back to socket_id lookup when no session manager is wired', async () => {
const { gmm, calls } = buildGmm();
// No gmm.sessionManager (legacy path).
const user = await gmm.getOrCreateUser('sockLEGACY');
expect(calls.bySocketSelect).toBe(1); // used socket_id lookup
// socket_id lookup returned no rows in this mock -> created as last resort
expect(calls.insert).toBe(1);
expect(user.id).toBe(99);
});
});