L'API de la Botiga Aroma està construïda: rutes, representacions, validació, persistència, autenticació i errors. I ara mateix l'única prova que compleix el contracte són uns quants curl que vas executar a mà fa tres lliçons i que ningú no tornarà a executar. Això no és una garantia: és memòria. Aquesta lliçó tanca el mòdul convertint aquelles comprovacions en proves automàtiques que s'executen amb npm test en dos segons i que fallen tan bon punt algú trenca una promesa del contracte —un Location que desapareix, un total que passa a comptar només la pàgina, un 403 que es converteix en 401—. Farem servir el runner natiu de Node, sense instal·lar res, i Supertest per llançar peticions contra l'aplicació sense obrir un port. Al final tindràs a més una llista de verificació del contracte: què comprovar abans de donar per bona qualsevol API REST.

Contingut

  1. Què prova cada tipus de prova
  2. La piràmide aplicada a aquesta API
  3. El runner natiu: node:test
  4. Prova unitària del mapejador
  5. Dobles de prova i injecció de dependències
  6. Prova unitària del servei de cafès
  7. Supertest: peticions sense port
  8. Dades de prova: base de dades aïllada
  9. Proves d'integració de la col·lecció
  10. Proves de creació, capçaleres i validació
  11. Proves d'autenticació i autorització
  12. Cobertura i què mesura realment
  13. Proves de contracte: la idea
  14. Exploració manual i Postman
  15. Llista de verificació del contracte de la Botiga Aroma
  16. Balanç del mòdul 3

  1. Què prova cada tipus de prova

Tipus Què exercita Velocitat Què detecta Què no detecta
Unitària Una funció o servei, aïllat Mil·lisegons Errors de lògica i de càlcul Que les peces encaixin
Integració Diverses capes juntes, amb base de dades Desenes de ms Contracte HTTP, SQL, middleware Fallades de xarxa o desplegament
Extrem a extrem Sistema complet desplegat Segons Problemes de configuració real Casos límit (n'hi ha poques)

Les tres responen a preguntes diferents, i confondre-les produeix suites lentes que no detecten res. La regla operativa: com més avall, més proves i més ràpides.

En aquest mòdul escriurem unitàries i d'integració. Les d'extrem a extrem —contra un entorn desplegat de debò, amb la seva base de dades i la seva passarel·la— es reprenen a 05-04, juntament amb les proves de contracte i els mocks.

  1. La piràmide aplicada a aquesta API

Capa Què provem a la Botiga Aroma Quantes
Unitàries centimsAEuros, cafeARepresentacio, enllacosDeComanda, serveiCafes, esquemes Zod Moltes
Integració GET/POST/PATCH/DELETE /v1/cafes, POST /v1/sessions, permisos, errors Bastantes
Extrem a extrem Comprar un cafè de principi a fi contra l'entorn de proves Poques

I una decisió de criteri que estalvia molta feina inútil: no provem Express, ni Zod, ni SQLite. Aquelles llibreries tenen les seves pròpies proves. Provem les nostres decisions: que el preu es converteix bé, que el _links d'una comanda pagada inclou factura i no pagar, que limit=5000 retorna 400 i no retalla, que un client no veu les comandes d'un altre. Tot això està escrit al contracte del mòdul 2, i per això les proves s'escriuen gairebé soles: cada decisió del contracte és una prova.

  1. El runner natiu: node:test

Des de Node 18 no cal Jest, Mocha ni Vitest per a l'essencial: Node porta runner i asseveracions.

import { describe, it, before, beforeEach, after } from 'node:test';
import assert from 'node:assert/strict';
Peça Per a què
describe(nom, fn) Agrupa proves relacionades
it(nom, fn) Una prova concreta
before / after S'executa un cop abans/després de tot el grup
beforeEach / afterEach Abans/després de cada prova
assert Asseveracions

Sobre node:assert/strict: és la variant que fa servir comparació estricta (===) i cal fer servir sempre aquesta. Amb la versió laxa, assert.equal('20', 20) passa, i això és just el tipus de bug que busquem —recorda que els paràmetres de consulta arriben com a text—.

Les asseveracions que farem servir:

assert.equal(resposta.status, 200);                     // igualtat estricta
assert.deepEqual(cos, { dades: [], total: 0 });         // estructures completes
assert.ok(cos.total > 0);                               // cert
assert.match(capcalera, /rel="next"/);                  // expressió regular
assert.throws(() => servei.obtenir('caf_999'), /no existeix/); // llança

Execució:

node --test proves/          # tot
node --test --watch proves/  # rellança en desar
node --test proves/unitaries/mapejadors.test.js   # un fitxer

Node considera fitxer de prova tot el que encaixi amb *.test.js, *-test.js o sigui dins d'una carpeta test/. El nostre conveni és *.test.js dins de proves/. Ja ho teníem al package.json des de 03-01:

"scripts": { "test": "node --test proves/" }

  1. Prova unitària del mapejador

Comencem pel més senzill i alhora més rendible: la conversió de diners, on una fallada silenciosa costa diners de debò.

// proves/unitaries/mapejadors.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
  centimsAEuros,
  eurosACentims,
  cafeARepresentacio,
  comandaARepresentacio,
} from '../../src/serveis/mapejadors.js';

describe('conversió entre cèntims i euros', () => {
  it('converteix cèntims a euros', () => {
    assert.equal(centimsAEuros(1450), 14.5);
    assert.equal(centimsAEuros(1290), 12.9);
    assert.equal(centimsAEuros(0), 0);
    assert.equal(centimsAEuros(1), 0.01);
  });

  it('converteix euros a cèntims sense perdre un cèntim per coma flotant', () => {
    assert.equal(eurosACentims(14.5), 1450);
    assert.equal(eurosACentims(16.75), 1675);
    // El cas crític: 16.75 * 100 dona 1674.9999999999998 a JavaScript.
    // Si eurosACentims fes servir Math.floor o un truncament, això donaria 1674.
    assert.equal(eurosACentims(0.29), 29);
    assert.equal(eurosACentims(1.005), 101);
  });

  it("l'anada i tornada és estable", () => {
    for (const centims of [1, 29, 1450, 1675, 99999]) {
      assert.equal(eurosACentims(centimsAEuros(centims)), centims);
    }
  });
});

describe('cafeARepresentacio', () => {
  const cafeIntern = {
    id: 'caf_001',
    nom: 'Etiòpia Yirgacheffe',
    origen: 'Etiòpia',
    torrefaccio: 'clar',
    preuCentims: 1450,
    estoc: 120,
    notesTast: ['cítric', 'floral'],
    descripcio: null,
    dataCreacio: '2026-01-15T09:00:00Z',
    actiu: true,
    versio: 1,
  };

  it('exposa preuEuros i NO exposa preuCentims', () => {
    const r = cafeARepresentacio(cafeIntern);
    assert.equal(r.preuEuros, 14.5);
    assert.equal(r.preuCentims, undefined);
  });

  it('no filtra camps interns', () => {
    const r = cafeARepresentacio(cafeIntern);
    // 'actiu' i 'versio' són interns: no formen part del contracte.
    assert.equal(r.actiu, undefined);
    assert.equal(r.versio, undefined);
  });

  it('els camps sense valor són presents amb null', () => {
    const r = cafeARepresentacio(cafeIntern);
    assert.ok('descripcio' in r, 'descripcio ha de ser present encara que sigui null');
    assert.equal(r.descripcio, null);
  });

  it('els arrays buits són [] i mai null', () => {
    const r = cafeARepresentacio({ ...cafeIntern, notesTast: undefined });
    assert.deepEqual(r.notesTast, []);
  });

  it("inclou l'enllaç self", () => {
    const r = cafeARepresentacio(cafeIntern);
    assert.equal(r._links.self.href, '/v1/cafes/caf_001');
  });
});

describe("enllaços d'acció d'una comanda segons el seu estat", () => {
  const base = {
    id: 'com_5001',
    clientId: 'cli_842',
    linies: [{ cafeId: 'caf_001', nom: 'Etiòpia Yirgacheffe', quantitat: 2, preuCentims: 1450 }],
    totalCentims: 2900,
    dataCreacio: '2026-03-14T10:30:00Z',
  };

  it('pendent_pagament ofereix pagar i anullar, però no factura', () => {
    const { _links } = comandaARepresentacio({ ...base, estat: 'pendent_pagament' });
    assert.ok(_links.pagar, "ha d'oferir pagar");
    assert.ok(_links.anullar, "ha d'oferir anullar");
    assert.equal(_links.factura, undefined, 'no hi ha factura sense pagament');
    assert.equal(_links.retornar, undefined);
  });

  it('enviat ofereix retornar i factura, però ja no pagar', () => {
    const { _links } = comandaARepresentacio({ ...base, estat: 'enviat' });
    assert.ok(_links.retornar);
    assert.ok(_links.factura);
    assert.equal(_links.pagar, undefined, 'una comanda enviada no es pot tornar a pagar');
  });

  it("el mètode de les accions és POST", () => {
    const { _links } = comandaARepresentacio({ ...base, estat: 'pendent_pagament' });
    assert.equal(_links.pagar.method, 'POST');
  });
});
npm test
✔ conversió entre cèntims i euros (2.1ms)
✔ cafeARepresentacio (1.4ms)
✔ enllaços d'acció d'una comanda segons el seu estat (0.9ms)

ℹ tests 12
ℹ pass 12
ℹ fail 0

Aquestes dotze proves s'executen en mil·lisegons, no necessiten base de dades ni servidor, i cobreixen decisions del contracte de 02-05 que altrament només estarien escrites en prosa. La prova d'actiu i versio és especialment valuosa: és la que detectarà el dia que algú "simplifiqui" el mapejador amb un {...cafe} i filtri camps interns.

  1. Dobles de prova i injecció de dependències

Per provar serveiCafes sense base de dades cal poder substituir-ne el repositori. I avui no es pot, perquè el servei l'importa directament:

import { repositoriCafes } from '../repositoris/index.js';   // fix

La solució és la injecció de dependències: el servei rep el seu repositori en comptes d'importar-lo. Convertim l'objecte en una fàbrica:

// src/serveis/cafes.js  (refactoritzat)
import { errors } from '../errors/error-api.js';
import { eurosACentims } from './mapejadors.js';
import { repositoriCafes } from '../repositoris/index.js';

/**
 * Crea el servei de cafès sobre un repositori concret.
 * En producció es fa servir el de SQLite; a les proves, un de fals en memòria.
 */
export function crearServeiCafes(repositori) {
  return {
    llistar(criteris) {
      return repositori.buscar(criteris);
    },

    obtenir(id) {
      const cafe = repositori.buscarPerId(id);
      if (!cafe) throw errors.noTrobat('cafe', id);
      return cafe;
    },

    crear(dades) {
      return repositori.crear({
        nom: dades.nom.trim(),
        origen: dades.origen.trim(),
        torrefaccio: dades.torrefaccio,
        preuCentims: eurosACentims(dades.preuEuros),
        estoc: dades.estoc,
        notesTast: dades.notesTast ?? [],
        descripcio: dades.descripcio ?? null,
      });
    },

    esborrar(id) {
      if (!repositori.esborrar(id)) throw errors.noTrobat('cafe', id);
    },
    // ...reemplacar i modificar, igual que abans...
  };
}

/** Instància per defecte: la que fan servir els controladors. */
export const serveiCafes = crearServeiCafes(repositoriCafes);

Els controladors no canvien: continuen important serveiCafes. Però ara les proves poden construir la seva pròpia instància.

Sobre els tipus de dobles, perquè la terminologia sovint es fa servir malament:

Doble Què és Exemple aquí
Dummy Es passa per omplir, no es fa servir Un usuari qualsevol
Stub Retorna respostes fixes Un repositori que sempre retorna el mateix cafè
Fake Implementació real però simplificada El nostre repositori en memòria
Mock A més verifica que se l'ha cridat com s'esperava Comprovar que s'ha cridat esborrar una vegada
Spy Registra les crides sense canviar el comportament mock.fn() de node:test

Farem servir sobretot fakes, i aquí arriba la recompensa d'una decisió de 03-05: vam conservar cafes-memoria.js en migrar a SQLite. Aquell fitxer, que semblava codi mort, és ara un fake complet, ja escrit i amb la mateixa interfície.

  1. Prova unitària del servei de cafès

// proves/unitaries/servei-cafes.test.js
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { crearServeiCafes } from '../../src/serveis/cafes.js';

/** Fake mínim amb la interfície del repositori. */
function crearRepositoriFals(cafesInicials = []) {
  let cafes = cafesInicials.map((c) => ({ ...c }));
  let comptador = cafes.length;

  return {
    // Registre de crides, per poder verificar interaccions.
    crides: [],

    buscar(criteris) {
      this.crides.push(['buscar', criteris]);
      return { elements: cafes.map((c) => ({ ...c })), total: cafes.length };
    },
    buscarPerId(id) {
      this.crides.push(['buscarPerId', id]);
      const cafe = cafes.find((c) => c.id === id);
      return cafe ? { ...cafe } : undefined;
    },
    crear(dades) {
      this.crides.push(['crear', dades]);
      const cafe = {
        id: `caf_${String(++comptador).padStart(3, '0')}`,
        ...dades,
        dataCreacio: '2026-03-14T10:00:00Z',
        actiu: true,
      };
      cafes.push(cafe);
      return { ...cafe };
    },
    esborrar(id) {
      this.crides.push(['esborrar', id]);
      const abans = cafes.length;
      cafes = cafes.filter((c) => c.id !== id);
      return cafes.length < abans;
    },
  };
}

const CAFE = {
  id: 'caf_001',
  nom: 'Etiòpia Yirgacheffe',
  origen: 'Etiòpia',
  torrefaccio: 'clar',
  preuCentims: 1450,
  estoc: 120,
  notesTast: ['cítric'],
  descripcio: null,
  actiu: true,
};

describe('serveiCafes', () => {
  let repositori;
  let servei;

  // beforeEach recrea l'estat ABANS DE CADA prova: així cap no
  // depèn del que fes l'anterior i l'ordre és irrellevant.
  beforeEach(() => {
    repositori = crearRepositoriFals([CAFE]);
    servei = crearServeiCafes(repositori);
  });

  it('retorna un cafè existent', () => {
    const cafe = servei.obtenir('caf_001');
    assert.equal(cafe.nom, 'Etiòpia Yirgacheffe');
  });

  it('llança ErrorApi 404 amb el codi del catàleg si no existeix', () => {
    assert.throws(
      () => servei.obtenir('caf_999'),
      (error) => {
        assert.equal(error.esErrorApi, true);
        assert.equal(error.estat, 404);
        assert.equal(error.codi, 'cafe_no_trobat');
        assert.match(error.message, /caf_999/);
        return true;
      }
    );
  });

  it('converteix els euros del client a cèntims en crear', () => {
    servei.crear({
      nom: 'Kenya Nyeri',
      origen: 'Kenya',
      torrefaccio: 'mitja',
      preuEuros: 16.75,
      estoc: 40,
    });

    const [, dades] = repositori.crides.find(([nom]) => nom === 'crear');
    assert.equal(dades.preuCentims, 1675);
    assert.equal(dades.preuEuros, undefined, 'el repositori no ha de veure euros');
  });

  it('retalla els espais del nom abans de desar', () => {
    servei.crear({
      nom: '   Kenya Nyeri   ',
      origen: 'Kenya',
      torrefaccio: 'mitja',
      preuEuros: 16.75,
      estoc: 40,
    });
    const [, dades] = repositori.crides.find(([nom]) => nom === 'crear');
    assert.equal(dades.nom, 'Kenya Nyeri');
  });

  it('llança 404 en esborrar un cafè inexistent', () => {
    assert.throws(() => servei.esborrar('caf_999'), /caf_999/);
  });
});

Aquestes proves són ràpides i precises: no toquen disc, no depenen de l'estat de la base de dades i, quan una falla, assenyalen una sola funció. Fixa't en la tercera: comprova que el repositori no veu mai euros, és a dir, que la frontera d'unitats que vam dissenyar es respecta. Això és exactament el que una prova unitària pot verificar i una d'integració no distingiria.

  1. Supertest: peticions sense port

Supertest pren l'objecte app d'Express i li llança peticions HTTP reals… sobre un servidor efímer que obre i tanca ell mateix en un port aleatori. Per a nosaltres equival a no obrir port: no hi ha EADDRINUSE, no cal arrencar res i diverses suites poden córrer alhora.

import request from 'supertest';
import { app } from '../../src/app.js';

const resposta = await request(app).get('/v1/cafes');
assert.equal(resposta.status, 200);

Aquí es cobra el dividend de la separació de 03-02. Si app.js hagués cridat listen(), importar-lo des d'una prova ocuparia el port 3000 i la segona suite fallaria.

L'API de Supertest, en una taula:

Crida Què fa
request(app).get(ruta) Mètode i ruta
.set('Authorization', valor) Capçalera de petició
.send(objecte) Cos JSON (posa el Content-Type)
.query({ limit: 5 }) Paràmetres de consulta
resposta.status Codi
resposta.body Cos ja analitzat
resposta.headers['location'] Capçalera de resposta (en minúscules)

  1. Dades de prova: base de dades aïllada

Les proves d'integració necessiten base de dades, i hi ha tres regles innegociables: no tocar la de desenvolupament, començar cada suite amb dades conegudes i que l'ordre de les proves no importi.

// proves/ajudes/entorn-prova.js

/**
 * Configura l'entorn ABANS que es carregui res de src/.
 * S'ha d'importar la PRIMERA a cada fitxer de prova.
 */
process.env.NODE_ENV = 'prova';
process.env.RUTA_BASE_DADES = ':memory:';   // base SQLite només a la RAM
process.env.JWT_SECRET = 'secret-nomes-per-a-proves-no-usar-en-produccio';
process.env.JWT_CADUCITAT = '1h';
process.env.PORT = '0';
// proves/ajudes/base-dades-prova.js
import { readFileSync } from 'node:fs';
import { baseDades } from '../../src/config/base-dades.js';

/** Aplica l'esquema complet sobre la base en memòria. */
export function migrar() {
  baseDades.exec(readFileSync('migracions/001-inicial.sql', 'utf8'));
}

/** Deixa la base amb dades conegudes. Es crida abans de cada prova. */
export function sembrar() {
  const netejar = baseDades.transaction(() => {
    baseDades.exec('DELETE FROM linies_comanda; DELETE FROM comandes; DELETE FROM ressenyes;');
    baseDades.exec('DELETE FROM cafes; DELETE FROM clients;');
  });
  netejar();

  const inserirCafe = baseDades.prepare(`
    INSERT INTO cafes (id, nom, origen, torrefaccio, preu_centims, estoc,
                       notes_tast, descripcio, data_creacio, actiu, versio)
    VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, 1, 1)
  `);

  inserirCafe.run('caf_001', 'Etiòpia Yirgacheffe', 'Etiòpia', 'clar', 1450, 120,
    JSON.stringify(['cítric', 'floral', 'te negre']), '2026-01-15T09:00:00Z');
  inserirCafe.run('caf_002', 'Colòmbia Huila', 'Colòmbia', 'mitja', 1290, 80,
    JSON.stringify(['xocolata', 'caramel', 'nou']), '2026-01-20T11:15:00Z');

  const inserirClient = baseDades.prepare(`
    INSERT INTO clients (id, nom, email, hash_contrasenya, rol, data_creacio)
    VALUES (?, ?, ?, ?, ?, '2026-01-10T08:00:00Z')
  `);

  // Hash real de 'contrasenya-de-prova', precalculat per no gastar
  // 80 ms de bcrypt a cada prova. A les d'inici de sessió sí que es fa servir bcrypt.
  const HASH = '$2b$10$K7L1OJ0/9F0iQZ8dJvKZ8eqPX5Y1cW0nR4tV6uH2sA9bC3dE4fG5i';

  inserirClient.run('cli_842', 'Marta Garcia', '[email protected]', HASH, 'client');
  inserirClient.run('cli_001', 'Alba Ruiz', '[email protected]', HASH, 'empleat');
  inserirClient.run('cli_002', 'Dídac Sanz', '[email protected]', HASH, 'administrador');

  baseDades
    .prepare(
      `INSERT INTO comandes (id, client_id, estat, total_centims, data_creacio, versio)
       VALUES ('com_5001', 'cli_842', 'pendent_pagament', 2900, '2026-03-14T10:30:00Z', 1)`
    )
    .run();

  baseDades
    .prepare(
      `INSERT INTO linies_comanda (comanda_id, cafe_id, nom_cafe, quantitat, preu_centims)
       VALUES ('com_5001', 'caf_001', 'Etiòpia Yirgacheffe', 2, 1450)`
    )
    .run();
}

I l'ajuda per autenticar-se sense passar per l'inici de sessió a cada prova:

// proves/ajudes/token.js
import { emetreToken } from '../../src/serveis/autenticacio.js';

/** Genera un Bearer vàlid per a un rol. Fa servir el MATEIX emissor que l'app. */
export function tokenDe(rol = 'client', id = 'cli_842') {
  return `Bearer ${emetreToken({ id, rol })}`;
}

Generar el token amb la funció real de l'aplicació, i no amb un JWT escrit a mà a la prova, és deliberat: si demà canvia l'emissor, l'algorisme o els claims, les proves continuen sent vàlides. Un token fabricat a mà es converteix en una còpia del codi que cal mantenir sincronitzada.

Sobre :memory:: cada connexió SQLite en memòria és una base independent que desapareix en tancar el procés. És l'opció més ràpida i la que garanteix l'aïllament total entre execucions. L'alternativa és un fitxer temporal (dades/prova-${process.pid}.db), útil quan vols inspeccionar l'estat després d'una fallada.

Un detall d'ESM que costa una tarda. src/config/base-dades.js obre la connexió en importar-se, llegint entorn.rutaBaseDades. Per això entorn-prova.js s'ha d'avaluar abans. Els mòduls ESM s'avaluen en l'ordre en què apareixen els seus import, així que posar import './ajudes/entorn-prova.js'; a la primera línia funciona… fins que un formatador reordena els imports alfabèticament. La versió a prova de bales és carregar l'app amb importació dinàmica:

import './ajudes/entorn-prova.js';
const { app } = await import('../../src/app.js');   // s'avalua aquí, no abans

  1. Proves d'integració de la col·lecció

// proves/integracio/cafes.test.js
import './../ajudes/entorn-prova.js';
import { describe, it, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { migrar, sembrar } from '../ajudes/base-dades-prova.js';
import { tokenDe } from '../ajudes/token.js';

const { app } = await import('../../src/app.js');

before(() => migrar());        // l'esquema, un cop
beforeEach(() => sembrar());   // les dades, abans de cada prova

describe('GET /v1/cafes', () => {
  it("retorna 200 amb l'embolcall del contracte", async () => {
    const r = await request(app).get('/v1/cafes');

    assert.equal(r.status, 200);
    assert.match(r.headers['content-type'], /application\/json/);
    assert.ok(Array.isArray(r.body.dades), 'dades ha de ser un array');
    assert.equal(typeof r.body.total, 'number');
    assert.equal(r.body.total, 2);
  });

  it('cada element té la forma exacta del contracte', async () => {
    const r = await request(app).get('/v1/cafes');
    const cafe = r.body.dades.find((c) => c.id === 'caf_001');

    assert.equal(cafe.nom, 'Etiòpia Yirgacheffe');
    assert.equal(cafe.preuEuros, 14.5);        // euros, no cèntims
    assert.equal(cafe.preuCentims, undefined);
    assert.equal(cafe.descripcio, null);       // present encara que sigui null
    assert.deepEqual(cafe.notesTast, ['cítric', 'floral', 'te negre']);
    assert.equal(cafe._links.self.href, '/v1/cafes/caf_001');
  });

  it('filtra per origen', async () => {
    const r = await request(app).get('/v1/cafes').query({ origen: 'Colòmbia' });
    assert.equal(r.body.total, 1);
    assert.equal(r.body.dades[0].id, 'caf_002');
  });

  it('total és el nombre de coincidències, no el de la pàgina', async () => {
    const r = await request(app).get('/v1/cafes').query({ limit: 1 });
    assert.equal(r.body.dades.length, 1, 'la pàgina porta un element');
    assert.equal(r.body.total, 2, 'però hi ha dues coincidències');
  });

  it('retorna la capçalera Link conservant els filtres', async () => {
    const r = await request(app).get('/v1/cafes').query({ limit: 1, ordenar: 'nom' });

    assert.ok(r.headers.link, "hi ha d'haver capçalera Link");
    assert.match(r.headers.link, /rel="next"/);
    assert.match(r.headers.link, /ordenar=nom/, "el filtre ha de sobreviure a l'enllaç");
  });

  it('rebutja un limit excessiu amb 400 i NO el retalla', async () => {
    const r = await request(app).get('/v1/cafes').query({ limit: 5000 });

    assert.equal(r.status, 400);
    assert.equal(r.body.error.codi, 'parametre_invalid');
    assert.equal(r.body.error.detalls[0].camp, 'limit');
  });

  it('rebutja un paràmetre desconegut (entrada estricta)', async () => {
    const r = await request(app).get('/v1/cafes').query({ limite: 20 });
    assert.equal(r.status, 400);
    assert.equal(r.body.error.codi, 'parametre_invalid');
  });
});

describe('GET /v1/cafes/:id', () => {
  it("retorna l'element SENSE embolcall", async () => {
    const r = await request(app).get('/v1/cafes/caf_001');
    assert.equal(r.status, 200);
    assert.equal(r.body.id, 'caf_001');
    assert.equal(r.body.dades, undefined, 'un element no porta embolcall');
  });

  it('retorna 404 amb el codi del catàleg', async () => {
    const r = await request(app).get('/v1/cafes/caf_999');

    assert.equal(r.status, 404);
    assert.equal(r.body.error.codi, 'cafe_no_trobat');
    assert.deepEqual(r.body.error.detalls, [], 'detalls sempre present');
    assert.equal(r.body.error.tracaId, undefined, 'tracaId només als 5xx');
  });
});

describe('DELETE /v1/cafes sobre la col·lecció', () => {
  it('retorna 405 amb la capçalera Allow', async () => {
    const r = await request(app).delete('/v1/cafes').set('Authorization', tokenDe('administrador'));

    assert.equal(r.status, 405);
    assert.equal(r.body.error.codi, 'metode_no_permes');
    assert.match(r.headers.allow, /GET/);
    assert.match(r.headers.allow, /POST/);
  });
});

Cadascuna d'aquestes proves correspon a una decisió concreta del mòdul 2. La de total protegeix contra l'error més freqüent a les col·leccions paginades; la de Link contra perdre els filtres; la de limit=5000 contra el retall silenciós; la de detalls: [] contra que un client hagi de comprovar si el camp existeix.

  1. Proves de creació, capçaleres i validació

// proves/integracio/cafes.test.js  (continuació)

describe('POST /v1/cafes', () => {
  const CAFE_VALID = {
    nom: 'Kenya Nyeri',
    origen: 'Kenya',
    torrefaccio: 'mitja',
    preuEuros: 16.75,
    estoc: 40,
    notesTast: ['grosella', 'tomàquet'],
  };

  it('crea amb 201, capçalera Location i representació completa', async () => {
    const r = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('empleat', 'cli_001'))
      .send(CAFE_VALID);

    assert.equal(r.status, 201);
    assert.ok(r.headers.location, 'Location és obligatòria en tota creació');
    assert.match(r.headers.location, /^\/v1\/cafes\/caf_\d{3}$/);
    assert.equal(r.body.preuEuros, 16.75);
    assert.equal(r.body.descripcio, null);
    assert.equal(r.headers.location, r.body._links.self.href, 'Location i self coincideixen');
  });

  it('el recurs creat es pot recuperar a la URI de Location', async () => {
    const creacio = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('empleat', 'cli_001'))
      .send(CAFE_VALID);

    const lectura = await request(app).get(creacio.headers.location);

    assert.equal(lectura.status, 200);
    assert.equal(lectura.body.nom, 'Kenya Nyeri');
    assert.equal(lectura.body.preuEuros, 16.75, "el preu sobreviu a l'anada i tornada");
  });

  it('retorna TOTES les fallades de validació alhora', async () => {
    const r = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('empleat', 'cli_001'))
      .send({ nom: 'K', origen: 'Kenya', torrefaccio: 'torrat', preuEuros: '16.75', estoc: -3 });

    assert.equal(r.status, 400);
    assert.equal(r.body.error.codi, 'dades_invalides');
    assert.equal(r.body.error.detalls.length, 4, 'quatre fallades, quatre detalls');

    const camps = r.body.error.detalls.map((d) => d.camp).sort();
    assert.deepEqual(camps, ['estoc', 'nom', 'preuEuros', 'torrefaccio']);

    // Cada detall té els tres camps del contracte.
    for (const detall of r.body.error.detalls) {
      assert.ok(detall.camp);
      assert.ok(detall.codi);
      assert.ok(detall.missatge);
    }
  });

  it('rebutja camps desconeguts (entrada estricta)', async () => {
    const r = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('empleat', 'cli_001'))
      .send({ ...CAFE_VALID, color: 'vermell' });

    assert.equal(r.status, 400);
    assert.equal(r.body.error.detalls[0].codi, 'camp_desconegut');
  });

  it('rebutja JSON mal format amb 400 del contracte, no amb HTML', async () => {
    const r = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('empleat', 'cli_001'))
      .set('Content-Type', 'application/json')
      .send('{"nom": "Kenya,}');

    assert.equal(r.status, 400);
    assert.equal(r.body.error.codi, 'dades_invalides');
  });
});

describe('PATCH /v1/cafes/:id', () => {
  it("modifica només el que s'ha enviat", async () => {
    const r = await request(app)
      .patch('/v1/cafes/caf_001')
      .set('Authorization', tokenDe('empleat', 'cli_001'))
      .set('Content-Type', 'application/merge-patch+json')
      .send({ estoc: 95 });

    assert.equal(r.status, 200);
    assert.equal(r.body.estoc, 95);
    assert.equal(r.body.preuEuros, 14.5, 'el preu no ha de canviar');
    assert.equal(r.body.nom, 'Etiòpia Yirgacheffe');
  });

  it('rebutja JSON Patch amb 415 i Accept-Patch', async () => {
    const r = await request(app)
      .patch('/v1/cafes/caf_001')
      .set('Authorization', tokenDe('empleat', 'cli_001'))
      .set('Content-Type', 'application/json-patch+json')
      .send([{ op: 'replace', path: '/estoc', value: 95 }]);

    assert.equal(r.status, 415);
    assert.equal(r.body.error.codi, 'format_no_suportat');
    assert.equal(r.headers['accept-patch'], 'application/merge-patch+json');
  });
});

describe('DELETE /v1/cafes/:id', () => {
  it("retorna 204 sense cos i el cafè deixa d'existir", async () => {
    const esborrat = await request(app)
      .delete('/v1/cafes/caf_002')
      .set('Authorization', tokenDe('administrador', 'cli_002'));

    assert.equal(esborrat.status, 204);
    assert.deepEqual(esborrat.body, {}, '204 no porta cos');

    const lectura = await request(app).get('/v1/cafes/caf_002');
    assert.equal(lectura.status, 404);
  });
});

La segona prova de POST és la més valuosa de tot el fitxer: crea i després llegeix a la URI que va retornar Location. Verifica d'una sola vegada que la capçalera és correcta, que el recurs s'ha persistit, que els cèntims sobreviuen a l'anada i tornada i que la representació és la mateixa en creació i en lectura. Una prova, quatre promeses del contracte.

  1. Proves d'autenticació i autorització

// proves/integracio/autenticacio.test.js
import './../ajudes/entorn-prova.js';
import { describe, it, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { migrar, sembrar } from '../ajudes/base-dades-prova.js';
import { tokenDe } from '../ajudes/token.js';

const { app } = await import('../../src/app.js');

before(() => migrar());
beforeEach(() => sembrar());

describe('autenticació', () => {
  it('401 sense token, amb WWW-Authenticate', async () => {
    const r = await request(app).get('/v1/comandes');

    assert.equal(r.status, 401);
    assert.equal(r.body.error.codi, 'no_autenticat');
    assert.match(r.headers['www-authenticate'], /^Bearer/);
  });

  it('401 amb token manipulat', async () => {
    const valid = tokenDe('client');
    const trencat = `${valid.slice(0, -4)}XXXX`;   // es canvia la signatura

    const r = await request(app).get('/v1/comandes').set('Authorization', trencat);

    assert.equal(r.status, 401);
    assert.equal(r.body.error.codi, 'no_autenticat');
  });

  it("401 token_caducat distingeix el cas d'un token vençut", async () => {
    // Se signa un token que va caducar fa una hora.
    const caducat = jwt.sign({ rol: 'client' }, process.env.JWT_SECRET, {
      subject: 'cli_842',
      issuer: 'api.botigaaroma.example',
      expiresIn: '-1h',
    });

    const r = await request(app).get('/v1/comandes').set('Authorization', `Bearer ${caducat}`);

    assert.equal(r.status, 401);
    assert.equal(r.body.error.codi, 'token_caducat', 'el client ha de poder renovar');
  });

  it('200 amb un token vàlid', async () => {
    const r = await request(app).get('/v1/comandes').set('Authorization', tokenDe('client'));
    assert.equal(r.status, 200);
  });
});

describe('autorització per rol', () => {
  it('403 en crear un cafè amb rol client', async () => {
    const r = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('client'))
      .send({ nom: 'Kenya Nyeri', origen: 'Kenya', torrefaccio: 'mitja', preuEuros: 16.75, estoc: 40 });

    assert.equal(r.status, 403, 'permís, no identitat: 403 i no 401');
    assert.equal(r.body.error.codi, 'permisos_insuficients');
  });

  it('201 en crear un cafè amb rol empleat', async () => {
    const r = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('empleat', 'cli_001'))
      .send({ nom: 'Kenya Nyeri', origen: 'Kenya', torrefaccio: 'mitja', preuEuros: 16.75, estoc: 40 });

    assert.equal(r.status, 201);
  });

  it('403 en esborrar un cafè amb rol empleat (només administrador)', async () => {
    const r = await request(app)
      .delete('/v1/cafes/caf_001')
      .set('Authorization', tokenDe('empleat', 'cli_001'));

    assert.equal(r.status, 403);
  });
});

describe('autorització a nivell de recurs', () => {
  it("un client NO veu la comanda d'un altre, i rep 404 (no 403)", async () => {
    const r = await request(app)
      .get('/v1/comandes/com_5001')                     // és de cli_842
      .set('Authorization', tokenDe('client', 'cli_999'));

    assert.equal(r.status, 404, 'no confirmem que la comanda existeixi');
    assert.equal(r.body.error.codi, 'comanda_no_trobada');
  });

  it('el propietari sí que la veu', async () => {
    const r = await request(app)
      .get('/v1/comandes/com_5001')
      .set('Authorization', tokenDe('client', 'cli_842'));

    assert.equal(r.status, 200);
    assert.equal(r.body.totalEuros, 29);
  });

  it("un client no pot llistar les comandes d'un altre amb ?clientId", async () => {
    const r = await request(app)
      .get('/v1/comandes')
      .query({ clientId: 'cli_842' })
      .set('Authorization', tokenDe('client', 'cli_999'));

    assert.equal(r.status, 200);
    assert.equal(r.body.total, 0, "el clientId de la query s'ignora i es força el del token");
  });

  it('un empleat sí que veu les comandes de qualsevol', async () => {
    const r = await request(app)
      .get('/v1/comandes')
      .query({ clientId: 'cli_842' })
      .set('Authorization', tokenDe('empleat', 'cli_001'));

    assert.equal(r.body.total, 1);
  });
});

Aquella penúltima prova —el ?clientId d'un altre client— és la més important del fitxer. És l'única que detecta l'IDOR de 03-06, i és exactament el tipus de fallada que les proves funcionals no veuen: la ruta respon 200, el token és vàlid, el rol és correcte i tot i així s'estarien filtrant dades alienes. La matriu de permisos de 03-06 hauria de tenir una prova per cel·la.

  1. Cobertura i què mesura realment

node --test --experimental-test-coverage proves/
ℹ start of coverage report
ℹ ------------------------------------------------------------
ℹ file                          | line % | branch % | funcs %
ℹ ------------------------------------------------------------
ℹ src/serveis/mapejadors.js     |  98.20 |    91.60 |  100.00
ℹ src/serveis/cafes.js          |  92.30 |    83.30 |  100.00
ℹ src/middleware/validacio.js   |  88.90 |    75.00 |  100.00
ℹ src/middleware/errors.js      |  71.40 |    58.30 |   80.00
ℹ src/repositoris/cafes-sqlite  |  85.00 |    70.00 |   90.00
ℹ ------------------------------------------------------------
ℹ all files                     |  86.10 |    74.20 |   92.30
Mètrica Què mesura
line % Línies executades
branch % Branques dels if/switch recorregudes
funcs % Funcions invocades almenys un cop

Per a què serveix la cobertura i per a què no. Serveix per trobar el que no s'ha provat: a l'informe de dalt, middleware/errors.js al 58 % de branques indica que hi ha traduccions d'error —la de SQLite, segurament— que cap prova no exercita. Aquell forat és informació accionable.

El que no és és una mesura de qualitat. Aquesta prova dona un 100 % de cobertura i no comprova res:

it('no falla', () => {
  cafeARepresentacio(cafeIntern);   // ni una sola asseveració
});

Perseguir el 100 % porta a escriure proves d'aquest tipus, a provar getters trivials i a afegir asseveracions sobre detalls interns que converteixen qualsevol refactorització en un dia d'arreglar proves. Un objectiu raonable és 80-90 % a la lògica de negoci, amb la mirada posada a les branques més que a les línies, i amb el criteri que tot camí d'error tingui almenys una prova. Els camins d'error són precisament els que ningú no executa a mà i els que més fallen quan arriba el moment.

  1. Proves de contracte: la idea

Les nostres proves verifiquen el que nosaltres creiem que diu el contracte. Però el contracte real és a openapi.yaml (02-08), i res no garanteix avui que tots dos coincideixin: si algú afegeix un camp a la resposta sense tocar el YAML, totes les nostres proves continuen en verd i la documentació passa a mentir.

Una prova de contracte tanca aquell forat validant la resposta real contra l'esquema publicat:

// Idea, no implementació: es desenvolupa a 05-04.
import { validarContraEsquema } from 'alguna-eina-openapi';

it("la resposta compleix l'esquema publicat", async () => {
  const r = await request(app).get('/v1/cafes/caf_001');
  const resultat = validarContraEsquema(r.body, 'openapi.yaml', '#/components/schemas/Cafe');
  assert.equal(resultat.valid, true, JSON.stringify(resultat.errors));
});

Amb això, el YAML deixa de ser documentació i passa a ser una prova: si la implementació i l'especificació divergeixen, el build falla. És l'única defensa real contra la deriva que vam descriure a 02-08. Les eines concretes —validadors d'OpenAPI, Pact per a contractes entre consumidor i proveïdor, mocks generats des de l'especificació— són la lliçó 05-04.

  1. Exploració manual i Postman

Les proves automàtiques comproven el que se't va acudir comprovar. L'exploració manual serveix per a la resta: què passa si envio un array on espera un objecte? I si l'Accept-Language és zh? I si envio limit=0?

# Guió de fum després de qualsevol canvi important
API=http://localhost:3000/v1

curl -s "$API/cafes" | jq '.total'
curl -s -o /dev/null -w "%{http_code}\n" "$API/cafes/caf_999"    # espera 404
curl -s -o /dev/null -w "%{http_code}\n" "$API/cafes?limit=5000" # espera 400
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "$API/cafes"  # espera 405
curl -si "$API/cafes?limit=1" | grep -i "^link"                  # espera Link

-w "%{http_code}\n" imprimeix només el codi d'estat, que és el que interessa en un guió de fum.

Quan l'exploració manual creix —col·leccions organitzades, entorns amb variables, encadenar el token de l'inici de sessió a les peticions següents, executar-ho tot de cop— l'eina adequada és Postman, i li dediquem sencera la lliçó 05-01. L'important és l'ordre: el que descobreixis explorant, converteix-ho en una prova automàtica. Un bug trobat a mà que no acaba en una prova tornarà.

  1. Llista de verificació del contracte de la Botiga Aroma

Abans de donar per bona qualsevol versió de l'API:

Col·leccions

  • [ ] El GET de col·lecció retorna {"dades": [...], "total": n}, mai un array nu.
  • [ ] total és el nombre de coincidències del filtre, no el de la pàgina.
  • [ ] Una col·lecció buida és 200 amb dades: [], mai 404.
  • [ ] Els filtres es combinen amb I lògic i un paràmetre desconegut dona 400.
  • [ ] limit per defecte 20, màxim 100, i per sobre 400, sense retallar.
  • [ ] Tota ordenació acaba amb desempat per id.
  • [ ] La capçalera Link és present quan hi ha més d'una pàgina i conserva els filtres.

Elements

  • [ ] El GET d'element retorna l'objecte nu, sense embolcall.
  • [ ] Els camps sense valor són presents amb null; els arrays buits són [].
  • [ ] Els imports surten en euros; els cèntims no creuen mai la frontera.
  • [ ] Els enumerats van en snake_case i no es tradueixen.
  • [ ] Les dates són ISO-8601 en UTC amb Z.
  • [ ] _links.self hi és sempre; les accions només a les comandes i segons el seu estat.

Escriptura

  • [ ] El POST retorna 201 amb Location, i aquella URI es pot recuperar amb GET.
  • [ ] El PUT reemplaça el recurs sencer; el PATCH només el que s'ha enviat.
  • [ ] El PATCH amb application/json-patch+json dona 415 amb Accept-Patch.
  • [ ] El DELETE retorna 204 sense cos.
  • [ ] Els camps que fixa el servidor (id, estat, preus de línia) es rebutgen a l'entrada.

Errors

  • [ ] Tots tenen la forma {"error": {"codi", "missatge", "detalls"}}.
  • [ ] detalls hi és sempre, encara que sigui [].
  • [ ] La validació retorna totes les fallades alhora.
  • [ ] tracaId només als 5xx.
  • [ ] Cap error no filtra pila, SQL, versions ni camins del sistema.
  • [ ] Mètode no permès: 405 amb Allow.
  • [ ] Ruta inexistent: 404 en JSON, mai la pàgina HTML d'Express.

Seguretat

  • [ ] 401 amb WWW-Authenticate; token_caducat distingit de no_autenticat.
  • [ ] 403 quan falta permís, 401 quan falta identitat.
  • [ ] Recursos aliens: 404, no 403.
  • [ ] L'identificador del propietari surt del token, mai de l'entrada.
  • [ ] Cap resposta no inclou contrasenyes ni hashos.
  • [ ] Hi ha una prova per a cada cel·la de la matriu de permisos.

Procés

  • [ ] npm test passa en verd.
  • [ ] openapi.yaml reflecteix els endpoints, camps i errors reals.
  • [ ] Tota ruta nova té la seva prova de camí feliç i d'error.

  1. Balanç del mòdul 3

Vuit lliçons, un sol projecte. Això és el que hi ha construït:

Lliçó El que va aportar
03-01 Node 20, ESM, dependències, estructura per capes, configuració validada
03-02 Express, middleware, app/servidor separats, Router a /v1, 404 del contracte
03-03 Capes rutes/controladors/serveis, mapejador, CRUD, filtres, paginació, Link
03-04 Esquemes Zod, entrada estricta, validar(esquema, origen), totes les fallades alhora
03-05 SQLite darrere del repositori, migracions, sentències preparades, transaccions, cursor
03-06 bcrypt, JWT, autenticar, exigirRol, propietat a nivell de recurs, matriu de permisos
03-07 ErrorApi, fàbriques, middleware d'errors únic, asincron(), tracaId
03-08 Unitàries amb dobles, integració amb Supertest, cobertura, llista de verificació

I això és el que no té encara, que és exactament el programa del mòdul 4: no hi ha CORS, així que un navegador en un altre domini no la pot cridar; no hi ha límit de peticions, així que un bucle mal escrit la satura; no hi ha capçaleres de seguretat ni protecció davant de les amenaces de l'OWASP; no hi ha memòria cau HTTP, així que cada petició ho recalcula tot; no hi ha delegació d'accés per a tercers; i els seus logs són console.log sense nivells ni agregació, impossibles de consultar en producció.

Errors Comuns i Consells

1. Proves que depenen de l'ordre. Si la prova B necessita que l'A hagi creat un cafè, qualsevol reordenació les trenca. beforeEach amb sembra completa ho resol.

2. Compartir la base de dades de desenvolupament. Les proves la buidaran. Base en memòria o fitxer temporal, sempre.

3. Provar Express, Zod o SQLite. No és el teu codi. Prova les teves decisions.

4. Asseveracions sobre l'objecte complet. assert.deepEqual(cos, {...}) amb vint camps falla cada vegada que se n'afegeix un, encara que sigui un canvi additiu perfectament vàlid. Afirma sobre el que importa.

5. Oblidar await en una prova async. La prova passa sense haver comprovat res, perquè acaba abans que la petició.

6. Perseguir el 100 % de cobertura. Produeix proves sense asseveracions i proves fràgils. Mira les branques de la lògica de negoci.

7. Llegir capçaleres amb majúscules. A la resposta de Supertest són r.headers['content-type'], en minúscules.

8. No provar els camins d'error. Són els que ningú no executa a mà i els que més es trenquen.

9. Fabricar tokens JWT a mà a la prova. Duplica el codi d'emissió. Fes servir la funció real de l'aplicació.

Consell: quan aparegui un bug en producció, escriu primer la prova que el reprodueix, comprova que falla, i només llavors arregla'l. Així saps que la prova serveix i que aquell bug concret no torna mai.

Exercicis

Exercici 1

Escriu les proves d'integració de POST /v1/comandes que cobreixin: creació correcta amb 201 i Location, 409 estoc_insuficient en demanar més unitats de les disponibles, 400 clau_idempotencia_requerida sense la capçalera, i —la important— que després d'un 409 l'estoc no s'hagi descomptat. Explica per què aquesta darrera prova és la que verifica la transacció de 03-05.

Exercici 2

Aquesta prova passa sempre, fins i tot amb l'aplicació trencada. Troba'n els tres motius i escriu-la correctament.

it('crea un cafè', () => {
  const resposta = request(app).post('/v1/cafes').send({ nom: 'Kenya' });
  assert.ok(resposta);
});

Exercici 3

L'informe de cobertura mostra src/middleware/errors.js al 58 % de branques. Identifica quins camins concrets no estan coberts segons el que hem escrit en aquesta lliçó, decideix quins mereixen prova i quins no, i escriu la prova del més important: comprovar que un error inesperat produeix 500 error_intern amb tracaId i sense filtrar el missatge intern.

Solucions

Solució 1

// proves/integracio/comandes.test.js
import './../ajudes/entorn-prova.js';
import { describe, it, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { migrar, sembrar } from '../ajudes/base-dades-prova.js';
import { tokenDe } from '../ajudes/token.js';

const { app } = await import('../../src/app.js');

before(() => migrar());
beforeEach(() => sembrar());

describe('POST /v1/comandes', () => {
  const capcaleres = (clau = 'clau-unica-1') => ({
    Authorization: tokenDe('client', 'cli_842'),
    'Idempotency-Key': clau,
  });

  it('crea la comanda amb 201 i Location', async () => {
    const r = await request(app)
      .post('/v1/comandes')
      .set(capcaleres())
      .send({ linies: [{ cafeId: 'caf_001', quantitat: 2 }] });

    assert.equal(r.status, 201);
    assert.match(r.headers.location, /^\/v1\/comandes\/com_\d+$/);
    assert.equal(r.body.estat, 'pendent_pagament');
    assert.equal(r.body.totalEuros, 29);          // 2 × 14,50 €
    assert.equal(r.body.linies[0].preuEuros, 14.5, 'preu congelat');
    assert.ok(r.body._links.pagar, 'una comanda pendent ofereix pagar');
  });

  it("descompta l'estoc del cafè", async () => {
    await request(app)
      .post('/v1/comandes')
      .set(capcaleres())
      .send({ linies: [{ cafeId: 'caf_001', quantitat: 2 }] });

    const cafe = await request(app).get('/v1/cafes/caf_001');
    assert.equal(cafe.body.estoc, 118, '120 − 2');
  });

  it('409 estoc_insuficient en demanar més del disponible', async () => {
    const r = await request(app)
      .post('/v1/comandes')
      .set(capcaleres())
      .send({ linies: [{ cafeId: 'caf_001', quantitat: 99 }] });

    assert.equal(r.status, 409);
    assert.equal(r.body.error.codi, 'estoc_insuficient');
  });

  it('400 clau_idempotencia_requerida sense la capçalera', async () => {
    const r = await request(app)
      .post('/v1/comandes')
      .set('Authorization', tokenDe('client', 'cli_842'))
      .send({ linies: [{ cafeId: 'caf_001', quantitat: 1 }] });

    assert.equal(r.status, 400);
    assert.equal(r.body.error.codi, 'clau_idempotencia_requerida');
  });

  it("després d'un 409, RES no s'ha modificat (atomicitat)", async () => {
    // Primera línia vàlida, segona sense estoc: ha de fallar sencera.
    const r = await request(app)
      .post('/v1/comandes')
      .set(capcaleres())
      .send({
        linies: [
          { cafeId: 'caf_001', quantitat: 2 },   // n'hi ha 120: hi cabria
          { cafeId: 'caf_002', quantitat: 99 },  // n'hi ha 80: no hi cap
        ],
      });

    assert.equal(r.status, 409);

    // L'estoc del PRIMER cafè ha de continuar intacte.
    const cafe1 = await request(app).get('/v1/cafes/caf_001');
    assert.equal(cafe1.body.estoc, 120, 'el ROLLBACK ha de desfer el primer descompte');

    // I no hi ha d'haver quedat cap comanda a mitges.
    const comandes = await request(app)
      .get('/v1/comandes')
      .set('Authorization', tokenDe('empleat', 'cli_001'));
    assert.equal(comandes.body.total, 1, 'només la comanda sembrada');
  });
});

Per què la darrera prova verifica la transacció: és l'única que exercita el camí de fallada a mitges d'una operació de diversos passos. Sense BEGIN/ROLLBACK, la primera línia hauria descomptat 2 unitats de caf_001 i la fila de la comanda ja estaria inserida quan la segona línia falla; el resultat seria estoc desaparegut i una comanda incompleta a la base. Amb la transacció, l'excepció provoca ROLLBACK i l'estat torna exactament a com estava. És una fallada que no es veu mai al camí feliç, que no apareix en desenvolupament amb dades de sobres, i que en producció produeix descompensacions d'inventari impossibles d'explicar. Per això mereix una prova explícita.

Solució 2

# Motiu Efecte
1 Falta await request(app).post(...) retorna un objecte thenable que només executa la petició en esperar-lo. La prova acaba sense que la petició arribi a enviar-se
2 La prova no és async Sense async no es pot fer servir await, i el runner dona la prova per acabada immediatament
3 assert.ok(resposta) no comprova res Un objecte sempre és cert. Passaria igual amb un 500, amb un 400 o amb l'aplicació caiguda. A més, el cos enviat és invàlid (falten origen, torrefaccio, preuEuros, estoc) i no hi ha token, així que la resposta real seria 401

Versió correcta:

it('crea un cafè amb dades vàlides', async () => {
  const resposta = await request(app)
    .post('/v1/cafes')
    .set('Authorization', tokenDe('empleat', 'cli_001'))
    .send({
      nom: 'Kenya Nyeri',
      origen: 'Kenya',
      torrefaccio: 'mitja',
      preuEuros: 16.75,
      estoc: 40,
    });

  assert.equal(resposta.status, 201);
  assert.ok(resposta.headers.location);
  assert.equal(resposta.body.nom, 'Kenya Nyeri');
  assert.equal(resposta.body.preuEuros, 16.75);
});

La lliçó general: una prova sense asseveracions concretes és pitjor que cap prova, perquè dona una falsa sensació de seguretat i a més compta per a la cobertura. Una regla útil: si en trencar deliberadament el codi la prova continua en verd, la prova no serveix.

Solució 3

Camins d'errors.js que probablement no estan coberts:

Camí Mereix prova?
traduirSqlite amb SQLITE_CONSTRAINT_UNIQUE : és una cursa real (dos registres amb el mateix email)
traduirSqlite amb SQLITE_CONSTRAINT_FOREIGNKEY Sí: comanda amb client inexistent
traduirSqlite amb SQLITE_BUSY No: difícil de provocar i la seva lògica és trivial
Error genèric → 500 error_intern Sí, la més important
Branca Accept-Patch al 415 Ja coberta per la prova de PATCH
Branca Allow al 405 Ja coberta per la prova de DELETE sobre la col·lecció
Camp depuracio fora de producció Sí: comprovar que no apareix amb NODE_ENV=produccio

Prova del cas més important:

// proves/integracio/errors.test.js
import './../ajudes/entorn-prova.js';
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import express from 'express';
import { gestorErrors } from '../../src/middleware/errors.js';
import { assignarTracaId } from '../../src/middleware/traca.js';
import { asincron } from '../../src/middleware/asincron.js';

/**
 * Es munta una aplicació mínima amb una ruta que peta a propòsit.
 * Provocar un bug real a l'aplicació de debò seria fràgil; aquí es
 * prova el MIDDLEWARE, que és el que volem verificar.
 */
function crearAppQueFalla() {
  const app = express();
  app.use(assignarTracaId);
  app.get(
    '/peta',
    asincron(async () => {
      throw new TypeError("Cannot read properties of null (reading 'map')");
    })
  );
  app.use(gestorErrors);
  return app;
}

describe('errors inesperats', () => {
  const app = crearAppQueFalla();

  it('retorna 500 error_intern amb tracaId i sense filtrar res', async () => {
    const r = await request(app).get('/peta');

    assert.equal(r.status, 500);
    assert.equal(r.body.error.codi, 'error_intern');
    assert.equal(r.body.error.missatge, "S'ha produït un error inesperat.");
    assert.deepEqual(r.body.error.detalls, []);

    // tracaId present i correlacionat amb la capçalera.
    assert.match(r.body.error.tracaId, /^trz_[0-9a-f]{8}$/);
    assert.equal(r.headers['aroma-traca-id'], r.body.error.tracaId);

    // I l'essencial: RES de l'interior no s'ha filtrat.
    const text = JSON.stringify(r.body);
    assert.ok(!text.includes('TypeError'), "no hi ha d'aparèixer el tipus d'error");
    assert.ok(!text.includes('Cannot read properties'), "no hi ha d'aparèixer el missatge intern");
    assert.ok(!text.includes('.js:'), "no hi ha d'aparèixer cap traça de pila");
  });

  it("l'embolcall asincron captura el rebuig: la petició SÍ que respon", async () => {
    // Sense asincron(), aquesta petició es penjaria i la prova donaria timeout.
    const r = await request(app).get('/peta');
    assert.ok(r.status, "hi ha d'haver resposta, no un penjament");
  });
});

Les tres asseveracions negatives del final són el nucli: comproven que el consumidor no veu el tipus d'excepció, ni el missatge intern, ni cap camí de fitxer. És una prova de seguretat, no de funcionalitat, i és de les poques que s'escriuen en negatiu. I la segona prova verifica l'embolcall asincron(): si algú el tragués, aquesta prova fallaria per temps d'espera esgotat en comptes de passar, que és justament el senyal que volem.

Conclusió

El mòdul es tanca amb l'única garantia que val alguna cosa: la implementació es comprova sola. Tens proves unitàries que verifiquen que 16,75 € són 1675 cèntims i tornen a ser 16,75 €, que el mapejador no filtra actiu ni versio, i que una comanda enviat ofereix retornar i ja no pagar; proves de servei amb un repositori fals injectat, possibles perquè a 03-03 vam separar les capes i a 03-05 vam conservar el magatzem en memòria; i proves d'integració amb Supertest sobre l'objecte app, possibles perquè a 03-02 no vam cridar listen() allà, que recorren codis d'estat, capçaleres Location, Link, Allow i Accept-Patch, la forma exacta del cos, els detalls complets de la validació i la matriu de permisos, inclosa la prova del ?clientId aliè que és l'única capaç de detectar un IDOR. Saps a més què mesura la cobertura i què no, que les proves de contracte contra openapi.yaml són el graó següent, i tens una llista de verificació aplicable a qualsevol API REST, no només a aquesta.

I amb això queda acabat el mòdul 3. Has partit del contracte sobre paper del mòdul 2 i has construït una API completa: un entorn reproduïble amb la configuració a l'entorn i validada en arrencar; un servidor Express amb la seva cadena de middleware ordenada i el versionat materialitzat a /v1; tres capes amb fronteres reals, un mapejador que concentra les decisions de representació i el cicle complet d'escriptura amb els seus codis i capçaleres; validació declarativa que rebutja l'entrada estricta i retorna totes les fallades alhora; persistència en SQL amb migracions, sentències preparades, transaccions atòmiques, concurrència optimista i paginació per cursor; autenticació amb JWT i autorització per rol i per propietat; un únic middleware d'errors que no filtra mai l'interior del sistema; i una suite de proves que protegeix tot l'anterior.

El que tens és una API correcta. El que encara no és, és una API llesta per a producció. Al mòdul 4, Bones Pràctiques i Seguretat, s'endureix: repassarem les bones pràctiques de disseny que separen una API decent d'una d'excel·lent (04-01); cobrirem les amenaces reals i les seves defenses, de l'OWASP API Security Top 10 a les capçaleres de seguretat (04-02); implementarem OAuth 2.0 i OpenID Connect perquè tercers hi accedeixin sense conèixer les contrasenyes (04-03); posarem límits de peticions amb 429 i Retry-After perquè ningú no la pugui saturar (04-04); configurarem CORS perquè la SPA la pugui cridar des d'un altre domini sense obrir la porta a qualsevol (04-05); afegirem memòria cau HTTP amb ETag, Cache-Control i peticions condicionals, que és on conflicte_versio es retrobarà amb If-Match i el 412 (04-06); i substituirem els console.log per observabilitat de debò —logs estructurats, mètriques i traces distribuïdes— aprofitant el tracaId que ja emetem (04-07).

Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful

Mòdul 1: Introducció a les APIs RESTful

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats