feat(ADM-018): completed feature
This commit is contained in:
100
project/src/app/tests/brands.itest.ts
Normal file
100
project/src/app/tests/brands.itest.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
164
project/src/app/tests/cart.itest.ts
Normal file
164
project/src/app/tests/cart.itest.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
269
project/src/app/tests/catalog.itest.ts
Normal file
269
project/src/app/tests/catalog.itest.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
140
project/src/app/tests/categories.itest.ts
Normal file
140
project/src/app/tests/categories.itest.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
180
project/src/app/tests/checkout.itest.ts
Normal file
180
project/src/app/tests/checkout.itest.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
156
project/src/app/tests/e2e/checkout-flow.itest.ts
Normal file
156
project/src/app/tests/e2e/checkout-flow.itest.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
81
project/src/app/tests/inventory.itest.ts
Normal file
81
project/src/app/tests/inventory.itest.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
129
project/src/app/tests/orders.itest.ts
Normal file
129
project/src/app/tests/orders.itest.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
171
project/src/app/tests/pricing.itest.ts
Normal file
171
project/src/app/tests/pricing.itest.ts
Normal 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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
149
project/src/app/tests/promotions.itest.ts
Normal file
149
project/src/app/tests/promotions.itest.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
108
project/src/app/tests/shipping.itest.ts
Normal file
108
project/src/app/tests/shipping.itest.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user