Skip to content

Commit 3d9af10

Browse files
committed
fix: catchup shows no codes after startup backfill
Three bugs prevented /catchup from showing codes to newly registered users: 1. backfillHandler never persisted pending codes - incremented stats.pendingCodes counter but never called codeManager.addPendingCode(), so no codes were stored in the database for /catchup to find. 2. backfillHandler skipped all user redemptions - checked 'success' in userDetailsResponse which is never true for valid PlayerData responses (they have a 'details' property, not 'success'). Fixed by checking !playerData?.details instead. 3. codeManager was missing several methods used by production code and tests: normalizeCodeStatus, isCodeRedeemedByUser, getAllValidCodes, addNewPendingCodes, getRedeemedCodeCount, deleteUserRedeemedCodes, getRedeemedCodesByUsers, getServerCodeStats, getAggregateLoot (now backed by loot_totals table), and backfillLootTotals. Additional fixes in codeManager: - addRedeemedCode: normalize numeric status codes via /^\d+$/ guard, fix onConflictDoUpdate target to [code, discordId], propagate isPublic flag correctly, write loot aggregates to loot_totals on Success. - addPendingCode: add onConflictDoNothing() to make it idempotent. - getRedeemedCodeDetails: add optional offset parameter. - addNewPendingCodes: chunk inserts at 499 rows (2 params/row) to stay within SQLite 999-variable limit. - getRedeemedCodesFromList: chunk IN queries at 997 codes (+2 status params) to stay within SQLite 999-variable limit. - deleteUserRedeemedCodes: COUNT(*) + DELETE instead of RETURNING to avoid materialising a large deleted-row array. - getRedeemedCodesByUsers: chunk discordIds within per-query param budget. - Export CHEST_TYPE_NAMES and LootSummary type (used by codes.ts/stats.ts). backfillHandler fixes: - Validate instance_id is present and non-'0' before calling submitCode, matching the guard in autoRedeemer.ts. - Replace N+1 isCodeRedeemed/addPendingCode loop with a single getRedeemedCodesFromList query + bulk addNewPendingCodes insert. Adds end-to-end tests covering the startup-backfill -> setup -> catchup flow, including API failure fallback, duplicate backfill idempotency, invalid instance_id skip, and SQLite chunking boundary paths. Signed-off-by: Michael Cramer <michael@bigmichi1.de>
1 parent 8f207e4 commit 3d9af10

4 files changed

Lines changed: 655 additions & 238 deletions

File tree

src/bot/database/codeManager.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ describe('normalizeCodeStatus', () => {
5959
test('normalizes numeric string "4" to Code Expired', () => {
6060
expect(normalizeCodeStatus('4')).toBe('Code Expired');
6161
});
62+
test('treats partial-numeric strings as canonical (not parsed as int)', () => {
63+
expect(normalizeCodeStatus('4foo')).toBe('4foo');
64+
expect(normalizeCodeStatus('0bar')).toBe('0bar');
65+
});
6266
});
6367

6468
// ---------------------------------------------------------------------------
@@ -447,6 +451,61 @@ describe('deleteUserRedeemedCodes', () => {
447451
});
448452
});
449453

454+
// ---------------------------------------------------------------------------
455+
// getRedeemedCodesFromList
456+
// ---------------------------------------------------------------------------
457+
describe('getRedeemedCodesFromList', () => {
458+
test('returns empty set for empty codes list', async () => {
459+
const result = await codeManager.getRedeemedCodesFromList([]);
460+
expect(result.size).toBe(0);
461+
});
462+
test('returns empty set when no codes have been redeemed', async () => {
463+
const result = await codeManager.getRedeemedCodesFromList(['CODE1234ABCD']);
464+
expect(result.size).toBe(0);
465+
});
466+
test('returns codes with Success status', async () => {
467+
await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 'Success');
468+
const result = await codeManager.getRedeemedCodesFromList(['CODE1234ABCD', 'CODE1111AAAA']);
469+
expect(result.has('CODE1234ABCD')).toBe(true);
470+
expect(result.has('CODE1111AAAA')).toBe(false);
471+
});
472+
test('returns codes with Code Expired status', async () => {
473+
await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 'Code Expired');
474+
const result = await codeManager.getRedeemedCodesFromList(['CODE1234ABCD']);
475+
expect(result.has('CODE1234ABCD')).toBe(true);
476+
});
477+
test('excludes non-qualifying statuses like Invalid Parameters', async () => {
478+
await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 'Invalid Parameters');
479+
const result = await codeManager.getRedeemedCodesFromList(['CODE1234ABCD']);
480+
expect(result.has('CODE1234ABCD')).toBe(false);
481+
});
482+
test('chunks queries when codes list exceeds 997 (SQLite variable budget)', async () => {
483+
// Insert 998 codes — spans 2 chunks (chunk size = 997).
484+
const codes = Array.from({ length: 998 }, (_, i) => `CHUNK${String(i).padStart(4, '0')}AAAA`);
485+
for (const code of codes) {
486+
await codeManager.addRedeemedCode(code, USER_A, 'Success');
487+
}
488+
const result = await codeManager.getRedeemedCodesFromList(codes);
489+
expect(result.size).toBe(998);
490+
for (const code of codes) expect(result.has(code)).toBe(true);
491+
});
492+
});
493+
494+
// ---------------------------------------------------------------------------
495+
// addNewPendingCodes chunking
496+
// ---------------------------------------------------------------------------
497+
describe('addNewPendingCodes chunking', () => {
498+
test('chunks inserts when codes list exceeds 499 (2 params per row)', async () => {
499+
// 500 codes — spans 2 chunks (chunk size = 499).
500+
const codes = Array.from({ length: 500 }, (_, i) => `PEND${String(i).padStart(4, '0')}AAAA`);
501+
const inserted = await codeManager.addNewPendingCodes(codes);
502+
expect(inserted).toHaveLength(500);
503+
// Second call skips already-present codes across both chunks.
504+
const duplicate = await codeManager.addNewPendingCodes(codes);
505+
expect(duplicate).toHaveLength(0);
506+
});
507+
});
508+
450509
// ---------------------------------------------------------------------------
451510
// getRedeemedCodesByUsers
452511
// ---------------------------------------------------------------------------
@@ -511,6 +570,14 @@ describe('getRedeemedCodesByUsers', () => {
511570
'Too many codes provided to getRedeemedCodesByUsers'
512571
);
513572
});
573+
574+
test('chunks large discordIds lists to stay within SQLite param limit', async () => {
575+
await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 'Success');
576+
// With 1 code: chunkSize = 999 - 1 - 3 = 995. 996 ids forces a second chunk iteration.
577+
const manyIds = [USER_A, ...Array.from({ length: 995 }, (_, i) => `fake-user-${i}`)];
578+
const result = await codeManager.getRedeemedCodesByUsers(['CODE1234ABCD'], manyIds);
579+
expect(result.get(USER_A)?.has('CODE1234ABCD')).toBe(true);
580+
});
514581
});
515582

516583
// ---------------------------------------------------------------------------
@@ -786,4 +853,15 @@ describe('backfillLootTotals', () => {
786853
expect(loot.chests).toEqual({});
787854
expect(loot.items).toEqual({});
788855
});
856+
857+
test('skips rows with malformed JSON loot detail', async () => {
858+
db.insert(redeemedCodes)
859+
.values({ code: 'CODE1111AAAA', discordId: USER_A, status: 'Success', lootDetail: 'not-valid-json{' })
860+
.run();
861+
await codeManager.backfillLootTotals();
862+
const loot = await codeManager.getAggregateLoot(USER_A);
863+
expect(loot.chests).toEqual({});
864+
expect(loot.items).toEqual({});
865+
});
789866
});
867+

0 commit comments

Comments
 (0)