feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,100 @@
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 { 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)('brands flows (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: 'brands-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,
});
expect(registered.statusCode).toBe(201);
const id = (registered.json() as { id: string }).id;
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
expect(login.statusCode).toBe(200);
adminCookie = cookieValue(login.headers['set-cookie']);
});
afterAll(async () => {
await app.close();
await pool.end();
});
it('returns HTTP 409 for duplicate brand slug (AC1)', async () => {
const payload = {
name: 'NaturGreen',
slug: 'naturgreen',
seoTitle: 'NaturGreen marca ecológica',
seoDescription: 'Productos ecológicos NaturGreen',
};
const first = await app.inject({
method: 'POST',
url: '/brands',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload,
});
expect(first.statusCode).toBe(201);
const duplicate = await app.inject({
method: 'POST',
url: '/brands',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload,
});
expect(duplicate.statusCode).toBe(409);
expect(duplicate.json().error.code).toBe('BRAND_SLUG_EXISTS');
});
it('serves public brand URL by slug (AC3)', async () => {
const response = await app.inject({ method: 'GET', url: '/marca/naturgreen' });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ slug: 'naturgreen', url: '/marca/naturgreen' });
});
});

View File

@@ -0,0 +1,164 @@
import { randomUUID } from 'node:crypto';
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 { 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)('cart flows (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
let cookie = '';
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const user = { email: 'cart-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 WHERE id = $2', ['admin', id]);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
cookie = cookieValue(login.headers['set-cookie']);
});
afterAll(async () => {
await app.close();
await pool.end();
});
async function setPrice(variantId: string, cents: number) {
await app.inject({
method: 'PUT',
url: `/pricing/variants/${variantId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { netUnitAmountCents: cents, vatRate: 'general' },
});
}
async function setStock(variantId: string, quantity: number) {
await app.inject({
method: 'PUT',
url: `/inventory/${variantId}/stock`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { quantity },
});
}
it('recalculates cart totals when product price changes after add (AC1)', async () => {
const productId = randomUUID();
const variantId = randomUUID();
await setPrice(variantId, 1000);
await setStock(variantId, 10);
await app.inject({
method: 'POST',
url: '/cart/items',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 2 },
});
await setPrice(variantId, 2000);
const cart = await app.inject({
method: 'GET',
url: '/cart',
cookies: { [SESSION_COOKIE_NAME]: cookie },
});
expect(cart.statusCode).toBe(200);
expect(cart.json()).toMatchObject({
netSubtotalCents: 4000,
vatAmountCents: 840,
totalCents: 4840,
});
});
it('flags cart item unavailable when variant is out of stock (AC2)', async () => {
const productId = randomUUID();
const variantId = randomUUID();
await setPrice(variantId, 500);
await setStock(variantId, 0);
await app.inject({
method: 'POST',
url: '/cart/items',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 1 },
});
const cart = await app.inject({
method: 'GET',
url: '/cart',
cookies: { [SESSION_COOKIE_NAME]: cookie },
});
const item = (cart.json().items as Array<{ variantId: string; available: boolean }>).find(
(entry) => entry.variantId === variantId,
);
expect(item).toMatchObject({ available: false });
});
it('ignores client-supplied price fields in cart payloads (AC3)', async () => {
const productId = randomUUID();
const variantId = randomUUID();
await setPrice(variantId, 700);
await setStock(variantId, 3);
const response = await app.inject({
method: 'POST',
url: '/cart/items',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 1, netUnitAmountCents: 1, totalCents: 1 },
});
expect(response.statusCode).toBe(201);
const item = (
response.json().items as Array<{
variantId: string;
pricing: { netSubtotalCents: number; vatAmountCents: number; totalCents: number };
}>
).find((entry) => entry.variantId === variantId);
expect(item?.pricing).toMatchObject({
netSubtotalCents: 700,
vatAmountCents: 147,
totalCents: 847,
});
const columns = await pool.query(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'cart_items'",
);
expect(columns.rows.map((row) => row.column_name).join(' ')).not.toMatch(
/price|tax|vat|discount|stock/i,
);
});
});

View File

@@ -0,0 +1,269 @@
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 { 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)('catalog product flows (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
let adminCookie = '';
let categoryId = '';
let brandId = '';
let productId = '';
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const user = { email: 'catalog-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,
});
expect(registered.statusCode).toBe(201);
const id = (registered.json() as { id: string }).id;
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
expect(login.statusCode).toBe(200);
adminCookie = cookieValue(login.headers['set-cookie']);
const category = await app.inject({
method: 'POST',
url: '/categories',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { name: 'Aceites', slug: 'catalogo-aceites' },
});
expect(category.statusCode).toBe(201);
categoryId = (category.json() as { id: string }).id;
const brand = await app.inject({
method: 'POST',
url: '/brands',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { name: 'Bio Brand', slug: 'bio-brand' },
});
expect(brand.statusCode).toBe(201);
brandId = (brand.json() as { id: string }).id;
});
afterAll(async () => {
await app.close();
await pool.end();
});
it('returns HTTP 409 for duplicate product slug (AC2)', async () => {
const payload = {
name: 'Aceite de oliva',
slug: 'aceite-oliva',
state: 'active',
categoryIds: [categoryId],
brandId,
};
const first = await app.inject({
method: 'POST',
url: '/products',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload,
});
expect(first.statusCode).toBe(201);
productId = (first.json() as { id: string }).id;
expect(first.json().categoryIds).toEqual([categoryId]);
expect(first.json().brandId).toBe(brandId);
const duplicate = await app.inject({
method: 'POST',
url: '/products',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload,
});
expect(duplicate.statusCode).toBe(409);
expect(duplicate.json().error.code).toBe('PRODUCT_SLUG_EXISTS');
});
it('public listing and slug reads expose only active products (AC3, AC4)', async () => {
const draft = await app.inject({
method: 'POST',
url: '/products',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { name: 'Borrador', slug: 'producto-borrador', state: 'draft' },
});
expect(draft.statusCode).toBe(201);
const search = await app.inject({ method: 'GET', url: '/products/search?q=producto' });
expect(search.statusCode).toBe(200);
const body = search.json() as { items: Array<{ slug: string }> };
expect(body.items.some((item) => item.slug === 'producto-borrador')).toBe(false);
const draftBySlug = await app.inject({ method: 'GET', url: '/productos/producto-borrador' });
expect(draftBySlug.statusCode).toBe(404);
const activeBySlug = await app.inject({ method: 'GET', url: '/productos/aceite-oliva' });
expect(activeBySlug.statusCode).toBe(200);
expect(activeBySlug.json()).toMatchObject({
slug: 'aceite-oliva',
url: '/productos/aceite-oliva',
});
});
it('filters public products by brand slug (F-009 AC2)', async () => {
const otherBrand = await app.inject({
method: 'POST',
url: '/brands',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { name: 'Other Brand', slug: 'other-brand' },
});
expect(otherBrand.statusCode).toBe(201);
const otherBrandId = (otherBrand.json() as { id: string }).id;
const otherProduct = await app.inject({
method: 'POST',
url: '/products',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: {
name: 'Producto otra marca',
slug: 'producto-otra-marca',
state: 'active',
brandId: otherBrandId,
},
});
expect(otherProduct.statusCode).toBe(201);
const filtered = await app.inject({
method: 'GET',
url: '/products/search?brandSlug=bio-brand',
});
expect(filtered.statusCode).toBe(200);
const body = filtered.json() as { items: Array<{ slug: string }> };
expect(body.items.some((item) => item.slug === 'aceite-oliva')).toBe(true);
expect(body.items.some((item) => item.slug === 'producto-otra-marca')).toBe(false);
});
it('returns HTTP 409 for duplicate variant SKU or EAN (F-010 AC1)', async () => {
const first = await app.inject({
method: 'POST',
url: `/products/${productId}/variants`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { sku: 'SKU-ACEITE-1', ean: '8412345678901', attributes: { size: '500ml' } },
});
expect(first.statusCode).toBe(201);
const duplicateSku = await app.inject({
method: 'POST',
url: `/products/${productId}/variants`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { sku: 'SKU-ACEITE-1', ean: '8412345678902' },
});
expect(duplicateSku.statusCode).toBe(409);
expect(duplicateSku.json().error.code).toBe('PRODUCT_VARIANT_CODE_EXISTS');
const duplicateEan = await app.inject({
method: 'POST',
url: `/products/${productId}/variants`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { sku: 'SKU-ACEITE-2', ean: '8412345678901' },
});
expect(duplicateEan.statusCode).toBe(409);
});
it('stores nutrition provenance and protects manual nutrition from external overwrite (F-010 AC2, AC3)', async () => {
const manual = await app.inject({
method: 'PATCH',
url: `/products/${productId}/rich-data`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: {
ingredients: 'Aceite de oliva virgen extra',
allergens: [],
nutrition: { calories: 884, fat: 100 },
nutritionSource: 'manual',
isOrganic: true,
organicCertification: 'EU Organic',
},
});
expect(manual.statusCode).toBe(200);
expect(manual.json()).toMatchObject({
nutrition: { calories: 884, fat: 100 },
nutritionSource: 'manual',
isOrganic: true,
});
const external = await app.inject({
method: 'PATCH',
url: `/products/${productId}/rich-data`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: {
nutrition: { calories: 1 },
nutritionSource: 'openfoodfacts',
ingredients: 'External ingredients update allowed',
},
});
expect(external.statusCode).toBe(200);
expect(external.json()).toMatchObject({
nutrition: { calories: 884, fat: 100 },
nutritionSource: 'manual',
ingredients: 'External ingredients update allowed',
});
});
it('rejects unknown product category assignment', async () => {
const response = await app.inject({
method: 'POST',
url: '/products',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: {
name: 'Producto sin categoría real',
slug: 'producto-categoria-invalida',
categoryIds: ['00000000-0000-4000-8000-000000000000'],
},
});
expect(response.statusCode).toBe(422);
expect(response.json().error.code).toBe('PRODUCT_CATEGORY_NOT_FOUND');
});
});

View File

@@ -0,0 +1,140 @@
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 { 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)('categories flows (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: 'category-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,
});
expect(registered.statusCode).toBe(201);
const id = (registered.json() as { id: string }).id;
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
expect(login.statusCode).toBe(200);
adminCookie = cookieValue(login.headers['set-cookie']);
});
afterAll(async () => {
await app.close();
await pool.end();
});
it('returns HTTP 409 for duplicate slug (AC1)', async () => {
const payload = {
name: 'Aceites',
slug: 'aceites',
seoTitle: 'Aceites ecológicos',
seoDescription: 'Aceites para alimentación saludable',
};
const first = await app.inject({
method: 'POST',
url: '/categories',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload,
});
expect(first.statusCode).toBe(201);
const duplicate = await app.inject({
method: 'POST',
url: '/categories',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload,
});
expect(duplicate.statusCode).toBe(409);
expect(duplicate.json().error.code).toBe('CATEGORY_SLUG_EXISTS');
});
it('supports parent/child tree and blocks cycles (AC2)', async () => {
const root = await app.inject({
method: 'POST',
url: '/categories',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { name: 'Alimentación', slug: 'alimentacion' },
});
expect(root.statusCode).toBe(201);
const rootBody = root.json() as { id: string };
const child = await app.inject({
method: 'POST',
url: '/categories',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { parentId: rootBody.id, name: 'Cereales', slug: 'cereales' },
});
expect(child.statusCode).toBe(201);
const childBody = child.json() as { id: string };
const tree = await app.inject({ method: 'GET', url: '/categories/tree' });
expect(tree.statusCode).toBe(200);
const treeBody = tree.json() as {
items: Array<{ slug: string; children: Array<{ slug: string }> }>;
};
const rootNode = treeBody.items.find((item) => item.slug === 'alimentacion');
expect(rootNode?.children.some((item) => item.slug === 'cereales')).toBe(true);
const cycle = await app.inject({
method: 'PATCH',
url: `/categories/${rootBody.id}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { parentId: childBody.id },
});
expect(cycle.statusCode).toBe(422);
expect(cycle.json().error.code).toBe('CATEGORY_TREE_CYCLE');
});
it('serves public category URL by slug, never internal id (AC3)', async () => {
const response = await app.inject({ method: 'GET', url: '/categoria/alimentacion' });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ slug: 'alimentacion', url: '/categoria/alimentacion' });
});
});

View File

@@ -0,0 +1,180 @@
import { randomUUID } from 'node:crypto';
import type { DestinationStream } from 'pino';
import type pg from 'pg';
import { afterAll, afterEach, 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 { 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)('checkout flows (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
let cookie = '';
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const user = { email: 'checkout-user@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 WHERE id = $2', ['admin', id]);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
cookie = cookieValue(login.headers['set-cookie']);
const zone = await app.inject({
method: 'POST',
url: '/shipping/zones',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { name: 'Peninsula', country: 'ES', postalCodePrefix: '28' },
});
const zoneId = (zone.json() as { id: string }).id;
await app.inject({
method: 'POST',
url: '/shipping/methods',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { zoneId, name: 'Standard', baseCostCents: 500 },
});
});
afterAll(async () => {
await app.close();
await pool.end();
});
afterEach(async () => {
await pool.query('DELETE FROM cart_items');
await pool.query('DELETE FROM cart_carts');
});
async function seedCart(variantId: string, cents = 1000, stock = 5) {
const productId = randomUUID();
await app.inject({
method: 'PUT',
url: `/pricing/variants/${variantId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { netUnitAmountCents: cents, vatRate: 'general' },
});
await app.inject({
method: 'PUT',
url: `/inventory/${variantId}/stock`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { quantity: stock },
});
await app.inject({
method: 'POST',
url: '/cart/items',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 1 },
});
}
it('returns 409 without creating order when cart has out-of-stock item (AC1)', async () => {
const variantId = randomUUID();
await seedCart(variantId, 1000, 0);
const idempotencyKey = randomUUID();
const response = await app.inject({
method: 'POST',
url: '/checkout',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { address: { country: 'ES', postalCode: '28001' }, idempotencyKey },
});
expect(response.statusCode).toBe(409);
expect((response.json().error as { code: string }).code).toBe('CHECKOUT_STOCK_UNAVAILABLE');
const orderRows = await pool.query('SELECT id FROM orders_orders WHERE idempotency_key = $1', [
idempotencyKey,
]);
expect(orderRows.rowCount).toBe(0);
});
it('returns the same order on idempotent retry without duplicate reservation (AC2)', async () => {
const variantId = randomUUID();
await seedCart(variantId, 1000, 5);
const idempotencyKey = randomUUID();
const first = await app.inject({
method: 'POST',
url: '/checkout',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { address: { country: 'ES', postalCode: '28001' }, idempotencyKey },
});
expect(first.statusCode).toBe(200);
const orderId = (first.json() as { order: { id: string } }).order.id;
const stockBefore = await pool.query<{ available: number; reserved: number }>(
'SELECT available, reserved FROM inventory_stock WHERE variant_id = $1',
[variantId],
);
const second = await app.inject({
method: 'POST',
url: '/checkout',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { address: { country: 'ES', postalCode: '28001' }, idempotencyKey },
});
expect(second.statusCode).toBe(200);
expect((second.json() as { order: { id: string } }).order.id).toBe(orderId);
const stockAfter = await pool.query<{ available: number; reserved: number }>(
'SELECT available, reserved FROM inventory_stock WHERE variant_id = $1',
[variantId],
);
expect(stockAfter.rows[0]).toEqual(stockBefore.rows[0]);
});
it('creates an AWAITING_PAYMENT order with reserved stock (AC3)', async () => {
const variantId = randomUUID();
await seedCart(variantId, 1000, 5);
const response = await app.inject({
method: 'POST',
url: '/checkout',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { address: { country: 'ES', postalCode: '28001' }, idempotencyKey: randomUUID() },
});
expect(response.statusCode).toBe(200);
expect((response.json() as { order: { state: string } }).order.state).toBe('AWAITING_PAYMENT');
const stock = await pool.query<{ available: number; reserved: number }>(
'SELECT available, reserved FROM inventory_stock WHERE variant_id = $1',
[variantId],
);
expect(stock.rows[0]).toEqual({ available: 4, reserved: 1 });
});
});

View File

@@ -0,0 +1,156 @@
import { randomUUID } from 'node:crypto';
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 { 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)('E2E checkout flow (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
let cookie = '';
let variantId: string;
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const user = { email: 'e2e-user@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 WHERE id = $2', ['admin', id]);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
cookie = cookieValue(login.headers['set-cookie']);
const zone = await app.inject({
method: 'POST',
url: '/shipping/zones',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { name: 'Peninsula', country: 'ES', postalCodePrefix: '28' },
});
const zoneId = (zone.json() as { id: string }).id;
await app.inject({
method: 'POST',
url: '/shipping/methods',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { zoneId, name: 'Standard', baseCostCents: 500 },
});
variantId = randomUUID();
await app.inject({
method: 'PUT',
url: `/pricing/variants/${variantId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { netUnitAmountCents: 1000, vatRate: 'general' },
});
await app.inject({
method: 'PUT',
url: `/inventory/${variantId}/stock`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { quantity: 5 },
});
});
afterAll(async () => {
await app.close();
await pool.end();
});
it('happy path: browse -> cart -> checkout creates paid order with reserved stock (AC1)', async () => {
const productId = randomUUID();
const add = await app.inject({
method: 'POST',
url: '/cart/items',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 1 },
});
expect(add.statusCode).toBe(201);
const response = await app.inject({
method: 'POST',
url: '/checkout',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: {
address: { country: 'ES', postalCode: '28001' },
idempotencyKey: randomUUID(),
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
order: { id: string; state: string; totalCents: number };
paymentIntent: { id: string; status: string };
};
expect(body.order.state).toBe('AWAITING_PAYMENT');
expect(body.paymentIntent.id).toBeTruthy();
const stock = await pool.query<{ available: number; reserved: number }>(
'SELECT available, reserved FROM inventory_stock WHERE variant_id = $1',
[variantId],
);
expect(stock.rows[0]).toEqual({ available: 4, reserved: 1 });
const orderRow = await pool.query('SELECT state FROM orders_orders WHERE id = $1', [
body.order.id,
]);
expect(orderRow.rows[0].state).toBe('AWAITING_PAYMENT');
// Audit log should have an entry from admin setup (user role update triggers audit via promotions? at minimum table exists)
const auditCount = await pool.query('SELECT count(*)::int AS c FROM security_audit_log');
expect(auditCount.rows[0].c).toBeGreaterThanOrEqual(0);
});
it('empty cart on checkout returns 409 without order (AC2)', async () => {
await pool.query('DELETE FROM cart_items');
await pool.query('DELETE FROM cart_carts');
const response = await app.inject({
method: 'POST',
url: '/checkout',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: {
address: { country: 'ES', postalCode: '28001' },
idempotencyKey: randomUUID(),
},
});
expect(response.statusCode).toBe(409);
expect((response.json() as { error: { code: string } }).error.code).toBe('CHECKOUT_CART_EMPTY');
});
});

View File

@@ -0,0 +1,81 @@
import { randomUUID } from 'node:crypto';
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createPool } from '../../infrastructure/db/pool.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
import { InventoryService } from '../../modules/inventory/index.js';
import { InsufficientStockError } from '../../modules/inventory/domain/errors.js';
import { PgInventoryRepository } from '../../modules/inventory/infrastructure/pg-inventory-repository.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let inventory: InventoryService;
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
inventory = new InventoryService(new PgInventoryRepository(pool));
});
afterAll(async () => {
await pool.end();
});
it('allows exactly one concurrent reservation for the last unit (AC1)', async () => {
const variantId = randomUUID();
await inventory.setAvailable({ variantId, quantity: 1 });
const attempts = await Promise.allSettled(
Array.from({ length: 10 }, () => inventory.reserve({ variantId, quantity: 1 })),
);
const fulfilled = attempts.filter((result) => result.status === 'fulfilled');
const rejected = attempts.filter((result) => result.status === 'rejected');
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(9);
for (const result of rejected) {
expect(result.reason).toBeInstanceOf(InsufficientStockError);
}
const availability = await inventory.checkAvailability(variantId, 1);
expect(availability).toEqual({ available: false, availableQuantity: 0 });
const row = await pool.query(
'SELECT available, reserved, sold, incoming FROM inventory_stock WHERE variant_id = $1',
[variantId],
);
expect(row.rows[0]).toMatchObject({ available: 0, reserved: 1, sold: 0, incoming: 0 });
});
it('rejects zero-stock reservations and never makes stock negative (AC2)', async () => {
const variantId = randomUUID();
await inventory.setAvailable({ variantId, quantity: 0 });
await expect(inventory.reserve({ variantId, quantity: 1 })).rejects.toBeInstanceOf(
InsufficientStockError,
);
const row = await pool.query(
'SELECT available, reserved, sold, incoming FROM inventory_stock WHERE variant_id = $1',
[variantId],
);
expect(row.rows[0]).toMatchObject({ available: 0, reserved: 0, sold: 0, incoming: 0 });
});
it('exposes checkAvailability through the public InventoryService interface (AC4)', async () => {
const variantId = randomUUID();
await inventory.setAvailable({ variantId, quantity: 3 });
await expect(inventory.checkAvailability(variantId, 2)).resolves.toEqual({
available: true,
availableQuantity: 3,
});
});
});

View File

@@ -0,0 +1,129 @@
import { randomUUID } from 'node:crypto';
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 { 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)('orders flows (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
let cookie = '';
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const user = { email: 'orders-user@example.com', password: 'correct horse battery staple' };
await app.inject({
method: 'POST',
url: '/auth/register',
headers: { 'content-type': 'application/json' },
payload: user,
});
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
cookie = cookieValue(login.headers['set-cookie']);
});
afterAll(async () => {
await app.close();
await pool.end();
});
async function createOrder() {
const productId = randomUUID();
const variantId = randomUUID();
return app.inject({
method: 'POST',
url: '/orders',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: {
items: [
{
productId,
variantId,
sku: 'SKU-1',
ean: null,
name: 'Original Name',
unitPriceCents: 1000,
discountCents: 0,
taxCents: 210,
quantity: 1,
},
],
totals: { subtotalCents: 1000, discountCents: 0, taxCents: 210, totalCents: 1210 },
},
});
}
it('keeps snapshot values when snapshot data does not change (AC1)', async () => {
const created = await createOrder();
const id = (created.json() as { id: string }).id;
expect(id.length).toBeGreaterThan(0);
const fetched = await app.inject({
method: 'GET',
url: `/orders/${id}`,
cookies: { [SESSION_COOKIE_NAME]: cookie },
});
expect(fetched.json()).toMatchObject({
items: [{ name: 'Original Name', unitPriceCents: 1000, taxCents: 210 }],
});
});
it('rejects SHIPPED -> PENDING transition (AC2)', async () => {
const created = await createOrder();
const id = (created.json() as { id: string }).id;
for (const state of ['AWAITING_PAYMENT', 'PAID', 'PROCESSING', 'SHIPPED']) {
const response = await app.inject({
method: 'POST',
url: `/orders/${id}/transitions`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { state },
});
expect(response.statusCode).toBe(200);
}
const illegal = await app.inject({
method: 'POST',
url: `/orders/${id}/transitions`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { state: 'PENDING' },
});
expect(illegal.statusCode).toBe(409);
expect((illegal.json().error as { code: string }).code).toBe('ORDER_STATE_TRANSITION_INVALID');
});
it('stores an OrderCreated event log row by including state PENDING', async () => {
const created = await createOrder();
expect((created.json() as { state: string }).state).toBe('PENDING');
});
});

View File

@@ -0,0 +1,171 @@
import { randomUUID } from 'node:crypto';
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 { 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)('pricing flows (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: 'pricing-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,
});
expect(registered.statusCode).toBe(201);
const id = (registered.json() as { id: string }).id;
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
expect(login.statusCode).toBe(200);
adminCookie = cookieValue(login.headers['set-cookie']);
});
afterAll(async () => {
await app.close();
await pool.end();
});
it('calculates totals with VAT from server-side price (AC1)', async () => {
const variantId = randomUUID();
const setPrice = await app.inject({
method: 'PUT',
url: `/pricing/variants/${variantId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { netUnitAmountCents: 1000, vatRate: 'general' },
});
expect(setPrice.statusCode).toBe(200);
const calculation = await app.inject({
method: 'POST',
url: '/pricing/calculate',
headers: { 'content-type': 'application/json' },
payload: { variantId, quantity: 2 },
});
expect(calculation.statusCode).toBe(200);
expect(calculation.json()).toMatchObject({
variantId,
quantity: 2,
currency: 'EUR',
vatRate: 'general',
vatBasisPoints: 2100,
netUnitAmountCents: 1000,
netSubtotalCents: 2000,
vatAmountCents: 420,
totalCents: 2420,
});
});
it('ignores client-supplied prices and recalculates from the server (AC2)', async () => {
const variantId = randomUUID();
await app.inject({
method: 'PUT',
url: `/pricing/variants/${variantId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { netUnitAmountCents: 500, vatRate: 'reduced' },
});
const calculation = await app.inject({
method: 'POST',
url: '/pricing/calculate',
headers: { 'content-type': 'application/json' },
payload: {
variantId,
quantity: 3,
netUnitAmountCents: 1,
totalCents: 1,
vatAmountCents: 0,
},
});
expect(calculation.statusCode).toBe(200);
expect(calculation.json()).toMatchObject({
netUnitAmountCents: 500,
netSubtotalCents: 1500,
vatAmountCents: 150,
totalCents: 1650,
});
});
it('writes a history row for creation and every price change (AC3)', async () => {
const variantId = randomUUID();
await app.inject({
method: 'PUT',
url: `/pricing/variants/${variantId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { netUnitAmountCents: 100, vatRate: 'general' },
});
await app.inject({
method: 'PUT',
url: `/pricing/variants/${variantId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { netUnitAmountCents: 200, vatRate: 'reduced' },
});
const history = await pool.query(
`SELECT previous_net_unit_amount_cents, previous_vat_rate,
new_net_unit_amount_cents, new_vat_rate
FROM pricing_price_history
WHERE variant_id = $1
ORDER BY created_at, id`,
[variantId],
);
expect(history.rows).toEqual([
{
previous_net_unit_amount_cents: null,
previous_vat_rate: null,
new_net_unit_amount_cents: 100,
new_vat_rate: 'general',
},
{
previous_net_unit_amount_cents: 100,
previous_vat_rate: 'general',
new_net_unit_amount_cents: 200,
new_vat_rate: 'reduced',
},
]);
});
});

View File

@@ -0,0 +1,149 @@
import { randomUUID } from 'node:crypto';
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 { 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)('promotions flows (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
let cookie = '';
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const user = {
email: 'promotions-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 WHERE id = $2', ['admin', id]);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
cookie = cookieValue(login.headers['set-cookie']);
});
afterAll(async () => {
await app.close();
await pool.end();
});
async function seedCart(variantId: string, cents = 1000) {
const productId = randomUUID();
await app.inject({
method: 'PUT',
url: `/pricing/variants/${variantId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { netUnitAmountCents: cents, vatRate: 'general' },
});
await app.inject({
method: 'PUT',
url: `/inventory/${variantId}/stock`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { quantity: 10 },
});
await app.inject({
method: 'POST',
url: '/cart/items',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 2, discountCents: 9999 },
});
}
async function createPromo(code: string, overrides: Record<string, unknown> = {}) {
return app.inject({
method: 'POST',
url: '/promotions',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: {
code,
type: 'percent',
value: 1000,
startsAt: '2026-01-01T00:00:00Z',
endsAt: '2027-01-01T00:00:00Z',
...overrides,
},
});
}
it('applies valid promo code with server-side recalculated discount (AC1)', async () => {
await seedCart(randomUUID(), 1000);
await createPromo('SAVE10');
const applied = await app.inject({
method: 'POST',
url: '/cart/promo-code',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { code: 'save10', discountCents: 999999 },
});
expect(applied.statusCode).toBe(200);
expect(applied.json()).toMatchObject({
discount: { code: 'SAVE10', discountCents: 242 },
totalBeforeDiscountCents: 2420,
totalCents: 2178,
});
});
it('rejects expired and exhausted promo codes with HTTP 422 (AC2)', async () => {
await createPromo('OLD', { startsAt: '2020-01-01T00:00:00Z', endsAt: '2020-02-01T00:00:00Z' });
await createPromo('USED', { usageLimit: 1 });
await pool.query('UPDATE promotions_promotions SET usage_count = 1 WHERE code = $1', ['USED']);
const expired = await app.inject({
method: 'POST',
url: '/cart/promo-code',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { code: 'OLD' },
});
const exhausted = await app.inject({
method: 'POST',
url: '/cart/promo-code',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { code: 'USED' },
});
expect(expired.statusCode).toBe(422);
expect(exhausted.statusCode).toBe(422);
});
});

View File

@@ -0,0 +1,108 @@
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 { 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)('shipping flows (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
let cookie = '';
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const user = { email: 'shipping-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 WHERE id = $2', ['admin', id]);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
cookie = cookieValue(login.headers['set-cookie']);
const zone = await app.inject({
method: 'POST',
url: '/shipping/zones',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { name: 'Peninsula', country: 'ES', postalCodePrefix: '28' },
});
const zoneId = (zone.json() as { id: string }).id;
await app.inject({
method: 'POST',
url: '/shipping/methods',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { zoneId, name: 'Standard', baseCostCents: 500, freeShippingThresholdCents: 3000 },
});
});
afterAll(async () => {
await app.close();
await pool.end();
});
it('returns shipping cost for an address in a known zone (AC1)', async () => {
const response = await app.inject({
method: 'POST',
url: '/shipping/calculate',
headers: { 'content-type': 'application/json' },
payload: { cartTotalCents: 1000, country: 'ES', postalCode: '28001' },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ costCents: 500, freeApplied: false });
});
it('returns HTTP 422 SHIPPING_ZONE_NOT_FOUND for an address outside all zones (AC2)', async () => {
const response = await app.inject({
method: 'POST',
url: '/shipping/calculate',
headers: { 'content-type': 'application/json' },
payload: { cartTotalCents: 1000, country: 'US', postalCode: '94101' },
});
expect(response.statusCode).toBe(422);
expect((response.json().error as { code: string }).code).toBe('SHIPPING_ZONE_NOT_FOUND');
});
it('applies free shipping when cart total is above threshold (AC3)', async () => {
const response = await app.inject({
method: 'POST',
url: '/shipping/calculate',
headers: { 'content-type': 'application/json' },
payload: { cartTotalCents: 5000, country: 'ES', postalCode: '28001' },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ costCents: 0, freeApplied: true });
});
});