149 lines
5.5 KiB
TypeScript
149 lines
5.5 KiB
TypeScript
import type pg from 'pg';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { CreatePosSaleUseCase } from '../../modules/pos/index.js';
|
|
import { createPool } from '../../infrastructure/db/pool.js';
|
|
import {
|
|
getTestDbUrl,
|
|
recreateDatabase,
|
|
runMigrations,
|
|
} from '../../infrastructure/db/tests/db-test-support.js';
|
|
|
|
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
|
const STORE_ID = '00000000-0000-0000-0000-000000000001';
|
|
const USER_ID = '10000000-0000-4000-8000-000000000001';
|
|
const TERMINAL_ID = '20000000-0000-4000-8000-000000000001';
|
|
const SESSION_ID = '30000000-0000-4000-8000-000000000001';
|
|
const PRODUCT_ID = '40000000-0000-4000-8000-000000000001';
|
|
const VARIANT_ID = '50000000-0000-4000-8000-000000000001';
|
|
|
|
describe.skipIf(!hasDb)('F-186 POS checkout and receipts (real PostgreSQL)', () => {
|
|
const url = hasDb ? getTestDbUrl() : '';
|
|
let pool: pg.Pool;
|
|
|
|
beforeAll(async () => {
|
|
await recreateDatabase(url);
|
|
await runMigrations(url, 'up');
|
|
pool = createPool(url);
|
|
await pool.query(
|
|
`INSERT INTO backoffice_users (id, email, password_hash, role)
|
|
VALUES ($1, 'cashier@example.test', 'test-hash', 'pos_cashier')`,
|
|
[USER_ID],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO pos_terminals (id, store_id, name, settings)
|
|
VALUES ($1, $2, 'Caja test', '{"lineDiscountsEnabled":true}'::jsonb)`,
|
|
[TERMINAL_ID, STORE_ID],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO pos_cash_sessions (
|
|
id, terminal_id, store_id, user_id, opening_cash_cents, expected_cash_cents
|
|
) VALUES ($1, $2, $3, $4, 2000, NULL)`,
|
|
[SESSION_ID, TERMINAL_ID, STORE_ID, USER_ID],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO catalog_products (id, name, slug, state)
|
|
VALUES ($1, 'Producto test', 'producto-test', 'active')`,
|
|
[PRODUCT_ID],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO catalog_product_variants (id, product_id, sku, ean)
|
|
VALUES ($1, $2, 'SKU-TEST', '8412345678901')`,
|
|
[VARIANT_ID, PRODUCT_ID],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO pricing_variant_prices (variant_id, net_unit_amount_cents, vat_rate)
|
|
VALUES ($1, 700, 'reduced')`,
|
|
[VARIANT_ID],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO inventory_stock (variant_id, store_id, available)
|
|
VALUES ($1, $2, 3)`,
|
|
[VARIANT_ID, STORE_ID],
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await pool.end();
|
|
});
|
|
|
|
it('atomically records mixed payment, change, free item, reporting and receipt', async () => {
|
|
const useCase = new CreatePosSaleUseCase(pool);
|
|
const input = {
|
|
idempotencyKey: 'f186-sale-1',
|
|
cashSessionId: SESSION_ID,
|
|
terminalId: TERMINAL_ID,
|
|
userId: USER_ID,
|
|
items: [
|
|
{ kind: 'stock' as const, variantId: VARIANT_ID, quantity: 1, discountCents: 0 },
|
|
{ kind: 'free' as const, name: 'Servicio libre', unitPriceCents: 500, quantity: 1 },
|
|
],
|
|
payments: [
|
|
{ methodCode: 'cash', amountCents: 500, tenderedCents: 1000 },
|
|
{ methodCode: 'card', amountCents: 770 },
|
|
],
|
|
};
|
|
|
|
const result = await useCase.execute(input);
|
|
|
|
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: 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: 770 }),
|
|
]),
|
|
);
|
|
|
|
const order = await pool.query<{ state: string; receipt_number: string }>(
|
|
'SELECT state, receipt_number FROM orders_orders WHERE id = $1',
|
|
[result.orderId],
|
|
);
|
|
expect(order.rows[0]).toEqual({ state: 'COMPLETED', receipt_number: 'TPV-000001' });
|
|
|
|
const freeLine = await pool.query<{
|
|
product_id: string | null;
|
|
variant_id: string | null;
|
|
is_free_item: boolean;
|
|
}>(
|
|
`SELECT product_id, variant_id, is_free_item FROM orders_items
|
|
WHERE order_id = $1 AND is_free_item = true`,
|
|
[result.orderId],
|
|
);
|
|
expect(freeLine.rows[0]).toEqual({ product_id: null, variant_id: null, is_free_item: true });
|
|
|
|
const stock = await pool.query<{ available: number; sold: number }>(
|
|
'SELECT available, sold FROM inventory_stock WHERE variant_id = $1 AND store_id = $2',
|
|
[VARIANT_ID, STORE_ID],
|
|
);
|
|
expect(stock.rows[0]).toEqual({ available: 2, sold: 1 });
|
|
|
|
const reporting = await pool.query<{ count: number }>(
|
|
'SELECT COUNT(*)::int AS count FROM reporting_payment_lines WHERE order_id = $1',
|
|
[result.orderId],
|
|
);
|
|
expect(reporting.rows[0]?.count).toBe(2);
|
|
|
|
const session = await pool.query<{ expected_cash_cents: number }>(
|
|
'SELECT expected_cash_cents FROM pos_cash_sessions WHERE id = $1',
|
|
[SESSION_ID],
|
|
);
|
|
expect(session.rows[0]?.expected_cash_cents).toBe(2500);
|
|
|
|
const replay = await useCase.execute(input);
|
|
expect(replay.orderId).toBe(result.orderId);
|
|
expect(replay.receiptNumber).toBe(result.receiptNumber);
|
|
const afterReplay = await pool.query<{ available: number }>(
|
|
'SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2',
|
|
[VARIANT_ID, STORE_ID],
|
|
);
|
|
expect(afterReplay.rows[0]?.available).toBe(2);
|
|
});
|
|
});
|