Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 308 additions & 0 deletions apps/payments/src/adminStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
import { describe, expect, it, vi } from 'vitest';
import { AdminStoreError, createSpacetimeAdminStore, type AdminAuditLog } from './adminStore.js';
import type { SpacetimeAdminConfig } from './config.js';

// Need to export the internal row type for tests — but it's not exported.
// We reference it via the auditLog contract instead.
type AuditRow = Parameters<AdminAuditLog['append']>[0];

const testConfig: SpacetimeAdminConfig = {
uri: 'https://stdb.example',
database: 'test-db',
};

function rowsToSql(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return '';
const columns = Object.keys(rows[0]);
return JSON.stringify([{
schema: { elements: columns.map(col => ({ name: col })) },
rows: rows.map(row => columns.map(col => row[col])),
}]);
}

function accountRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'telegram:99',
name: 'Alice',
rating: 1200,
wins: 1,
losses: 0,
balance: 500,
balance_kind: 'paid_elm',
season_points: 50,
...overrides,
};
}

function playerRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
identity: '0xabc123',
name: 'Alice',
online: false,
rating: 1200,
wins: 1,
losses: 0,
balance: 500,
balance_kind: 'paid_elm',
account_id: 'telegram:99',
season_points: 50,
...overrides,
};
}

function makeFetch(
accounts: Record<string, unknown>[] = [],
players: Record<string, unknown>[] = [],
): { fetchImpl: typeof fetch; sqlCalls: () => string[] } {
const calls: string[] = [];
const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => {
const query = (init?.body as string) ?? '';
calls.push(query);
if (query.startsWith('SELECT * FROM account')) return new Response(rowsToSql(accounts), { status: 200 });
if (query.startsWith('SELECT * FROM player')) return new Response(rowsToSql(players), { status: 200 });
return new Response('', { status: 200 });
});
return { fetchImpl: fetchImpl as unknown as typeof fetch, sqlCalls: () => calls };
}

function makeAuditLog(): { log: AdminAuditLog; rows: () => AuditRow[] } {
const rows: AuditRow[] = [];
const log: AdminAuditLog = {
append: vi.fn(async row => { rows.push(row); }),
read: vi.fn(async () => rows),
};
return { log, rows: () => rows };
}

describe('createSpacetimeAdminStore adjustBalance', () => {
it('credits increase account balance and produce a positive delta audit row', async () => {
const { fetchImpl, sqlCalls } = makeFetch([accountRow()]);
const { log, rows } = makeAuditLog();
const store = createSpacetimeAdminStore(testConfig, fetchImpl, log);

const result = await store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'credit',
amount: 200,
});

expect(result.account.balance).toBe(700);
expect(result.audit.operation).toBe('credit');
expect(result.audit.previousBalance).toBe(500);
expect(result.audit.newBalance).toBe(700);
expect(result.audit.delta).toBe(200);

const updateCall = sqlCalls().find(q => q.startsWith('UPDATE account SET balance'));
expect(updateCall).toMatch(/balance = 700/);
expect(rows()).toHaveLength(1);
expect(rows()[0].delta).toBe(200);
});

it('debits decrease account balance and produce a negative delta audit row', async () => {
const { fetchImpl, sqlCalls } = makeFetch([accountRow()]);
const { log, rows } = makeAuditLog();
const store = createSpacetimeAdminStore(testConfig, fetchImpl, log);

const result = await store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'debit',
amount: 100,
});

expect(result.account.balance).toBe(400);
expect(result.audit.delta).toBe(-100);
expect(sqlCalls().find(q => q.startsWith('UPDATE account SET balance'))).toMatch(/balance = 400/);
expect(rows()[0].operation).toBe('debit');
});

it('set changes balance to exact value regardless of previous balance', async () => {
const { fetchImpl, sqlCalls } = makeFetch([accountRow()]);
const { log, rows } = makeAuditLog();
const store = createSpacetimeAdminStore(testConfig, fetchImpl, log);

const result = await store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'set',
amount: 300,
});

expect(result.account.balance).toBe(300);
expect(result.audit.delta).toBe(-200);
expect(sqlCalls().find(q => q.startsWith('UPDATE account SET balance'))).toMatch(/balance = 300/);
expect(rows()[0].operation).toBe('set');
});

it('synchronizes linked player balance in the same operation', async () => {
const { fetchImpl, sqlCalls } = makeFetch([accountRow()], [playerRow()]);
const { log } = makeAuditLog();
const store = createSpacetimeAdminStore(testConfig, fetchImpl, log);

await store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'credit',
amount: 50,
});

const playerUpdate = sqlCalls().find(q => q.startsWith('UPDATE player SET balance'));
expect(playerUpdate).toMatch(/balance = 550/);
});

it('skips balance_event insert when delta is zero (set to same value)', async () => {
const { fetchImpl, sqlCalls } = makeFetch([accountRow()]);
const { log } = makeAuditLog();
const store = createSpacetimeAdminStore(testConfig, fetchImpl, log);

await store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'set',
amount: 500,
});

const balanceInsert = sqlCalls().find(q => q.startsWith('INSERT INTO balance_event'));
expect(balanceInsert).toBeUndefined();
});

it('writes audit via SQL when no auditLog is provided', async () => {
const { fetchImpl, sqlCalls } = makeFetch([accountRow()]);
const store = createSpacetimeAdminStore(testConfig, fetchImpl);

await store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'credit',
amount: 100,
});

const auditInsert = sqlCalls().find(q => q.startsWith('INSERT INTO admin_audit_event'));
expect(auditInsert).toBeDefined();
});

it('includes admin telegramId and reason in audit row', async () => {
const { fetchImpl } = makeFetch([accountRow()]);
const { log, rows } = makeAuditLog();
const store = createSpacetimeAdminStore(testConfig, fetchImpl, log);

await store.adjustBalance({
admin: { telegramId: 42 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'credit',
amount: 10,
reason: 'support refund',
});

expect(rows()[0].adminTelegramId).toBe('42');
expect(rows()[0].reason).toBe('support refund');
expect(rows()[0].targetAccountId).toBe('telegram:99');
});

it('throws not_found when account does not exist', async () => {
const { fetchImpl } = makeFetch([]);
const store = createSpacetimeAdminStore(testConfig, fetchImpl);

await expect(store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'credit',
amount: 100,
})).rejects.toThrow(AdminStoreError);

await expect(store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'credit',
amount: 100,
})).rejects.toMatchObject({ code: 'not_found' });
});

it('throws invalid_input when account balance kind does not match', async () => {
const { fetchImpl } = makeFetch([accountRow({ balance_kind: 'demo_teml' })]);
const store = createSpacetimeAdminStore(testConfig, fetchImpl);

await expect(store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'credit',
amount: 100,
})).rejects.toMatchObject({ code: 'invalid_input' });
});

it('throws conflict when debit would make balance negative', async () => {
const { fetchImpl } = makeFetch([accountRow({ balance: 100 })]);
const store = createSpacetimeAdminStore(testConfig, fetchImpl);

await expect(store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'debit',
amount: 200,
})).rejects.toMatchObject({ code: 'conflict' });
});

it('throws invalid_input for zero credit amount', async () => {
const { fetchImpl } = makeFetch([accountRow()]);
const store = createSpacetimeAdminStore(testConfig, fetchImpl);

await expect(store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'credit',
amount: 0,
})).rejects.toMatchObject({ code: 'invalid_input' });
});

it('throws invalid_input for zero debit amount', async () => {
const { fetchImpl } = makeFetch([accountRow()]);
const store = createSpacetimeAdminStore(testConfig, fetchImpl);

await expect(store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'debit',
amount: 0,
})).rejects.toMatchObject({ code: 'invalid_input' });
});

it('throws invalid_input for non-integer amount', async () => {
const { fetchImpl } = makeFetch([accountRow()]);
const store = createSpacetimeAdminStore(testConfig, fetchImpl);

await expect(store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'credit',
amount: 1.5,
})).rejects.toMatchObject({ code: 'invalid_input' });
});

it('throws invalid_input for negative amount', async () => {
const { fetchImpl } = makeFetch([accountRow()]);
const store = createSpacetimeAdminStore(testConfig, fetchImpl);

await expect(store.adjustBalance({
admin: { telegramId: 1 },
accountId: 'telegram:99',
balanceKind: 'paid_elm',
operation: 'credit',
amount: -50,
})).rejects.toMatchObject({ code: 'invalid_input' });
});
});
Loading
Loading