chore(checkpoint): save club core backend and pending pos fixes
This commit is contained in:
@@ -35,6 +35,7 @@ import { createPromotionService, registerPromotionsRoutes } from '../modules/pro
|
||||
import { registerCartRoutes } from '../modules/cart/index.js';
|
||||
import { registerShippingRoutes } from '../modules/shipping/index.js';
|
||||
import { registerOrdersRoutes } from '../modules/orders/index.js';
|
||||
import { registerClubRoutes } from '../modules/club/index.js';
|
||||
import { registerPosRoutes } from '../modules/pos/index.js';
|
||||
import { registerCheckoutRoutes } from '../modules/checkout/index.js';
|
||||
import { registerPaymentsRoutes } from '../modules/payments/index.js';
|
||||
@@ -320,6 +321,13 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerClubRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
// POS routes (POS-004)
|
||||
if (deps.pool && combinedAuth) {
|
||||
await app.register(async (instance) => {
|
||||
|
||||
191
project/src/app/tests/club.itest.ts
Normal file
191
project/src/app/tests/club.itest.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
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' } });
|
||||
});
|
||||
});
|
||||
@@ -79,25 +79,25 @@ describe.skipIf(!hasDb)('F-186 POS checkout and receipts (real PostgreSQL)', ()
|
||||
],
|
||||
payments: [
|
||||
{ methodCode: 'cash', amountCents: 500, tenderedCents: 1000 },
|
||||
{ methodCode: 'card', amountCents: 700 },
|
||||
{ methodCode: 'card', amountCents: 770 },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.totalCents).toBe(1200);
|
||||
expect(result.totalCents).toBe(1270);
|
||||
expect(result.changeCents).toBe(500);
|
||||
expect(result.receiptNumber).toBe('TPV-000001');
|
||||
expect(result.receipt.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'Producto test', freeItem: false, totalCents: 700 }),
|
||||
expect.objectContaining({ name: 'Producto test', freeItem: false, totalCents: 770, taxCents: 70 }),
|
||||
expect.objectContaining({ name: 'Servicio libre', freeItem: true, totalCents: 500 }),
|
||||
]),
|
||||
);
|
||||
expect(result.receipt.payments).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ methodCode: 'cash', amountCents: 500, changeCents: 500 }),
|
||||
expect.objectContaining({ methodCode: 'card', amountCents: 700 }),
|
||||
expect.objectContaining({ methodCode: 'card', amountCents: 770 }),
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
|
||||
terminalId: TERMINAL_ID,
|
||||
userId: USER_ID,
|
||||
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 3, discountCents: 0 }],
|
||||
payments: [{ methodCode: 'cash', amountCents: 3000, tenderedCents: 3000 }],
|
||||
payments: [{ methodCode: 'cash', amountCents: 3300, tenderedCents: 3300 }],
|
||||
});
|
||||
expect(sale.state).toBe('COMPLETED');
|
||||
|
||||
@@ -127,11 +127,11 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = response.json();
|
||||
expect(body.state).toBe('PARTIALLY_REFUNDED');
|
||||
expect(body.refundedCents).toBe(1000);
|
||||
expect(body.refundedCents).toBe(1100);
|
||||
expect(body.receipt.receiptNumber.startsWith('R-')).toBe(true);
|
||||
expect(body.receipt.totalCents).toBe(-1000);
|
||||
expect(body.receipt.totalCents).toBe(-1100);
|
||||
expect(body.receipt.items[0].quantity).toBe(1);
|
||||
expect(body.receipt.items[0].totalCents).toBe(-1000);
|
||||
expect(body.receipt.items[0].totalCents).toBe(-1100);
|
||||
|
||||
const order = await pool.query<{ state: string }>(
|
||||
`SELECT state FROM orders_orders WHERE id = $1`,
|
||||
@@ -183,7 +183,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
|
||||
{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 },
|
||||
{ kind: 'free', name: 'Mano de obra', unitPriceCents: 500, quantity: 1 },
|
||||
],
|
||||
payments: [{ methodCode: 'cash', amountCents: 1500, tenderedCents: 1500 }],
|
||||
payments: [{ methodCode: 'cash', amountCents: 1600, tenderedCents: 1600 }],
|
||||
});
|
||||
expect(sale.state).toBe('COMPLETED');
|
||||
|
||||
@@ -214,7 +214,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = response.json();
|
||||
expect(body.state).toBe('REFUNDED');
|
||||
expect(body.refundedCents).toBe(1500);
|
||||
expect(body.refundedCents).toBe(1600);
|
||||
|
||||
const stock = await pool.query<{ available: number }>(
|
||||
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
|
||||
@@ -238,7 +238,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
|
||||
terminalId: TERMINAL_ID,
|
||||
userId: USER_ID,
|
||||
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }],
|
||||
payments: [{ methodCode: 'cash', amountCents: 1000, tenderedCents: 1000 }],
|
||||
payments: [{ methodCode: 'cash', amountCents: 1100, tenderedCents: 1100 }],
|
||||
});
|
||||
|
||||
const items = await pool.query<{ id: string }>(
|
||||
@@ -271,7 +271,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
|
||||
terminalId: TERMINAL_ID,
|
||||
userId: USER_ID,
|
||||
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 2, discountCents: 0 }],
|
||||
payments: [{ methodCode: 'cash', amountCents: 2000, tenderedCents: 2000 }],
|
||||
payments: [{ methodCode: 'cash', amountCents: 2200, tenderedCents: 2200 }],
|
||||
});
|
||||
|
||||
const items = await pool.query<{ id: string }>(
|
||||
|
||||
Reference in New Issue
Block a user