import type { DestinationStream } from 'pino'; import type pg from 'pg'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../build-app.js'; import { createPool } from '../../infrastructure/db/pool.js'; import { createLogger } from '../../infrastructure/logging/logger.js'; import { getTestDbUrl, recreateDatabase, runMigrations, } from '../../infrastructure/db/tests/db-test-support.js'; import { createClubService, CLUB_DEVICE_COOKIE_NAME } from '../../modules/club/index.js'; import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js'; const hasDb = Boolean(process.env.TEST_DATABASE_URL); function silentLogger() { const destination: DestinationStream = { write: () => undefined }; return createLogger({ level: 'info', destination }); } function cookieValue(setCookieHeader: string | string[] | undefined): string { const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader; expect(raw).toBeDefined(); const pair = (raw as string).split(';')[0] as string; return pair.slice(pair.indexOf('=') + 1); } describe.skipIf(!hasDb)('club backend phase 1 (real PostgreSQL)', () => { const url = hasDb ? getTestDbUrl() : ''; let pool: pg.Pool; let app: Awaited>; let adminCookie = ''; beforeAll(async () => { await recreateDatabase(url); await runMigrations(url, 'up'); pool = createPool(url); app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true }); const user = { email: 'club-admin@example.com', password: 'correct horse battery staple' }; const registered = await app.inject({ method: 'POST', url: '/auth/register', headers: { 'content-type': 'application/json' }, payload: user, }); const id = (registered.json() as { id: string }).id; await pool.query( `UPDATE identity_users SET role = $1, email_confirmed = true, confirmed_at = now(), confirmation_token = null WHERE id = $2`, ['admin', id], ); const login = await app.inject({ method: 'POST', url: '/auth/login', headers: { 'content-type': 'application/json' }, payload: user, }); adminCookie = cookieValue(login.headers['set-cookie']); }); afterAll(async () => { await app?.close(); await pool?.end(); }); it('creates an anonymous member, reuses device identity and lists movements', async () => { const config = await app.inject({ method: 'GET', url: '/club/config' }); expect(config.statusCode).toBe(200); expect(config.json()).toMatchObject({ clubEnabled: true, cashbackBps: 200 }); const joined = await app.inject({ method: 'POST', url: '/club/join' }); expect(joined.statusCode).toBe(201); expect(String(joined.headers['set-cookie'])).toContain(`${CLUB_DEVICE_COOKIE_NAME}=`); const payload = joined.json() as { created: boolean; deviceToken: string; member: { id: string; memberCode: string; currentBalanceCents: number }; }; expect(payload.created).toBe(true); expect(payload.deviceToken).toMatch(/^[A-Za-z0-9_-]{40,}$/); expect(payload.member.memberCode).toMatch(/^MDV-[A-Z0-9]{8}$/); expect(payload.member.currentBalanceCents).toBe(0); const me = await app.inject({ method: 'GET', url: '/club/me', headers: { 'x-club-device-token': payload.deviceToken }, }); expect(me.statusCode).toBe(200); expect(me.json()).toMatchObject({ member: { id: payload.member.id, memberCode: payload.member.memberCode } }); const repeatedJoin = await app.inject({ method: 'POST', url: '/club/join', cookies: { [CLUB_DEVICE_COOKIE_NAME]: payload.deviceToken }, }); expect(repeatedJoin.statusCode).toBe(200); expect(repeatedJoin.json()).toMatchObject({ created: false, member: { id: payload.member.id } }); const club = createClubService(pool); const first = await club.recordTransaction({ memberId: payload.member.id, type: 'bonus', amountCents: 100, balanceDeltaCents: 100, idempotencyKey: 'club-bonus-1', metadata: { reason: 'welcome' }, }); expect(first.created).toBe(true); expect(first.member.currentBalanceCents).toBe(100); const replay = await club.recordTransaction({ memberId: payload.member.id, type: 'bonus', amountCents: 100, balanceDeltaCents: 100, idempotencyKey: 'club-bonus-1', metadata: { reason: 'welcome' }, }); expect(replay.created).toBe(false); expect(replay.member.currentBalanceCents).toBe(100); const movements = await app.inject({ method: 'GET', url: '/club/movements?limit=10', headers: { 'x-club-device-token': payload.deviceToken }, }); expect(movements.statusCode).toBe(200); expect(movements.json()).toMatchObject({ member: { id: payload.member.id, currentBalanceCents: 100 }, items: [ { type: 'bonus', amountCents: 100, balanceDeltaCents: 100, idempotencyKey: 'club-bonus-1', }, ], }); }); it('reads and updates admin club settings and blocks new joins when disabled', async () => { const current = await app.inject({ method: 'GET', url: '/admin/club/settings', cookies: { [SESSION_COOKIE_NAME]: adminCookie }, }); expect(current.statusCode).toBe(200); expect(current.json()).toMatchObject({ clubEnabled: true, cashbackBps: 200, cashbackPercentage: 2, allowAnonymousMembers: true, allowRecoveryCodes: true, minimumRedeemAmountCents: 500, }); const updated = await app.inject({ method: 'PATCH', url: '/admin/club/settings', headers: { 'content-type': 'application/json' }, cookies: { [SESSION_COOKIE_NAME]: adminCookie }, payload: { clubEnabled: false, cashbackPercentage: 3.5, allowAnonymousMembers: false, allowRecoveryCodes: false, minimumRedeemAmountCents: 700, }, }); expect(updated.statusCode).toBe(200); expect(updated.json()).toMatchObject({ clubEnabled: false, cashbackBps: 350, cashbackPercentage: 3.5, allowAnonymousMembers: false, allowRecoveryCodes: false, minimumRedeemAmountCents: 700, }); const blocked = await app.inject({ method: 'POST', url: '/club/join' }); expect(blocked.statusCode).toBe(409); expect(blocked.json()).toMatchObject({ error: { code: 'CLUB_DISABLED' } }); }); });