forked from Xeio/IdleCodeRedeemer
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathbackfillHandler.test.ts
More file actions
308 lines (255 loc) · 11.3 KB
/
Copy pathbackfillHandler.test.ts
File metadata and controls
308 lines (255 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
/**
* End-to-end scenario: startup backfill → user setup → /catchup
*
* Reproduces the bug observed in the production logs (2026-05-22):
*
* 1. Bot starts with an empty database.
* 2. Startup backfill runs, finds N codes, but has no registered users to
* redeem for → codes should be stored in pending_codes so they survive
* until a user registers.
* 3. User runs /setup (registers credentials).
* 4. User runs /catchup → expects to see codes available, NOT "No Codes
* Available".
*
* Secondary scenario: backfill runs after a user is registered but the API
* returns an error for that user → unredeemed codes must still be persisted
* as pending so /catchup can pick them up.
*/
import { describe, test, expect, beforeAll, beforeEach, afterAll, spyOn } from 'bun:test';
import { ChannelType } from 'discord.js';
import { db, initializeDatabase } from '../database/db';
import { users, redeemedCodes, pendingCodes, auditLog, backfillOperations } from '../database/schema/index';
import { userManager } from '../database/userManager';
import { codeManager } from '../database/codeManager';
import IdleChampionsApi from '../api/idleChampionsApi';
import { backfillChannelHistory } from './backfillHandler';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const CODE_A = 'LATU1234EGIS'; // 12-char code recognised by the scanner
const CODE_B = 'WXYZ5678IJKL';
const USER = 'discord-e2e-user';
const MOCK_SERVER = 'test.idlechampions.com';
const MOCK_INSTANCE = 'inst-e2e-abc';
// ---------------------------------------------------------------------------
// Fake Discord channel
//
// backfillChannelHistory() calls channel.messages.fetch({ limit, before? }).
// The first call returns messages; subsequent calls return an empty collection
// to stop the pagination loop.
//
// If DISCORD_CODE_AUTHOR_ID is set (loaded from .env by Bun), the handler
// only scans messages whose author.id matches. Our fake messages must use
// that ID so they are treated as code candidates.
// ---------------------------------------------------------------------------
const CODE_AUTHOR_ID = process.env.DISCORD_CODE_AUTHOR_ID || 'fake-code-poster';
function makeChannel(codes: string[]): any {
let fetched = false;
return {
type: ChannelType.GuildText,
name: 'idlecode',
messages: {
async fetch(_opts: unknown) {
if (fetched) {
// Terminate the pagination loop
return makeCollection([]);
}
fetched = true;
return makeCollection(
codes.map((code, i) => ({
id: String(1_000_000 + i),
content: code,
author: { id: CODE_AUTHOR_ID, tag: 'Poster#0001', bot: false },
webhookId: null,
createdAt: new Date(),
}))
);
},
},
};
}
/** Minimal Map that also exposes `last()` and `size`, matching discord.js Collection. */
function makeCollection(messages: Array<{ id: string; [key: string]: unknown }>) {
const map = new Map(messages.map((m) => [m.id, m]));
(map as any).last = () => {
const vals = [...map.values()];
return vals[vals.length - 1];
};
return map;
}
// ---------------------------------------------------------------------------
// API spies
// ---------------------------------------------------------------------------
const getUserDetailsSpy = spyOn(IdleChampionsApi, 'getUserDetails');
const submitCodeSpy = spyOn(IdleChampionsApi, 'submitCode');
const getServerSpy = spyOn(IdleChampionsApi, 'getServer');
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeAll(() => {
initializeDatabase();
});
beforeEach(() => {
// Wipe tables in FK-safe order.
db.delete(auditLog).run();
db.delete(backfillOperations).run();
db.delete(pendingCodes).run();
db.delete(redeemedCodes).run();
db.delete(users).run();
// Default spy behaviour (override per test as needed).
getUserDetailsSpy.mockReset();
submitCodeSpy.mockReset();
getServerSpy.mockReset();
getUserDetailsSpy.mockResolvedValue({ details: { instance_id: MOCK_INSTANCE } } as any);
submitCodeSpy.mockResolvedValue({ codeStatus: 0 } as any);
getServerSpy.mockResolvedValue(MOCK_SERVER as any);
});
afterAll(() => {
getUserDetailsSpy.mockRestore();
submitCodeSpy.mockRestore();
getServerSpy.mockRestore();
db.delete(auditLog).run();
db.delete(backfillOperations).run();
db.delete(pendingCodes).run();
db.delete(redeemedCodes).run();
db.delete(users).run();
});
// ---------------------------------------------------------------------------
// Scenario 1 — startup backfill with NO registered users
// ---------------------------------------------------------------------------
describe('Scenario 1: startup backfill with no users → setup → catchup', () => {
test('backfill stores unredeemed codes as pending when no users exist', async () => {
const stats = await backfillChannelHistory(makeChannel([CODE_A, CODE_B]));
expect(stats.codesFound).toBe(2);
expect(stats.codesRedeemed).toBe(0);
// Core assertion: codes must be persisted so /catchup can find them later.
const stored = await codeManager.getPendingCodes();
expect(stored).toContain(CODE_A);
expect(stored).toContain(CODE_B);
});
test('codes stored by startup backfill are visible to catchup after user setup', async () => {
// Phase 1 — startup backfill (no users).
await backfillChannelHistory(makeChannel([CODE_A, CODE_B]));
// Phase 2 — user registers (/setup).
await userManager.saveCredentials({
discordId: USER,
userId: '316463',
userHash: 'f4e6d3dbc34173d23e7d198e4a8fc773',
server: MOCK_SERVER,
});
// Phase 3 — /catchup queries: what codes are available?
const [validCodes, pending] = await Promise.all([
codeManager.getAllValidCodes(),
codeManager.getPendingCodes(),
]);
// No one has successfully redeemed yet → validCodes empty.
expect(validCodes).toHaveLength(0);
// The pending codes stored by backfill must be here.
expect(pending).toContain(CODE_A);
expect(pending).toContain(CODE_B);
// Combined (mirrors the dedup logic in catchup.ts).
const allCodes = Array.from(new Set([...validCodes, ...pending]));
expect(allCodes).toHaveLength(2);
});
test('after user setup the next backfill redeems codes immediately (no pending leftover)', async () => {
// User is already registered before the backfill this time.
await userManager.saveCredentials({
discordId: USER,
userId: '316463',
userHash: 'f4e6d3dbc34173d23e7d198e4a8fc773',
server: MOCK_SERVER,
});
const stats = await backfillChannelHistory(makeChannel([CODE_A, CODE_B]));
expect(stats.codesFound).toBe(2);
expect(stats.codesRedeemed).toBe(2);
// Codes were redeemed → isCodeRedeemed returns true → they are NOT stored
// in pending_codes.
const pending = await codeManager.getPendingCodes();
expect(pending).not.toContain(CODE_A);
expect(pending).not.toContain(CODE_B);
// They should be in redeemed_codes with Success status.
expect(await codeManager.isCodeRedeemedByUser(CODE_A, USER)).toBe(true);
expect(await codeManager.isCodeRedeemedByUser(CODE_B, USER)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Scenario 2 — backfill runs after setup but API rejects user details
// (mirrors the "Failed to get user details" warnings in the log)
// ---------------------------------------------------------------------------
describe('Scenario 2: backfill with API failure → codes stay pending for catchup', () => {
test('stores codes as pending when getUserDetails returns an invalid response', async () => {
await userManager.saveCredentials({
discordId: USER,
userId: '316463',
userHash: 'f4e6d3dbc34173d23e7d198e4a8fc773',
server: MOCK_SERVER,
});
// Simulate the "not a valid response" warning seen in the logs.
getUserDetailsSpy.mockResolvedValue({ status: 1 } as any);
const stats = await backfillChannelHistory(makeChannel([CODE_A, CODE_B]));
expect(stats.codesFound).toBe(2);
expect(stats.codesRedeemed).toBe(0);
// Despite the API failure, codes must be stored so /catchup can retry.
const pending = await codeManager.getPendingCodes();
expect(pending).toContain(CODE_A);
expect(pending).toContain(CODE_B);
});
test('/catchup sees codes pending from a failed backfill and can redeem them', async () => {
await userManager.saveCredentials({
discordId: USER,
userId: '316463',
userHash: 'f4e6d3dbc34173d23e7d198e4a8fc773',
server: MOCK_SERVER,
});
// Backfill fails → codes become pending.
getUserDetailsSpy.mockResolvedValue({ status: 1 } as any);
await backfillChannelHistory(makeChannel([CODE_A, CODE_B]));
// Restore the API for the catchup phase.
getUserDetailsSpy.mockResolvedValue({ details: { instance_id: MOCK_INSTANCE } } as any);
// /catchup collects codes the same way the command does.
const [validCodes, pending] = await Promise.all([
codeManager.getAllValidCodes(),
codeManager.getPendingCodes(),
]);
const allCodes = Array.from(new Set([...validCodes, ...pending]));
// Must NOT be empty — that was the reported bug.
expect(allCodes.length).toBeGreaterThan(0);
expect(allCodes).toContain(CODE_A);
expect(allCodes).toContain(CODE_B);
});
});
// ---------------------------------------------------------------------------
// Scenario 3 — idempotency: running backfill twice does not duplicate pending
// ---------------------------------------------------------------------------
describe('Scenario 3: duplicate backfill runs do not create duplicate pending codes', () => {
test('second backfill run does not insert the same pending code twice', async () => {
const channel1 = makeChannel([CODE_A]);
const channel2 = makeChannel([CODE_A]);
await backfillChannelHistory(channel1);
await backfillChannelHistory(channel2);
const pending = await codeManager.getPendingCodes();
const hits = pending.filter((c) => c === CODE_A);
expect(hits).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// Scenario 4 — invalid instance_id skips submission but stores codes as pending
// ---------------------------------------------------------------------------
describe('Scenario 4: invalid instance_id skips code submission', () => {
test('codes become pending when instance_id is missing from user details', async () => {
await userManager.saveCredentials({
discordId: USER,
userId: '316463',
userHash: 'f4e6d3dbc34173d23e7d198e4a8fc773',
server: MOCK_SERVER,
});
// details object exists but instance_id is absent (falsy → treated as '0')
getUserDetailsSpy.mockResolvedValue({ details: {} } as any);
const stats = await backfillChannelHistory(makeChannel([CODE_A, CODE_B]));
expect(stats.codesRedeemed).toBe(0);
expect(submitCodeSpy).not.toHaveBeenCalled();
const pending = await codeManager.getPendingCodes();
expect(pending).toContain(CODE_A);
expect(pending).toContain(CODE_B);
});
});