12-testing.md
Testing · visión
Marlow tiene 264 tests en 36 ficheros, todos con Vitest sobre jsdom a través del builder @angular/build:unit-test. La suite entera tarda unos 3 segundos y no necesita red ni base de datos: sin DATABASE_URL, el libro de la tienda vive en memoria y cada test lo reinicia (resetShopServer()).
| Capa | Ficheros | Tests | Qué cubre |
|---|---|---|---|
| Unitarios | 16 | 114 | Reglas puras: direcciones, recorridos, sesión firmada, stock, dinero, idioma, plantillas de email |
| Integración | 11 | 69 | La API de la tienda llamada directamente, la cuenta, el webhook de Neon Auth, el envío de email y las migraciones SQL |
| Flujos completos | 9 | 81 | La app entera renderizada con sus rutas reales y la API interceptada hacia el servidor de verdad |
| CI | — | — | No hay GitHub Actions; el build de la plataforma es la única puerta automática |
Comandos
npm test -- --watch=false # una pasada: los 264 tests
npm test # en una terminal, se queda observando cambios
npm run test:scripts # node --test sobre scripts/ (los 28 tests de la plantilla)
Convenciones
- El nombre del test es una frase del comportamiento, escrita como lo diría la tienda: “lets a guest reach confirmation without signing in”, “takes no money for a cancelled order”.
- Sin mocks del dominio. Los tests de rutas no simulan la API: un
HttpInterceptorFnllama ahandleShopHttp, el mismo código que corre en el servidor. - Lo externo se sustituye en el borde: un mailer falso que recoge los emails, un directorio de cuentas falso en lugar de Neon, claves Ed25519 generadas en el test para el webhook.
- El tiempo se controla.
SessionSealy el webhook reciben un reloj, y el carrusel corre con temporizadores falsos de Vitest, así que caducidades y turnos se prueban sin esperar. - Personas fijas: Avery (cliente), Jordan (personal) y Sloan (administración), con sesiones abiertas por
openSession, como hace el servidor tras Neon Auth.
13-testing-unit.md
Testing · unitarios
Funciones y clases sin TestBed, sin HTTP y sin base de datos. 16 ficheros, 114 tests.
| Fichero | Tests | Cubre |
|---|---|---|
shop/address-rules.spec.ts | 15 | Nombre, email, teléfono, calle y ciudad; formato postal y región por país |
shop/shop-cloth.spec.ts | 11 | Tejido y cuidados, fotos por color (y sus dos ficheros en disco), prendas relacionadas, filas de Neon a prendas y pedidos |
shop/shop-floor.spec.ts | 9 | Estados de stock, tallas por color, agotado, envío gratis, dólares a céntimos, bolsa guardada |
shop/fulfillment.spec.ts | 7 | Recorridos de envío y recogida, la frase de cada paso, qué se puede cancelar |
shop/order-list.spec.ts | 7 | Buscar y ordenar los pedidos del cliente desde la URL |
shop/session-seal.spec.ts | 6 | Firmar, leer, caducar y rechazar tokens |
i18n/i18n.spec.ts | 6 | Idioma del navegador frente a la elección guardada, frases sin traducción y sus marcadores |
mail/shop-emails.spec.ts | 19 | Cada plantilla de email, en inglés y en español |
ui/slide-show.spec.ts, ui/slide-track.spec.ts | 13 | Turnos del carrusel, pausa, movimiento reducido, seguimiento del deslizamiento |
stale-build.spec.ts | 5 | Recargar una vez cuando falta el script de un build anterior |
layout/site-header/header-tuck.spec.ts | 5 | La cabecera que se esconde al bajar y vuelve al subir |
| Otros | 11 | Cola del panel de tienda, cliente de Neon Auth, mailer, ficheros estáticos |
Recorridos (shop/fulfillment.spec.ts)
Un envío y una recogida comparten los dos primeros pasos y terminan distinto. Un pedido cancelado no tiene recorrido, solo su nombre.
it('follows a shipped order from the shop to the door', () => {
expect(labels(shipped('placed'))).toEqual([
'Order placed',
'Being prepared',
'On its way',
'Delivered',
]);
expect(statuses(shipped('shipped'))).toEqual(['done', 'done', 'current', 'upcoming']);
});
it('has no track for a cancelled order, only its name', () => {
expect(fulfillmentTrack(shipped('cancelled'))).toEqual([]);
expect(fulfillmentName('cancelled')).toBe('Cancelled');
});
Direcciones (shop/address-rules.spec.ts)
Una tabla de casos por país. Las mismas funciones validan el formulario y el pedido en el servidor.
const cases: [string, string, boolean][] = [
['United States', '02903', true],
['United States', '2903', false],
['Spain', '28013', true],
['Spain', '53001', false],
['Canada', 'K1A 0B1', true],
['Canada', 'D1A 0B1', false],
['United Kingdom', 'SW1A 1AA', true],
['Portugal', '1100-148', true],
// …
];
Sesión firmada (shop/session-seal.spec.ts)
Un token firmado por un proceso lo lee otro con la misma clave, como tras un reinicio. Otra clave, un payload cambiado a mano o un token de las cuentas compartidas retiradas no valen.
it('is read by any server that shares the key, as after a restart', () => {
const token = sealAt(() => NOW).issue(ADA);
expect(sealAt(() => NOW).read(token)).toEqual(ADA);
});
it('is refused by a server with another key', () => {
const token = sealAt(() => NOW).issue(ADA);
expect(sealAt(() => NOW, 'postgres://another-secret').read(token)).toBeNull();
});
Build antiguo (stale-build.spec.ts)
it('loads the page in full when its script belongs to a build the server replaced', async () => {
const router = openShop(() => Promise.reject(CHROME_MISSING_SCRIPT));
await goTo(router, '/checkout?from=bag');
expect(fullLoads).toEqual(['/checkout?from=bag']);
});
it('does not reload in a loop when the full load meets the same failure', async () => {
const router = openShop(() => Promise.reject(CHROME_MISSING_SCRIPT));
await goTo(router, '/checkout');
await goTo(router, '/checkout');
expect(fullLoads).toEqual(['/checkout']);
});
El mismo fichero comprueba que reconoce el error de script ausente en Chrome, Firefox y Safari, que tienen mensajes distintos.
14-testing-integration.md
Testing · integración
Varios módulos juntos, con lo externo sustituido en el borde. Casi todos llaman a handleShopHttp(method, path, body, authorization), la función que el servidor Express usa para cada /api/*, y leen el resultado como lo leería el navegador. 11 ficheros, 69 tests.
| Fichero | Tests | Cubre |
|---|---|---|
shop/account-server.spec.ts | 11 | Sesión guardada, cuentas retiradas, cambiar nombre, borrar la cuenta con un JWT reciente |
mail/neon-auth-webhook.spec.ts | 10 | Firma Ed25519, reintentos, enlaces de contraseña en el idioma recordado |
shop/pickup.spec.ts | 8 | Pedido para recoger: sin dirección, sin envío, pago en tienda solo ahí |
mail/shop-mail.spec.ts | 8 | Qué email sale en cada momento, y que un fallo no tumba el pedido |
shop/bank-transfer.spec.ts | 7 | Pago pendiente, enlace firmado, pagar una vez, cancelado no cobra |
shop/shop-domain.spec.ts | 7 | Catálogo, bolsa, checkout, permisos del personal y entrada del servidor |
shop/order-cancel.spec.ts | 6 | Quién cancela, hasta cuándo, y que el stock vuelve |
shop/shop-presentation.spec.ts | 4 | Tailwind 4 sobre tokens, SSR, sin Ionic, Archivo autoalojada, movimiento reducido |
shop/neon-accounts.spec.ts | 3 | Renombrar y borrar en una transacción, con un cliente SQL falso |
shop/shop-migration.spec.ts | 3 | Las migraciones leídas como texto: RLS, permisos, sin tarjeta |
shop/neon-auth-relay.spec.ts | 2 | El relay devuelve la cookie como propia y rechaza rutas ajenas sin llamar a Neon |
Transferencia (shop/bank-transfer.spec.ts)
El enlace del email paga el pedido una sola vez; un código que no es el del pedido da 404 sin decir si el pedido existe.
it('marks the order paid once the transfer is sent, and only once', () => {
const order = placed('bank-transfer', `Bearer ${openSession(ADA).token}`);
const pay = apiPathOf(linkOf(order));
const sent = handleShopHttp('POST', pay, {}, null);
const again = handleShopHttp('POST', pay, {}, null);
expect((sent.body as TransferDetails).state).toBe('paid');
expect((again.body as TransferDetails).state).toBe('paid');
});
it('refuses a link whose code is not the shop own, without saying whether the order exists', () => {
const order = placed();
const forged = `/api/transfers/${order.reference}/not-the-code-for-this-order`;
const refusal = { status: 404, body: { message: 'That payment link is not valid.' } };
expect(handleShopHttp('GET', forged, null, null)).toEqual(refusal);
expect(handleShopHttp('POST', forged, {}, null)).toEqual(refusal);
});
Cancelar (shop/order-cancel.spec.ts)
it('lets a client cancel their own order and puts its pieces back on the floor', () => {
const before = shirtsOnTheFloor();
const order = shirtOrder(avery);
expect(shirtsOnTheFloor()).toBe(before - 2);
const cancelled = cancelOrder(avery, order.reference);
expect(cancelled.fulfillment).toBe('cancelled');
expect(shirtsOnTheFloor()).toBe(before);
});
Otros casos del fichero: no se cancela un pedido que ya salió, ni uno de otro cliente, ni uno de invitado, y sin sesión la API responde 403.
Webhook de Neon Auth (mail/neon-auth-webhook.spec.ts)
El test genera su propio par de claves Ed25519 y firma los cuerpos como lo haría Neon. Un cuerpo cambiado tras firmar, una clave que Neon no publica o una entrega de hace más de 5 minutos se rechazan.
it('refuses a replay older than five minutes', async () => {
const old = String(NOW - 6 * 60_000);
await expect(verifyNeonWebhook(RESET, headers(RESET, old), keys, NOW)).rejects.toThrow();
});
Email (mail/shop-mail.spec.ts)
Un mailer falso recoge cada mensaje. Si falla, el pedido sigue y el error queda en el log.
it('never fails the order when the email cannot go out, and says why in the log', async () => {
mailer.failing = true;
await expect(mail.orderPlaced(ORDER, null)).resolves.toBeUndefined();
expect(reported).toHaveLength(1);
});
Migraciones (shop/shop-migration.spec.ts)
Lee db/migrations/*.sql como texto. Si un cambio diera escritura a anonymous, moviera el rol a una columna editable o añadiera un campo de tarjeta, el test falla antes de que la migración llegue a una rama de Neon.
expect(sql).not.toMatch(/grant\s+(insert|update|delete|all)[^;]*\bto\s+anonymous/i);
expect(sql).toContain('client_id = (select auth.uid())');
expect(sql).not.toContain('card_number'); 15-testing-e2e.md
Testing · flujos completos
Marlow no tiene Playwright. Los recorridos de punta a punta se prueban con la app entera en TestBed: las rutas reales de app.routes.ts, el shell, los guards y las páginas, sobre jsdom. Las llamadas a /api/* no se simulan: un interceptor las pasa a handleShopHttp, el mismo código del servidor, con el libro de la tienda en memoria. 9 ficheros, 81 tests.
const shopApiInterceptor: HttpInterceptorFn = (req, next) => {
const url = new URL(req.url, 'http://marlow.test');
if (!url.pathname.startsWith('/api/')) {
return next(req);
}
const result = handleShopHttp(
req.method,
url.pathname,
req.body,
req.headers.get('Authorization'),
);
// ≥ 400 → HttpErrorResponse; si no, HttpResponse con result.body
};
| Fichero | Tests | Cubre |
|---|---|---|
shop/shop-routes.spec.ts | 33 | Compra, checkout, color y talla, foto a pantalla completa, cancelación, transferencia, panel de tienda, permisos |
pages/account/account-pages.spec.ts | 16 | Entrar con Neon Auth, cambiar nombre y contraseña, borrar la cuenta, recuperar la contraseña, retomar o renovar la sesión |
layout/shell.spec.ts | 11 | Landmarks, enlace de salto, foco tras navegar, la bolsa que se abre y se cierra, el aviso de página lenta |
app.spec.ts | 5 | La portada con las 8 prendas y sus precios, qué fotos se cargan con la página, una categoría con su nota de corte, una URL desconocida vuelve a / |
shop/bag.spec.ts | 4 | El panel se abre en la línea añadida; la bolsa se guarda y se restaura |
| Otros | 12 | Carrusel de entrada, lupa de foto, revelado al hacer scroll, botones de la tienda |
Compra como invitado
it('lets a guest reach confirmation without signing in', async () => {
const { fixture } = await render('/product/oxford-shirt');
await choose(fixture, 'input[name="size"][value="S"]');
await clickButton(fixture, 'Add to bag');
expect(document.body.textContent).toContain('Added Oxford Cloth Shirt, White S.');
await go(fixture, '/checkout');
await typeInto(fixture, 'contact-name', 'Ada Lovelace');
await typeInto(fixture, 'contact-email', 'ada@example.com');
await typeInto(fixture, 'contact-phone', '5550148');
await typeInto(fixture, 'ship-line1', '12 Loom Street');
await typeInto(fixture, 'ship-city', 'Providence');
await pick(fixture, 'ship-region', 'Rhode Island');
await typeInto(fixture, 'ship-postal', '02903');
await choose(fixture, 'input[value="bank-transfer"]');
await clickButton(fixture, 'Place order');
expect(TestBed.inject(Router).url).toBe('/order/MR-1001');
expect(document.body.textContent).toContain('Your order is placed.');
expect(document.body.textContent).toContain('Providence, Rhode Island 02903');
});
Permisos por rol
El mismo test pasa por un visitante, un cliente, el personal y la administración, y comprueba qué abre cada uno.
it('refuses clients at both staff areas and workers at the backoffice', async () => {
const { fixture } = await render('/owner');
expect(document.body.textContent).toContain('closed to you');
adopt(fixture, AVERY); // cliente
await go(fixture, '/desk');
expect(document.body.textContent).toContain('closed to you');
adopt(fixture, JORDAN); // personal
await go(fixture, '/owner/pieces');
expect(document.body.textContent).toContain('closed to you');
await go(fixture, '/desk/stock');
expect(document.getElementById('piece-price')).toBeNull();
adopt(fixture, SLOAN); // administración
await go(fixture, '/owner/pieces');
expect(document.getElementById('piece-price')).not.toBeNull();
});
Otros recorridos del fichero
- Recoger en tienda, gratis y pagando allí; cambiar entre envío y recogida sin perder la dirección escrita.
- Un campo muestra su error al salir de él; el checkout incompleto conserva lo escrito y señala lo que falta.
- La dirección se adapta al país: estado en Estados Unidos, formato español en España.
- Elegir color en la tarjeta cambia la foto y abre la ficha en ese color.
- La ficha pide una talla en lugar de adivinarla.
- El cliente cancela su pedido tras confirmar; al invitado se le dice que lo pida en la tienda.
- La transferencia aparece pendiente, se paga en el banco simulado y pasa a Pagado.
- Un pedido para recoger avanza por sus propios pasos en el panel de tienda.
- Back vuelve por el historial si se vino desde la tienda, y a un sitio sensato si la página se abrió desde fuera.
Lo que no cubren
jsdom no pinta: no hay layout, ni showModal() (ui/dialog.ts usa el atributo open como reserva), ni View Transitions. El aspecto, el móvil y la accesibilidad se revisaron con Chrome headless y axe-core durante el trabajo (escritorio y móvil, claro y oscuro), con scripts que no están en el repo.
16-testing-ci.md
Testing · CI
No hay .github/workflows en el repo. Lo que corre solo en cada push a main es el build de la plataforma de hosting, y ese build no lanza los tests.
Qué para un deploy hoy
| Puerta | Dónde | Qué para |
|---|---|---|
npm ci | Plataforma de hosting | Un package-lock.json desfasado |
ng build | Plataforma de hosting | Un error de TypeScript o de plantilla, o un bundle inicial de más de 1 MB |
npm test | A mano, antes de cada commit | Un cambio que rompe un comportamiento probado |
PROJECT.md define cuándo un cambio está terminado: el build pasa, los tests pasan e incluyen el comportamiento cambiado, las rutas SSR responden con HTML renderizado, npm run test:scripts pasa si cambió scripts/, y no hay secretos en ficheros versionados. Hoy esa regla la cumple quien hace el commit, no una máquina.
Qué haría un workflow
La suite no necesita servicios: sin DATABASE_URL, la tienda vive en memoria. Un job bastaría:
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test -- --watch=false
- run: npm run test:scripts
- run: npm run build
Ningún secreto haría falta en GitHub. Tampoco Chromium, porque los flujos completos corren en jsdom.