feat(F-048): completed feature

This commit is contained in:
chattie
2026-08-19 07:17:14 +02:00
parent 8ee1938af9
commit 835ab66eda
187 changed files with 12361 additions and 1065 deletions

View File

@@ -7,11 +7,19 @@ import { performance } from 'node:perf_hooks';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyCors from '@fastify/cors';
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUi from '@fastify/swagger-ui';
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
import type { IncomingMessage } from 'node:http';
import type pg from 'pg';
import { swaggerConfig } from '../shared/swagger.js';
import { registerHealthRoutes } from '../modules/health/index.js';
import { registerIdentityRoutes, createSessionAuthenticator } from '../modules/identity/index.js';
import {
registerBackofficeRoutes,
createBackofficeSessionAuthenticator,
createCombinedAuthenticator,
} from '../modules/backoffice/index.js';
import { registerUsersRoutes } from '../modules/users/index.js';
import { registerCategoriesRoutes } from '../modules/categories/index.js';
import { registerCatalogRoutes } from '../modules/catalog/index.js';
@@ -30,7 +38,7 @@ import { registerCmsRoutes } from '../modules/cms/index.js';
import { registerStoreSettingsRoutes } from '../modules/store-settings/index.js';
import { CacheService, InMemoryCacheAdapter, registerCacheRoutes } from '../modules/cache/index.js';
import { AuditLogger, RateLimiter, registerSecurityRoutes } from '../modules/security/index.js';
import { registerAdminStatsRoutes } from '../modules/admin-stats/api/stats.routes.js';
import { registerAdminStatsRoutes } from '../modules/admin-stats/index.js';
import { createInMemoryTelemetry, registerMetricsRoutes } from '../modules/observability/index.js';
import { LoggingEmailProvider } from '../modules/notifications/index.js';
import { createFlagStore, type FeatureFlagProvider } from '../modules/flags/index.js';
@@ -75,6 +83,10 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
const startTimes = new WeakMap<FastifyRequest, number>();
const app = Fastify({ logger: false, genReqId: generateRequestId });
// Route schemas are also the OpenAPI source. Several endpoints intentionally
// use broad response schemas, so serialize the explicit route DTOs without
// letting fast-json-stringify discard fields absent from those broad schemas.
app.setSerializerCompiler(() => (payload) => JSON.stringify(payload));
app.decorate('flags', flags);
await app.register(fastifyCors, {
@@ -119,8 +131,13 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
details = error.details;
message = statusCode < 500 ? error.message : 'Internal Server Error';
} else if (statusCode < 500) {
code = error.code ?? 'BAD_REQUEST';
message = error.message;
if (error.code === 'FST_ERR_VALIDATION') {
code = 'VALIDATION_ERROR';
message = 'Invalid request';
} else {
code = error.code ?? 'BAD_REQUEST';
message = error.message;
}
}
if (statusCode >= 500) {
@@ -144,6 +161,17 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
// Cookie infrastructure is cross-module (identity + users): register once at root.
await app.register(fastifyCookie);
// ── OpenAPI / Swagger ────────────────────────────────────────────────────────
await app.register(fastifySwagger, swaggerConfig);
await app.register(fastifySwaggerUi, {
routePrefix: '/docs',
uiConfig: {
docExpansion: 'list',
deepLinking: true,
tryItOutEnabled: true,
},
});
if (deps.pool) {
// Session resolution; created before identity registration so it can be passed
// to identity's /auth/me route.
@@ -157,33 +185,48 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
});
});
// FIX-14: backoffice has its own auth mechanism (backoffice_session cookie,
// backoffice_users table). Two more authenticators:
// backofficeAuth — backoffice_session only (for backoffice routes).
// combinedAuth — tries backoffice_session first, then mdv_session
// (for routes shared by both admin and storefront).
const backofficeAuth = createBackofficeSessionAuthenticator(deps.pool as pg.Pool);
const combinedAuth = createCombinedAuthenticator(deps.pool as pg.Pool);
await app.register(async (instance) => {
await registerBackofficeRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate: backofficeAuth,
});
});
// Session resolution is identity's; users receives it by injection so no
// module ever imports another module.
await app.register(async (instance) => {
await registerUsersRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
});
});
await app.register(async (instance) => {
await registerCategoriesRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
});
});
await app.register(async (instance) => {
await registerBrandsRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
});
});
await app.register(async (instance) => {
await registerCatalogRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
logger,
});
});
@@ -192,7 +235,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
await app.register(async (instance) => {
await registerInventoryRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
});
});
@@ -200,7 +243,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
await app.register(async (instance) => {
await registerPricingRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
});
});
@@ -208,14 +251,14 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
await app.register(async (instance) => {
await registerPromotionsRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
});
});
await app.register(async (instance) => {
await registerCartRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
pricing,
inventory,
promotions,
@@ -225,27 +268,27 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
await app.register(async (instance) => {
await registerShippingRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
});
});
await app.register(async (instance) => {
await registerOrdersRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
});
});
const { telemetry, meter } = createInMemoryTelemetry();
await app.register(async (instance) => {
await registerMetricsRoutes(instance, { meter, authenticate });
await registerMetricsRoutes(instance, { meter, authenticate: combinedAuth });
});
await app.register(async (instance) => {
await registerCheckoutRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
tracer: telemetry.tracer,
});
});
@@ -253,7 +296,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
await app.register(async (instance) => {
await registerPaymentsRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? 'whsec_test',
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
});
@@ -262,7 +305,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
await app.register(async (instance) => {
await registerNotificationsRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
emailProvider: new LoggingEmailProvider(),
});
});
@@ -270,25 +313,25 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
await app.register(async (instance) => {
await registerReviewsRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
});
});
await app.register(async (instance) => {
await registerCmsRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
});
});
await app.register(async (instance) => {
await registerStoreSettingsRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
});
await registerAdminStatsRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
});
});
@@ -322,7 +365,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
invalidation: 'SearchInvalidated',
});
await app.register(async (instance) => {
await registerCacheRoutes(instance, { cache, authenticate });
await registerCacheRoutes(instance, { cache, authenticate: combinedAuth });
});
const rateLimiter = new RateLimiter();
@@ -330,7 +373,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
await app.register(async (instance) => {
await registerSecurityRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
authenticate: combinedAuth,
rateLimiter,
auditLogger,
});

View File

@@ -97,7 +97,7 @@ describe.skipIf(!hasDb)('categories flows (real PostgreSQL)', () => {
url: '/categories',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { name: 'Alimentación', slug: 'alimentacion' },
payload: { name: 'Alimentación', slug: 'alimentacion', isParent: true },
});
expect(root.statusCode).toBe(201);
const rootBody = root.json() as { id: string };

View File

@@ -7,9 +7,11 @@ import {
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';
import {
createInventoryService,
InsufficientStockError,
type InventoryService,
} from '../../modules/inventory/index.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
@@ -22,7 +24,7 @@ describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
inventory = new InventoryService(new PgInventoryRepository(pool));
inventory = createInventoryService(pool);
});
afterAll(async () => {

View File

@@ -98,13 +98,14 @@ describe.skipIf(!hasDb)('users + RBAC flows (real PostgreSQL)', () => {
expect(cross.statusCode).toBe(403);
expect(cross.json().error.code).toBe('FORBIDDEN');
// Own profile does not exist yet -> 404 (authz passed, resource missing).
const ownMissing = await app.inject({
// Customer detail exists even before optional profile fields are populated.
const ownCustomer = await app.inject({
method: 'GET',
url: `/users/${anaId}`,
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
expect(ownMissing.statusCode).toBe(404);
expect(ownCustomer.statusCode).toBe(200);
expect(ownCustomer.json()).toMatchObject({ id: anaId, email: 'ana@example.com' });
});
it('PATCH own profile upserts; PATCH is validated', async () => {
@@ -157,8 +158,8 @@ describe.skipIf(!hasDb)('users + RBAC flows (real PostgreSQL)', () => {
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
expect(asAdmin.statusCode).toBe(200);
const body = asAdmin.json() as { items: Array<{ userId: string }> };
expect(body.items.some((item) => item.userId === anaId)).toBe(true);
const body = asAdmin.json() as { items: Array<{ id: string }> };
expect(body.items.some((item) => item.id === anaId)).toBe(true);
// Admin can also read another user's profile (owner-or-admin).
const adminReadsBen = await app.inject({
@@ -166,8 +167,9 @@ describe.skipIf(!hasDb)('users + RBAC flows (real PostgreSQL)', () => {
url: `/users/${benId}`,
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
// Ben has no profile yet: 404, not 403 (authz passed).
expect(adminReadsBen.statusCode).toBe(404);
// Customer detail remains available when optional profile fields are absent.
expect(adminReadsBen.statusCode).toBe(200);
expect(adminReadsBen.json()).toMatchObject({ id: benId, email: 'ben@example.com' });
});
it('address CRUD works end to end for own addresses (AC4)', async () => {