192 lines
6.5 KiB
TypeScript
192 lines
6.5 KiB
TypeScript
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<ReturnType<typeof buildApp>>;
|
|
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' } });
|
|
});
|
|
});
|