import { generateKeyPairSync } from 'crypto'; import request from 'supertest'; import nock from 'nock'; import jwt from 'jsonwebtoken'; process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test'; process.env.KEYCLOAK_REALM = 'scouthub'; process.env.KEYCLOAK_MAGAZZINO_CLIENT_ID = 'test-client'; process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret'; process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test'; const eventoFindFirst = jest.fn(); const eventoCreate = jest.fn(); const eventoCheckUpsert = jest.fn(); const listaFindFirst = jest.fn(); const magazzinoVoceFindMany = jest.fn(); jest.mock('../../src/db/prisma', () => ({ prisma: { evento: { findFirst: (...args: unknown[]) => eventoFindFirst(...args), create: (...args: unknown[]) => eventoCreate(...args), }, eventoCheck: { upsert: (...args: unknown[]) => eventoCheckUpsert(...args), }, lista: { findFirst: (...args: unknown[]) => listaFindFirst(...args), }, magazzinoVoce: { findMany: (...args: unknown[]) => magazzinoVoceFindMany(...args), }, }, })); import { app } from '../../src/app'; const KEYCLOAK_HOST = 'http://keycloak.test'; const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs'; const KID = 'test-kid'; const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const jwk = publicKey.export({ format: 'jwk' }) as Record; const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string; function signToken(payload: object): string { return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' }); } function tokenOrg(orgId: string): string { return signToken({ sub: 'user-1', realm_access: { roles: ['censito'] }, organization: { gruppo: { id: orgId, roles: [] } }, }); } function materiale(id: string, nome: string, unitaMisura: string) { return { id, nome, categoria: 'x', unitaMisura, stato: 'approvato', propostoDaOrgId: 'org-seed', creatoIl: new Date() }; } // Evento con lista a due voci (Tenda x2, Torcia x4): un materiale è tracciato // in magazzino, l'altro no (deve risultare quantitaPosseduta: 0 di default). function eventoConDettagli(overrides: Partial> = {}) { return { id: 'ev-1', orgId: 'org-a', nome: 'Campo estivo 2026', listaId: 'l-1', data: new Date('2026-08-01'), lista: { id: 'l-1', nome: 'Kit campo estivo', orgId: 'org-a', creataIl: new Date('2026-01-01'), voci: [ { listaId: 'l-1', materialeId: 'm-1', quantita: 2, materiale: materiale('m-1', 'Tenda', 'pz') }, { listaId: 'l-1', materialeId: 'm-2', quantita: 4, materiale: materiale('m-2', 'Torcia', 'pz') }, ], }, check: [{ eventoId: 'ev-1', materialeId: 'm-1', portato: true, note: 'controllata' }], ...overrides, }; } beforeAll(() => { nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, { keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }], }); }); afterAll(() => { nock.cleanAll(); }); beforeEach(() => { jest.clearAllMocks(); }); describe('POST /eventi', () => { test("crea l'evento per l'org corrente se la lista appartiene alla stessa org", async () => { listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Kit', orgId: 'org-a', creataIl: new Date(), voci: [] }); eventoCreate.mockResolvedValueOnce(eventoConDettagli({ check: [] })); magazzinoVoceFindMany.mockResolvedValueOnce([]); const response = await request(app) .post('/eventi') .set('Authorization', `Bearer ${tokenOrg('org-a')}`) .send({ nome: 'Campo estivo 2026', listaId: 'l-1', data: '2026-08-01' }); expect(response.status).toBe(201); expect(listaFindFirst).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'l-1', orgId: 'org-a' } }), ); expect(eventoCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ orgId: 'org-a', listaId: 'l-1' }) }), ); }); test('risponde 400 se la lista non esiste o appartiene a un\'altra org', async () => { listaFindFirst.mockResolvedValueOnce(null); const response = await request(app) .post('/eventi') .set('Authorization', `Bearer ${tokenOrg('org-a')}`) .send({ nome: 'Campo estivo 2026', listaId: 'l-di-unaltra-org', data: '2026-08-01' }); expect(response.status).toBe(400); expect(eventoCreate).not.toHaveBeenCalled(); }); test('risponde 401 senza token', async () => { const response = await request(app).post('/eventi').send({ nome: 'x', listaId: 'l-1', data: '2026-08-01' }); expect(response.status).toBe(401); expect(eventoCreate).not.toHaveBeenCalled(); }); }); describe('GET /eventi/:id — join lista <-> magazzino', () => { test("un'org non può leggere un evento di un'altra org", async () => { eventoFindFirst.mockResolvedValueOnce(null); const response = await request(app).get('/eventi/ev-1').set('Authorization', `Bearer ${tokenOrg('org-b')}`); expect(response.status).toBe(404); expect(eventoFindFirst).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'ev-1', orgId: 'org-b' } }), ); expect(magazzinoVoceFindMany).not.toHaveBeenCalled(); }); test('combina, per ogni voce della lista, quantità posseduta in magazzino e stato di check', async () => { eventoFindFirst.mockResolvedValueOnce(eventoConDettagli()); // Solo m-1 è tracciato in magazzino (5 posseduti); m-2 non ha alcuna riga. magazzinoVoceFindMany.mockResolvedValueOnce([{ materialeId: 'm-1', quantitaPosseduta: 5 }]); const response = await request(app).get('/eventi/ev-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`); expect(response.status).toBe(200); expect(magazzinoVoceFindMany).toHaveBeenCalledWith({ where: { orgId: 'org-a', materialeId: { in: ['m-1', 'm-2'] } }, select: { materialeId: true, quantitaPosseduta: true }, }); expect(response.body).toEqual({ id: 'ev-1', orgId: 'org-a', nome: 'Campo estivo 2026', listaId: 'l-1', data: '2026-08-01T00:00:00.000Z', voci: [ { materialeId: 'm-1', nome: 'Tenda', unitaMisura: 'pz', quantitaRichiesta: 2, quantitaPosseduta: 5, portato: true, note: 'controllata', }, { materialeId: 'm-2', nome: 'Torcia', unitaMisura: 'pz', quantitaRichiesta: 4, quantitaPosseduta: 0, portato: false, note: null, }, ], }); }); test('risponde 401 senza token', async () => { const response = await request(app).get('/eventi/ev-1'); expect(response.status).toBe(401); expect(eventoFindFirst).not.toHaveBeenCalled(); }); }); describe('PATCH /eventi/:id/check', () => { test('aggiorna portato/note per una voce e restituisce il dettaglio aggiornato', async () => { eventoFindFirst .mockResolvedValueOnce(eventoConDettagli({ check: [] })) // ownership check dentro aggiornaCheckEvento .mockResolvedValueOnce(eventoConDettagli()); // rilettura per la response eventoCheckUpsert.mockResolvedValueOnce({ eventoId: 'ev-1', materialeId: 'm-1', portato: true, note: 'controllata' }); magazzinoVoceFindMany.mockResolvedValue([{ materialeId: 'm-1', quantitaPosseduta: 5 }]); const response = await request(app) .patch('/eventi/ev-1/check') .set('Authorization', `Bearer ${tokenOrg('org-a')}`) .send({ voci: [{ materialeId: 'm-1', portato: true, note: 'controllata' }] }); expect(response.status).toBe(200); expect(eventoCheckUpsert).toHaveBeenCalledWith({ where: { eventoId_materialeId: { eventoId: 'ev-1', materialeId: 'm-1' } }, create: { eventoId: 'ev-1', materialeId: 'm-1', portato: true, note: 'controllata' }, update: { portato: true, note: 'controllata' }, }); expect(response.body.voci[0]).toMatchObject({ materialeId: 'm-1', portato: true, note: 'controllata' }); }); test('rifiuta un materialeId che non appartiene alla lista collegata (400)', async () => { eventoFindFirst.mockResolvedValueOnce(eventoConDettagli()); const response = await request(app) .patch('/eventi/ev-1/check') .set('Authorization', `Bearer ${tokenOrg('org-a')}`) .send({ voci: [{ materialeId: 'm-estraneo', portato: true }] }); expect(response.status).toBe(400); expect(eventoCheckUpsert).not.toHaveBeenCalled(); }); test("un'org non può aggiornare il check di un evento di un'altra org", async () => { eventoFindFirst.mockResolvedValueOnce(null); const response = await request(app) .patch('/eventi/ev-1/check') .set('Authorization', `Bearer ${tokenOrg('org-b')}`) .send({ voci: [{ materialeId: 'm-1', portato: true }] }); expect(response.status).toBe(404); expect(eventoCheckUpsert).not.toHaveBeenCalled(); }); test('risponde 401 senza token', async () => { const response = await request(app).patch('/eventi/ev-1/check').send({ voci: [{ materialeId: 'm-1', portato: true }] }); expect(response.status).toBe(401); expect(eventoCheckUpsert).not.toHaveBeenCalled(); }); });