Skip to content

Commit d7bebce

Browse files
authored
Merge pull request #102
2 parents bd0f38d + 47cf5f7 commit d7bebce

2 files changed

Lines changed: 729 additions & 3 deletions

File tree

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { AdminStoreError, createSpacetimeAdminStore, type AdminAuditLog } from './adminStore.js';
3+
import type { SpacetimeAdminConfig } from './config.js';
4+
5+
// Need to export the internal row type for tests — but it's not exported.
6+
// We reference it via the auditLog contract instead.
7+
type AuditRow = Parameters<AdminAuditLog['append']>[0];
8+
9+
const testConfig: SpacetimeAdminConfig = {
10+
uri: 'https://stdb.example',
11+
database: 'test-db',
12+
};
13+
14+
function rowsToSql(rows: Record<string, unknown>[]): string {
15+
if (rows.length === 0) return '';
16+
const columns = Object.keys(rows[0]);
17+
return JSON.stringify([{
18+
schema: { elements: columns.map(col => ({ name: col })) },
19+
rows: rows.map(row => columns.map(col => row[col])),
20+
}]);
21+
}
22+
23+
function accountRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
24+
return {
25+
id: 'telegram:99',
26+
name: 'Alice',
27+
rating: 1200,
28+
wins: 1,
29+
losses: 0,
30+
balance: 500,
31+
balance_kind: 'paid_elm',
32+
season_points: 50,
33+
...overrides,
34+
};
35+
}
36+
37+
function playerRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
38+
return {
39+
identity: '0xabc123',
40+
name: 'Alice',
41+
online: false,
42+
rating: 1200,
43+
wins: 1,
44+
losses: 0,
45+
balance: 500,
46+
balance_kind: 'paid_elm',
47+
account_id: 'telegram:99',
48+
season_points: 50,
49+
...overrides,
50+
};
51+
}
52+
53+
function makeFetch(
54+
accounts: Record<string, unknown>[] = [],
55+
players: Record<string, unknown>[] = [],
56+
): { fetchImpl: typeof fetch; sqlCalls: () => string[] } {
57+
const calls: string[] = [];
58+
const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => {
59+
const query = (init?.body as string) ?? '';
60+
calls.push(query);
61+
if (query.startsWith('SELECT * FROM account')) return new Response(rowsToSql(accounts), { status: 200 });
62+
if (query.startsWith('SELECT * FROM player')) return new Response(rowsToSql(players), { status: 200 });
63+
return new Response('', { status: 200 });
64+
});
65+
return { fetchImpl: fetchImpl as unknown as typeof fetch, sqlCalls: () => calls };
66+
}
67+
68+
function makeAuditLog(): { log: AdminAuditLog; rows: () => AuditRow[] } {
69+
const rows: AuditRow[] = [];
70+
const log: AdminAuditLog = {
71+
append: vi.fn(async row => { rows.push(row); }),
72+
read: vi.fn(async () => rows),
73+
};
74+
return { log, rows: () => rows };
75+
}
76+
77+
describe('createSpacetimeAdminStore adjustBalance', () => {
78+
it('credits increase account balance and produce a positive delta audit row', async () => {
79+
const { fetchImpl, sqlCalls } = makeFetch([accountRow()]);
80+
const { log, rows } = makeAuditLog();
81+
const store = createSpacetimeAdminStore(testConfig, fetchImpl, log);
82+
83+
const result = await store.adjustBalance({
84+
admin: { telegramId: 1 },
85+
accountId: 'telegram:99',
86+
balanceKind: 'paid_elm',
87+
operation: 'credit',
88+
amount: 200,
89+
});
90+
91+
expect(result.account.balance).toBe(700);
92+
expect(result.audit.operation).toBe('credit');
93+
expect(result.audit.previousBalance).toBe(500);
94+
expect(result.audit.newBalance).toBe(700);
95+
expect(result.audit.delta).toBe(200);
96+
97+
const updateCall = sqlCalls().find(q => q.startsWith('UPDATE account SET balance'));
98+
expect(updateCall).toMatch(/balance = 700/);
99+
expect(rows()).toHaveLength(1);
100+
expect(rows()[0].delta).toBe(200);
101+
});
102+
103+
it('debits decrease account balance and produce a negative delta audit row', async () => {
104+
const { fetchImpl, sqlCalls } = makeFetch([accountRow()]);
105+
const { log, rows } = makeAuditLog();
106+
const store = createSpacetimeAdminStore(testConfig, fetchImpl, log);
107+
108+
const result = await store.adjustBalance({
109+
admin: { telegramId: 1 },
110+
accountId: 'telegram:99',
111+
balanceKind: 'paid_elm',
112+
operation: 'debit',
113+
amount: 100,
114+
});
115+
116+
expect(result.account.balance).toBe(400);
117+
expect(result.audit.delta).toBe(-100);
118+
expect(sqlCalls().find(q => q.startsWith('UPDATE account SET balance'))).toMatch(/balance = 400/);
119+
expect(rows()[0].operation).toBe('debit');
120+
});
121+
122+
it('set changes balance to exact value regardless of previous balance', async () => {
123+
const { fetchImpl, sqlCalls } = makeFetch([accountRow()]);
124+
const { log, rows } = makeAuditLog();
125+
const store = createSpacetimeAdminStore(testConfig, fetchImpl, log);
126+
127+
const result = await store.adjustBalance({
128+
admin: { telegramId: 1 },
129+
accountId: 'telegram:99',
130+
balanceKind: 'paid_elm',
131+
operation: 'set',
132+
amount: 300,
133+
});
134+
135+
expect(result.account.balance).toBe(300);
136+
expect(result.audit.delta).toBe(-200);
137+
expect(sqlCalls().find(q => q.startsWith('UPDATE account SET balance'))).toMatch(/balance = 300/);
138+
expect(rows()[0].operation).toBe('set');
139+
});
140+
141+
it('synchronizes linked player balance in the same operation', async () => {
142+
const { fetchImpl, sqlCalls } = makeFetch([accountRow()], [playerRow()]);
143+
const { log } = makeAuditLog();
144+
const store = createSpacetimeAdminStore(testConfig, fetchImpl, log);
145+
146+
await store.adjustBalance({
147+
admin: { telegramId: 1 },
148+
accountId: 'telegram:99',
149+
balanceKind: 'paid_elm',
150+
operation: 'credit',
151+
amount: 50,
152+
});
153+
154+
const playerUpdate = sqlCalls().find(q => q.startsWith('UPDATE player SET balance'));
155+
expect(playerUpdate).toMatch(/balance = 550/);
156+
});
157+
158+
it('skips balance_event insert when delta is zero (set to same value)', async () => {
159+
const { fetchImpl, sqlCalls } = makeFetch([accountRow()]);
160+
const { log } = makeAuditLog();
161+
const store = createSpacetimeAdminStore(testConfig, fetchImpl, log);
162+
163+
await store.adjustBalance({
164+
admin: { telegramId: 1 },
165+
accountId: 'telegram:99',
166+
balanceKind: 'paid_elm',
167+
operation: 'set',
168+
amount: 500,
169+
});
170+
171+
const balanceInsert = sqlCalls().find(q => q.startsWith('INSERT INTO balance_event'));
172+
expect(balanceInsert).toBeUndefined();
173+
});
174+
175+
it('writes audit via SQL when no auditLog is provided', async () => {
176+
const { fetchImpl, sqlCalls } = makeFetch([accountRow()]);
177+
const store = createSpacetimeAdminStore(testConfig, fetchImpl);
178+
179+
await store.adjustBalance({
180+
admin: { telegramId: 1 },
181+
accountId: 'telegram:99',
182+
balanceKind: 'paid_elm',
183+
operation: 'credit',
184+
amount: 100,
185+
});
186+
187+
const auditInsert = sqlCalls().find(q => q.startsWith('INSERT INTO admin_audit_event'));
188+
expect(auditInsert).toBeDefined();
189+
});
190+
191+
it('includes admin telegramId and reason in audit row', async () => {
192+
const { fetchImpl } = makeFetch([accountRow()]);
193+
const { log, rows } = makeAuditLog();
194+
const store = createSpacetimeAdminStore(testConfig, fetchImpl, log);
195+
196+
await store.adjustBalance({
197+
admin: { telegramId: 42 },
198+
accountId: 'telegram:99',
199+
balanceKind: 'paid_elm',
200+
operation: 'credit',
201+
amount: 10,
202+
reason: 'support refund',
203+
});
204+
205+
expect(rows()[0].adminTelegramId).toBe('42');
206+
expect(rows()[0].reason).toBe('support refund');
207+
expect(rows()[0].targetAccountId).toBe('telegram:99');
208+
});
209+
210+
it('throws not_found when account does not exist', async () => {
211+
const { fetchImpl } = makeFetch([]);
212+
const store = createSpacetimeAdminStore(testConfig, fetchImpl);
213+
214+
await expect(store.adjustBalance({
215+
admin: { telegramId: 1 },
216+
accountId: 'telegram:99',
217+
balanceKind: 'paid_elm',
218+
operation: 'credit',
219+
amount: 100,
220+
})).rejects.toThrow(AdminStoreError);
221+
222+
await expect(store.adjustBalance({
223+
admin: { telegramId: 1 },
224+
accountId: 'telegram:99',
225+
balanceKind: 'paid_elm',
226+
operation: 'credit',
227+
amount: 100,
228+
})).rejects.toMatchObject({ code: 'not_found' });
229+
});
230+
231+
it('throws invalid_input when account balance kind does not match', async () => {
232+
const { fetchImpl } = makeFetch([accountRow({ balance_kind: 'demo_teml' })]);
233+
const store = createSpacetimeAdminStore(testConfig, fetchImpl);
234+
235+
await expect(store.adjustBalance({
236+
admin: { telegramId: 1 },
237+
accountId: 'telegram:99',
238+
balanceKind: 'paid_elm',
239+
operation: 'credit',
240+
amount: 100,
241+
})).rejects.toMatchObject({ code: 'invalid_input' });
242+
});
243+
244+
it('throws conflict when debit would make balance negative', async () => {
245+
const { fetchImpl } = makeFetch([accountRow({ balance: 100 })]);
246+
const store = createSpacetimeAdminStore(testConfig, fetchImpl);
247+
248+
await expect(store.adjustBalance({
249+
admin: { telegramId: 1 },
250+
accountId: 'telegram:99',
251+
balanceKind: 'paid_elm',
252+
operation: 'debit',
253+
amount: 200,
254+
})).rejects.toMatchObject({ code: 'conflict' });
255+
});
256+
257+
it('throws invalid_input for zero credit amount', async () => {
258+
const { fetchImpl } = makeFetch([accountRow()]);
259+
const store = createSpacetimeAdminStore(testConfig, fetchImpl);
260+
261+
await expect(store.adjustBalance({
262+
admin: { telegramId: 1 },
263+
accountId: 'telegram:99',
264+
balanceKind: 'paid_elm',
265+
operation: 'credit',
266+
amount: 0,
267+
})).rejects.toMatchObject({ code: 'invalid_input' });
268+
});
269+
270+
it('throws invalid_input for zero debit amount', async () => {
271+
const { fetchImpl } = makeFetch([accountRow()]);
272+
const store = createSpacetimeAdminStore(testConfig, fetchImpl);
273+
274+
await expect(store.adjustBalance({
275+
admin: { telegramId: 1 },
276+
accountId: 'telegram:99',
277+
balanceKind: 'paid_elm',
278+
operation: 'debit',
279+
amount: 0,
280+
})).rejects.toMatchObject({ code: 'invalid_input' });
281+
});
282+
283+
it('throws invalid_input for non-integer amount', async () => {
284+
const { fetchImpl } = makeFetch([accountRow()]);
285+
const store = createSpacetimeAdminStore(testConfig, fetchImpl);
286+
287+
await expect(store.adjustBalance({
288+
admin: { telegramId: 1 },
289+
accountId: 'telegram:99',
290+
balanceKind: 'paid_elm',
291+
operation: 'credit',
292+
amount: 1.5,
293+
})).rejects.toMatchObject({ code: 'invalid_input' });
294+
});
295+
296+
it('throws invalid_input for negative amount', async () => {
297+
const { fetchImpl } = makeFetch([accountRow()]);
298+
const store = createSpacetimeAdminStore(testConfig, fetchImpl);
299+
300+
await expect(store.adjustBalance({
301+
admin: { telegramId: 1 },
302+
accountId: 'telegram:99',
303+
balanceKind: 'paid_elm',
304+
operation: 'credit',
305+
amount: -50,
306+
})).rejects.toMatchObject({ code: 'invalid_input' });
307+
});
308+
});

0 commit comments

Comments
 (0)