255 lines
9.1 KiB
TypeScript
255 lines
9.1 KiB
TypeScript
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<string, unknown>;
|
|
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<Record<string, unknown>> = {}) {
|
|
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();
|
|
});
|
|
});
|