Add scouthub-magazzino-be
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
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 listaFindMany = jest.fn();
|
||||
const listaFindFirst = jest.fn();
|
||||
const listaCreate = jest.fn();
|
||||
const listaUpdate = jest.fn();
|
||||
const listaDelete = jest.fn();
|
||||
const listaVoceDeleteMany = jest.fn();
|
||||
|
||||
const listaModelloFindUnique = jest.fn();
|
||||
const listaModelloUpdate = jest.fn();
|
||||
const listaModelloVoceDeleteMany = jest.fn();
|
||||
|
||||
// $transaction condiviso da entrambi i repository (liste e liste-modello): il tx
|
||||
// espone gli stessi metodi mockati usati fuori transazione, così i test possono
|
||||
// asserire su un'unica lista di chiamate indipendentemente dal fatto che passino
|
||||
// per una transazione o meno.
|
||||
const transactionFn = jest.fn(async (callback: (tx: unknown) => unknown) =>
|
||||
callback({
|
||||
lista: { update: listaUpdate, delete: listaDelete },
|
||||
listaVoce: { deleteMany: listaVoceDeleteMany },
|
||||
listaModello: { update: listaModelloUpdate },
|
||||
listaModelloVoce: { deleteMany: listaModelloVoceDeleteMany },
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('../../src/db/prisma', () => ({
|
||||
prisma: {
|
||||
lista: {
|
||||
findMany: (...args: unknown[]) => listaFindMany(...args),
|
||||
findFirst: (...args: unknown[]) => listaFindFirst(...args),
|
||||
create: (...args: unknown[]) => listaCreate(...args),
|
||||
},
|
||||
listaVoce: {
|
||||
deleteMany: (...args: unknown[]) => listaVoceDeleteMany(...args),
|
||||
},
|
||||
listaModello: {
|
||||
findUnique: (...args: unknown[]) => listaModelloFindUnique(...args),
|
||||
update: (...args: unknown[]) => listaModelloUpdate(...args),
|
||||
},
|
||||
listaModelloVoce: {
|
||||
deleteMany: (...args: unknown[]) => listaModelloVoceDeleteMany(...args),
|
||||
},
|
||||
$transaction: (...args: unknown[]) => transactionFn(...(args as [(tx: unknown) => unknown])),
|
||||
},
|
||||
}));
|
||||
|
||||
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 adminCatalogoToken(): string {
|
||||
return signToken({
|
||||
sub: 'admin-1',
|
||||
realm_access: { roles: ['moderatore'] },
|
||||
organization: { 'gruppo-omega': { id: 'org-9', roles: [] } },
|
||||
});
|
||||
}
|
||||
|
||||
function materialeJoin(id: string, nome: string, unitaMisura: string) {
|
||||
return { id, nome, categoria: 'x', unitaMisura, stato: 'approvato', propostoDaOrgId: 'org-seed', creatoIl: new Date() };
|
||||
}
|
||||
|
||||
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('GET /liste', () => {
|
||||
test('restituisce solo le liste dell\'org corrente, ricavata dal token', async () => {
|
||||
listaFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(listaFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { orgId: 'org-a' } }),
|
||||
);
|
||||
});
|
||||
|
||||
test('org diverse ottengono query filtrate su org_id diversi', async () => {
|
||||
listaFindMany.mockResolvedValue([]);
|
||||
|
||||
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
|
||||
|
||||
expect(listaFindMany).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: { orgId: 'org-a' } }));
|
||||
expect(listaFindMany).toHaveBeenNthCalledWith(2, expect.objectContaining({ where: { orgId: 'org-b' } }));
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).get('/liste');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(listaFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /liste', () => {
|
||||
test('crea una lista vuota per l\'org corrente, ignorando un org_id eventualmente inviato dal client', async () => {
|
||||
listaCreate.mockResolvedValueOnce({ id: 'l-1', nome: 'Lista vuota', orgId: 'org-a', creataIl: new Date(), voci: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Lista vuota', orgId: 'org-spoofed' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(listaCreate).toHaveBeenCalledWith({
|
||||
data: { nome: 'Lista vuota', orgId: 'org-a', voci: { create: [] } },
|
||||
include: { voci: { include: { materiale: true } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /liste/da-modello/:listaModelloId', () => {
|
||||
test('copia nome e voci dalla lista modello, senza salvare alcun riferimento verso di essa', async () => {
|
||||
const vociModello = [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }];
|
||||
listaModelloFindUnique.mockResolvedValueOnce({
|
||||
id: 'lm-1',
|
||||
nome: 'Kit campo estivo',
|
||||
tipoEventoId: 't-1',
|
||||
pubblica: true,
|
||||
voci: vociModello,
|
||||
});
|
||||
listaCreate.mockResolvedValueOnce({
|
||||
id: 'l-2',
|
||||
nome: 'Kit campo estivo',
|
||||
orgId: 'org-a',
|
||||
creataIl: new Date(),
|
||||
voci: [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }],
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste/da-modello/lm-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(JSON.stringify(response.body)).not.toMatch(/listaModello/i);
|
||||
|
||||
const callArg = listaCreate.mock.calls[0][0];
|
||||
expect(callArg.data).toEqual({
|
||||
nome: 'Kit campo estivo',
|
||||
orgId: 'org-a',
|
||||
voci: { create: [{ materialeId: 'm-1', quantita: 2 }] },
|
||||
});
|
||||
// Le voci passate a create sono un array nuovo con valori copiati, non lo
|
||||
// stesso array (né gli stessi oggetti) restituiti dalla lista modello.
|
||||
expect(callArg.data.voci.create).not.toBe(vociModello);
|
||||
});
|
||||
|
||||
test('risponde 404 se la lista modello non esiste', async () => {
|
||||
listaModelloFindUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste/da-modello/inesistente')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(listaCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).post('/liste/da-modello/lm-1');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(listaCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /liste/:id — isolamento tra org', () => {
|
||||
test('un\'org non può modificare una lista di un\'altra org (risponde 404, non 403, per non rivelarne l\'esistenza)', async () => {
|
||||
// La query combina sempre id + orgId: una lista di un'altra org non viene trovata.
|
||||
listaFindFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-b')}`)
|
||||
.send({ nome: 'Nome modificato' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(listaFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'l-1', orgId: 'org-b' } }),
|
||||
);
|
||||
expect(listaUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('l\'org proprietaria può modificare la propria lista', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Vecchio nome', orgId: 'org-a', creataIl: new Date(), voci: [] });
|
||||
listaUpdate.mockResolvedValueOnce({ id: 'l-1', nome: 'Nuovo nome', orgId: 'org-a', creataIl: new Date(), voci: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Nuovo nome' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'l-1' }, data: expect.objectContaining({ nome: 'Nuovo nome' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).put('/liste/l-1').send({ nome: 'x' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(listaFindFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /liste/:id — isolamento tra org', () => {
|
||||
test('un\'org non può eliminare una lista di un\'altra org', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(listaDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('l\'org proprietaria può eliminare la propria lista', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Lista', orgId: 'org-a', creataIl: new Date(), voci: [] });
|
||||
listaDelete.mockResolvedValueOnce({ id: 'l-1' });
|
||||
|
||||
const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(listaVoceDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-1' } });
|
||||
expect(listaDelete).toHaveBeenCalledWith({ where: { id: 'l-1' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('indipendenza tra lista modello e lista forkata', () => {
|
||||
test('aggiornare la lista modello originale non tocca in alcun modo le tabelle della lista privata forkata', async () => {
|
||||
listaModelloUpdate.mockResolvedValueOnce({
|
||||
id: 'lm-1',
|
||||
nome: 'Kit aggiornato',
|
||||
tipoEventoId: 't-1',
|
||||
pubblica: true,
|
||||
voci: [],
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste-modello/lm-1')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ nome: 'Kit aggiornato', voci: [] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaModelloVoceDeleteMany).toHaveBeenCalledWith({ where: { listaModelloId: 'lm-1' } });
|
||||
expect(listaModelloUpdate).toHaveBeenCalled();
|
||||
// Nessuna chiamata sulle tabelle delle liste private: le due entità sono
|
||||
// completamente disgiunte dopo il fork.
|
||||
expect(listaVoceDeleteMany).not.toHaveBeenCalled();
|
||||
expect(listaUpdate).not.toHaveBeenCalled();
|
||||
expect(listaDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('aggiornare la lista privata forkata non tocca in alcun modo le tabelle della lista modello originale', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce({ id: 'l-2', nome: 'Kit campo estivo', orgId: 'org-a', creataIl: new Date(), voci: [] });
|
||||
listaUpdate.mockResolvedValueOnce({
|
||||
id: 'l-2',
|
||||
nome: 'Kit campo estivo (personalizzato)',
|
||||
orgId: 'org-a',
|
||||
creataIl: new Date(),
|
||||
voci: [],
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-2')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Kit campo estivo (personalizzato)', voci: [] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaVoceDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-2' } });
|
||||
expect(listaUpdate).toHaveBeenCalled();
|
||||
expect(listaModelloVoceDeleteMany).not.toHaveBeenCalled();
|
||||
expect(listaModelloUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
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 listaModelloFindMany = jest.fn();
|
||||
const listaModelloCreate = jest.fn();
|
||||
|
||||
// tx espone gli stessi metodi usati dal repository dentro $transaction; i test su
|
||||
// update/delete forniscono un tx dedicato via mockImplementationOnce.
|
||||
const listaModelloVoceDeleteMany = jest.fn();
|
||||
const listaModelloUpdate = jest.fn();
|
||||
const listaModelloDelete = jest.fn();
|
||||
|
||||
const transactionFn = jest.fn(async (callback: (tx: unknown) => unknown) =>
|
||||
callback({
|
||||
listaModelloVoce: { deleteMany: listaModelloVoceDeleteMany },
|
||||
listaModello: { update: listaModelloUpdate, delete: listaModelloDelete },
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('../../src/db/prisma', () => ({
|
||||
prisma: {
|
||||
listaModello: {
|
||||
findMany: (...args: unknown[]) => listaModelloFindMany(...args),
|
||||
create: (...args: unknown[]) => listaModelloCreate(...args),
|
||||
},
|
||||
$transaction: (...args: unknown[]) => transactionFn(...(args as [(tx: unknown) => unknown])),
|
||||
},
|
||||
}));
|
||||
|
||||
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 utenteToken(): string {
|
||||
return signToken({
|
||||
sub: 'user-1',
|
||||
realm_access: { roles: ['censito'] },
|
||||
organization: { 'gruppo-alfa': { id: 'org-1', roles: [] } },
|
||||
});
|
||||
}
|
||||
|
||||
function adminCatalogoToken(): string {
|
||||
return signToken({
|
||||
sub: 'admin-1',
|
||||
realm_access: { roles: ['moderatore'] },
|
||||
organization: { 'gruppo-omega': { id: 'org-9', roles: [] } },
|
||||
});
|
||||
}
|
||||
|
||||
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('GET /liste-modello', () => {
|
||||
test('è pubblico e restituisce le liste con le voci (materiale + quantità)', async () => {
|
||||
listaModelloFindMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'lm-1',
|
||||
nome: 'Kit campo estivo',
|
||||
tipoEventoId: 't-1',
|
||||
pubblica: true,
|
||||
voci: [{ materialeId: 'm-1', quantita: 2, materiale: { id: 'm-1', nome: 'Tenda', unitaMisura: 'pz' } }],
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await request(app).get('/liste-modello');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
id: 'lm-1',
|
||||
nome: 'Kit campo estivo',
|
||||
tipoEventoId: 't-1',
|
||||
voci: [{ materialeId: 'm-1', nome: 'Tenda', unitaMisura: 'pz', quantita: 2 }],
|
||||
},
|
||||
]);
|
||||
expect(listaModelloFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { pubblica: true } }),
|
||||
);
|
||||
});
|
||||
|
||||
test('filtra per tipoEventoId quando richiesto', async () => {
|
||||
listaModelloFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
await request(app).get('/liste-modello').query({ tipoEventoId: 't-1' });
|
||||
|
||||
expect(listaModelloFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { pubblica: true, tipoEventoId: 't-1' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /liste-modello', () => {
|
||||
const body = { nome: 'Kit bivacco', tipoEventoId: 't-2', voci: [{ materialeId: 'm-1', quantita: 3 }] };
|
||||
|
||||
test('un utente normale non può creare una lista modello', async () => {
|
||||
const response = await request(app)
|
||||
.post('/liste-modello')
|
||||
.set('Authorization', `Bearer ${utenteToken()}`)
|
||||
.send(body);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(listaModelloCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un moderatore può creare una lista modello, sempre pubblica', async () => {
|
||||
listaModelloCreate.mockResolvedValueOnce({
|
||||
id: 'lm-2',
|
||||
nome: 'Kit bivacco',
|
||||
tipoEventoId: 't-2',
|
||||
pubblica: true,
|
||||
voci: [{ materialeId: 'm-1', quantita: 3, materiale: { id: 'm-1', nome: 'Corda', unitaMisura: 'm' } }],
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste-modello')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send(body);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(listaModelloCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
nome: 'Kit bivacco',
|
||||
tipoEventoId: 't-2',
|
||||
pubblica: true,
|
||||
voci: { create: [{ materialeId: 'm-1', quantita: 3 }] },
|
||||
},
|
||||
include: { voci: { include: { materiale: true } } },
|
||||
});
|
||||
});
|
||||
|
||||
test('ignora un eventuale pubblica:false inviato dal client, resta sempre true', async () => {
|
||||
listaModelloCreate.mockResolvedValueOnce({
|
||||
id: 'lm-3',
|
||||
nome: 'Kit bivacco',
|
||||
tipoEventoId: 't-2',
|
||||
pubblica: true,
|
||||
voci: [],
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post('/liste-modello')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ ...body, voci: [], pubblica: false });
|
||||
|
||||
expect(listaModelloCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ pubblica: true }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).post('/liste-modello').send(body);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(listaModelloCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /liste-modello/:id', () => {
|
||||
test('un utente normale non può modificare una lista modello', async () => {
|
||||
const response = await request(app)
|
||||
.put('/liste-modello/lm-1')
|
||||
.set('Authorization', `Bearer ${utenteToken()}`)
|
||||
.send({ nome: 'Kit aggiornato' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(listaModelloUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un moderatore può modificare nome e voci di una lista modello', async () => {
|
||||
listaModelloUpdate.mockResolvedValueOnce({
|
||||
id: 'lm-1',
|
||||
nome: 'Kit aggiornato',
|
||||
tipoEventoId: 't-1',
|
||||
pubblica: true,
|
||||
voci: [{ materialeId: 'm-2', quantita: 1, materiale: { id: 'm-2', nome: 'Torcia', unitaMisura: 'pz' } }],
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste-modello/lm-1')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ nome: 'Kit aggiornato', voci: [{ materialeId: 'm-2', quantita: 1 }] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaModelloVoceDeleteMany).toHaveBeenCalledWith({ where: { listaModelloId: 'lm-1' } });
|
||||
expect(listaModelloUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'lm-1' },
|
||||
data: expect.objectContaining({
|
||||
nome: 'Kit aggiornato',
|
||||
voci: { create: [{ materialeId: 'm-2', quantita: 1 }] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /liste-modello/:id', () => {
|
||||
test('un utente normale non può eliminare una lista modello', async () => {
|
||||
const response = await request(app).delete('/liste-modello/lm-1').set('Authorization', `Bearer ${utenteToken()}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(listaModelloDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un moderatore può eliminare una lista modello (e le sue voci)', async () => {
|
||||
listaModelloDelete.mockResolvedValueOnce({ id: 'lm-1' });
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/liste-modello/lm-1')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(listaModelloVoceDeleteMany).toHaveBeenCalledWith({ where: { listaModelloId: 'lm-1' } });
|
||||
expect(listaModelloDelete).toHaveBeenCalledWith({ where: { id: 'lm-1' } });
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).delete('/liste-modello/lm-1');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(listaModelloDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
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 magazzinoVoceFindMany = jest.fn();
|
||||
const magazzinoVoceFindFirst = jest.fn();
|
||||
const magazzinoVoceCreate = jest.fn();
|
||||
const magazzinoVoceUpdate = jest.fn();
|
||||
const magazzinoVoceDelete = jest.fn();
|
||||
const materialeFindUnique = jest.fn();
|
||||
|
||||
jest.mock('../../src/db/prisma', () => ({
|
||||
prisma: {
|
||||
magazzinoVoce: {
|
||||
findMany: (...args: unknown[]) => magazzinoVoceFindMany(...args),
|
||||
findFirst: (...args: unknown[]) => magazzinoVoceFindFirst(...args),
|
||||
create: (...args: unknown[]) => magazzinoVoceCreate(...args),
|
||||
update: (...args: unknown[]) => magazzinoVoceUpdate(...args),
|
||||
delete: (...args: unknown[]) => magazzinoVoceDelete(...args),
|
||||
},
|
||||
materiale: {
|
||||
findUnique: (...args: unknown[]) => materialeFindUnique(...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 materialeApprovato(id = 'm-1') {
|
||||
return {
|
||||
id,
|
||||
nome: 'Tenda canadese',
|
||||
categoria: 'campeggio',
|
||||
unitaMisura: 'pz',
|
||||
stato: 'approvato',
|
||||
propostoDaOrgId: 'org-seed',
|
||||
creatoIl: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function voceConMateriale(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: 'mv-1',
|
||||
orgId: 'org-a',
|
||||
materialeId: 'm-1',
|
||||
quantitaPosseduta: 3,
|
||||
stato: 'buono',
|
||||
posizione: 'scaffale A',
|
||||
note: null,
|
||||
materiale: materialeApprovato(),
|
||||
...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('GET /magazzino', () => {
|
||||
test('restituisce l\'inventario dell\'org corrente, con nome/categoria del materiale', async () => {
|
||||
magazzinoVoceFindMany.mockResolvedValueOnce([voceConMateriale()]);
|
||||
|
||||
const response = await request(app).get('/magazzino').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(magazzinoVoceFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { orgId: 'org-a' } }),
|
||||
);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
id: 'mv-1',
|
||||
orgId: 'org-a',
|
||||
materialeId: 'm-1',
|
||||
materialeNome: 'Tenda canadese',
|
||||
materialeCategoria: 'campeggio',
|
||||
quantitaPosseduta: 3,
|
||||
stato: 'buono',
|
||||
posizione: 'scaffale A',
|
||||
note: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('org diverse ottengono query filtrate su org_id diversi', async () => {
|
||||
magazzinoVoceFindMany.mockResolvedValue([]);
|
||||
|
||||
await request(app).get('/magazzino').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
await request(app).get('/magazzino').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
|
||||
|
||||
expect(magazzinoVoceFindMany).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: { orgId: 'org-a' } }));
|
||||
expect(magazzinoVoceFindMany).toHaveBeenNthCalledWith(2, expect.objectContaining({ where: { orgId: 'org-b' } }));
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).get('/magazzino');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(magazzinoVoceFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /magazzino', () => {
|
||||
test('aggiunge una voce per l\'org corrente se il materiale è approvato', async () => {
|
||||
materialeFindUnique.mockResolvedValueOnce(materialeApprovato());
|
||||
magazzinoVoceCreate.mockResolvedValueOnce(voceConMateriale());
|
||||
|
||||
const response = await request(app)
|
||||
.post('/magazzino')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ materialeId: 'm-1', quantitaPosseduta: 3, stato: 'buono', posizione: 'scaffale A' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(magazzinoVoceCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
materialeId: 'm-1',
|
||||
quantitaPosseduta: 3,
|
||||
stato: 'buono',
|
||||
posizione: 'scaffale A',
|
||||
note: undefined,
|
||||
orgId: 'org-a',
|
||||
},
|
||||
include: { materiale: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('ignora un eventuale org_id inviato dal client, usa sempre quello del token', async () => {
|
||||
materialeFindUnique.mockResolvedValueOnce(materialeApprovato());
|
||||
magazzinoVoceCreate.mockResolvedValueOnce(voceConMateriale());
|
||||
|
||||
await request(app)
|
||||
.post('/magazzino')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ materialeId: 'm-1', quantitaPosseduta: 3, stato: 'buono', orgId: 'org-spoofed' });
|
||||
|
||||
expect(magazzinoVoceCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ orgId: 'org-a' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('risponde 400 e invita a proporre il materiale se non esiste nel catalogo', async () => {
|
||||
materialeFindUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/magazzino')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ materialeId: 'inesistente', quantitaPosseduta: 1, stato: 'buono' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toMatch(/POST \/materiali\/proposte/);
|
||||
expect(magazzinoVoceCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 400 se il materiale esiste ma non è ancora approvato', async () => {
|
||||
materialeFindUnique.mockResolvedValueOnce({ ...materialeApprovato(), stato: 'proposto' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/magazzino')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ materialeId: 'm-1', quantitaPosseduta: 1, stato: 'buono' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toMatch(/POST \/materiali\/proposte/);
|
||||
expect(magazzinoVoceCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 400 se lo stato non è uno dei valori validi', async () => {
|
||||
const response = await request(app)
|
||||
.post('/magazzino')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ materialeId: 'm-1', quantitaPosseduta: 1, stato: 'ottimo' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(materialeFindUnique).not.toHaveBeenCalled();
|
||||
expect(magazzinoVoceCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).post('/magazzino').send({ materialeId: 'm-1', quantitaPosseduta: 1, stato: 'buono' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(magazzinoVoceCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /magazzino/:id — isolamento tra org', () => {
|
||||
test('un\'org non può modificare una voce di un\'altra org (404, non 403)', async () => {
|
||||
magazzinoVoceFindFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/magazzino/mv-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-b')}`)
|
||||
.send({ quantitaPosseduta: 5 });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(magazzinoVoceFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'mv-1', orgId: 'org-b' } }),
|
||||
);
|
||||
expect(magazzinoVoceUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('l\'org proprietaria può modificare la propria voce', async () => {
|
||||
magazzinoVoceFindFirst.mockResolvedValueOnce(voceConMateriale());
|
||||
magazzinoVoceUpdate.mockResolvedValueOnce(voceConMateriale({ quantitaPosseduta: 5 }));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/magazzino/mv-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ quantitaPosseduta: 5 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.quantitaPosseduta).toBe(5);
|
||||
expect(magazzinoVoceUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'mv-1' },
|
||||
data: { quantitaPosseduta: 5 },
|
||||
include: { materiale: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('se si cambia materialeId, valida di nuovo che sia approvato', async () => {
|
||||
magazzinoVoceFindFirst.mockResolvedValueOnce(voceConMateriale());
|
||||
materialeFindUnique.mockResolvedValueOnce({ ...materialeApprovato('m-2'), stato: 'proposto' });
|
||||
|
||||
const response = await request(app)
|
||||
.put('/magazzino/mv-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ materialeId: 'm-2' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toMatch(/POST \/materiali\/proposte/);
|
||||
expect(magazzinoVoceUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).put('/magazzino/mv-1').send({ quantitaPosseduta: 1 });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(magazzinoVoceFindFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /magazzino/:id — isolamento tra org', () => {
|
||||
test('un\'org non può eliminare una voce di un\'altra org', async () => {
|
||||
magazzinoVoceFindFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app).delete('/magazzino/mv-1').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(magazzinoVoceDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('l\'org proprietaria può eliminare la propria voce', async () => {
|
||||
magazzinoVoceFindFirst.mockResolvedValueOnce(voceConMateriale());
|
||||
magazzinoVoceDelete.mockResolvedValueOnce({ id: 'mv-1' });
|
||||
|
||||
const response = await request(app).delete('/magazzino/mv-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(magazzinoVoceDelete).toHaveBeenCalledWith({ where: { id: 'mv-1' } });
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).delete('/magazzino/mv-1');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(magazzinoVoceDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
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 materialeFindMany = jest.fn();
|
||||
const materialeFindUnique = jest.fn();
|
||||
const materialeCreate = jest.fn();
|
||||
const materialeUpdate = jest.fn();
|
||||
|
||||
jest.mock('../../src/db/prisma', () => ({
|
||||
prisma: {
|
||||
materiale: {
|
||||
findMany: (...args: unknown[]) => materialeFindMany(...args),
|
||||
findUnique: (...args: unknown[]) => materialeFindUnique(...args),
|
||||
create: (...args: unknown[]) => materialeCreate(...args),
|
||||
update: (...args: unknown[]) => materialeUpdate(...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 utenteToken(orgId = 'org-1'): string {
|
||||
return signToken({
|
||||
sub: 'user-1',
|
||||
realm_access: { roles: ['censito'] },
|
||||
organization: { 'gruppo-alfa': { id: orgId, roles: [] } },
|
||||
});
|
||||
}
|
||||
|
||||
function adminCatalogoToken(orgId = 'org-9'): string {
|
||||
return signToken({
|
||||
sub: 'admin-1',
|
||||
realm_access: { roles: ['moderatore'] },
|
||||
organization: { 'gruppo-omega': { id: orgId, roles: [] } },
|
||||
});
|
||||
}
|
||||
|
||||
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('GET /materiali', () => {
|
||||
test('non richiede autenticazione e restituisce solo i materiali approvati', async () => {
|
||||
materialeFindMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'm-1',
|
||||
nome: 'Tenda canadese',
|
||||
categoria: 'campeggio',
|
||||
unitaMisura: 'pz',
|
||||
stato: 'approvato',
|
||||
propostoDaOrgId: 'org-x',
|
||||
creatoIl: new Date('2026-01-01'),
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await request(app).get('/materiali');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([{ id: 'm-1', nome: 'Tenda canadese', categoria: 'campeggio', unitaMisura: 'pz' }]);
|
||||
expect(materialeFindMany).toHaveBeenCalledWith({
|
||||
where: { stato: 'approvato' },
|
||||
orderBy: { nome: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
test('filtra per categoria quando richiesto', async () => {
|
||||
materialeFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
await request(app).get('/materiali').query({ categoria: 'cucina' });
|
||||
|
||||
expect(materialeFindMany).toHaveBeenCalledWith({
|
||||
where: { stato: 'approvato', categoria: 'cucina' },
|
||||
orderBy: { nome: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
test('il catalogo pubblico esclude sempre le proposte non approvate, anche forzando uno stato via query string', async () => {
|
||||
materialeFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
await request(app).get('/materiali').query({ stato: 'proposto' });
|
||||
|
||||
// Il filtro "stato" non è un parametro accettato: la query verso il database
|
||||
// continua a chiedere solo lo stato "approvato".
|
||||
expect(materialeFindMany).toHaveBeenCalledWith({
|
||||
where: { stato: 'approvato' },
|
||||
orderBy: { nome: 'asc' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /materiali/proposte', () => {
|
||||
test('salva la proposta con stato "proposto" e proposto_da_org_id preso dal token', async () => {
|
||||
materialeCreate.mockResolvedValueOnce({
|
||||
id: 'm-2',
|
||||
nome: 'Fornello a gas',
|
||||
categoria: 'cucina',
|
||||
unitaMisura: 'pz',
|
||||
stato: 'proposto',
|
||||
propostoDaOrgId: 'org-1',
|
||||
creatoIl: new Date('2026-01-02'),
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/materiali/proposte')
|
||||
.set('Authorization', `Bearer ${utenteToken('org-1')}`)
|
||||
.send({ nome: 'Fornello a gas', categoria: 'cucina', unitaMisura: 'pz' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toMatchObject({ propostoDaOrgId: 'org-1', stato: 'proposto' });
|
||||
expect(materialeCreate).toHaveBeenCalledWith({
|
||||
data: { nome: 'Fornello a gas', categoria: 'cucina', unitaMisura: 'pz', propostoDaOrgId: 'org-1', stato: 'proposto' },
|
||||
});
|
||||
});
|
||||
|
||||
test('ignora un eventuale proposto_da_org_id inviato dal client, usa sempre quello del token', async () => {
|
||||
materialeCreate.mockResolvedValueOnce({
|
||||
id: 'm-3',
|
||||
nome: 'Piccone',
|
||||
categoria: 'attrezzi',
|
||||
unitaMisura: 'pz',
|
||||
stato: 'proposto',
|
||||
propostoDaOrgId: 'org-1',
|
||||
creatoIl: new Date('2026-01-03'),
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post('/materiali/proposte')
|
||||
.set('Authorization', `Bearer ${utenteToken('org-1')}`)
|
||||
.send({ nome: 'Piccone', categoria: 'attrezzi', unitaMisura: 'pz', propostoDaOrgId: 'org-spoofed' });
|
||||
|
||||
expect(materialeCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ propostoDaOrgId: 'org-1' }),
|
||||
});
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/materiali/proposte')
|
||||
.send({ nome: 'Fornello', categoria: 'cucina', unitaMisura: 'pz' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(materialeCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("risponde 400 se manca un campo obbligatorio", async () => {
|
||||
const response = await request(app)
|
||||
.post('/materiali/proposte')
|
||||
.set('Authorization', `Bearer ${utenteToken()}`)
|
||||
.send({ nome: 'Fornello' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(materialeCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /materiali/proposte', () => {
|
||||
test('un utente normale non può accedere', async () => {
|
||||
const response = await request(app).get('/materiali/proposte').set('Authorization', `Bearer ${utenteToken()}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(materialeFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un moderatore vede tutte le proposte pending', async () => {
|
||||
materialeFindMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'm-4',
|
||||
nome: 'Corda',
|
||||
categoria: 'attrezzi',
|
||||
unitaMisura: 'm',
|
||||
stato: 'proposto',
|
||||
propostoDaOrgId: 'org-7',
|
||||
creatoIl: new Date('2026-01-04'),
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await request(app).get('/materiali/proposte').set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(materialeFindMany).toHaveBeenCalledWith({
|
||||
where: { stato: 'proposto' },
|
||||
orderBy: { creatoIl: 'asc' },
|
||||
});
|
||||
expect(response.body).toEqual([
|
||||
expect.objectContaining({ id: 'm-4', propostoDaOrgId: 'org-7', stato: 'proposto' }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).get('/materiali/proposte');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(materialeFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /materiali/proposte/:id', () => {
|
||||
test('un utente normale non può decidere una proposta', async () => {
|
||||
const response = await request(app)
|
||||
.patch('/materiali/proposte/m-1')
|
||||
.set('Authorization', `Bearer ${utenteToken()}`)
|
||||
.send({ decisione: 'approvato' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(materialeUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un moderatore può approvare una proposta pending', async () => {
|
||||
materialeFindUnique.mockResolvedValueOnce({ id: 'm-1', stato: 'proposto' });
|
||||
materialeUpdate.mockResolvedValueOnce({
|
||||
id: 'm-1',
|
||||
nome: 'Tenda',
|
||||
categoria: 'campeggio',
|
||||
unitaMisura: 'pz',
|
||||
stato: 'approvato',
|
||||
propostoDaOrgId: 'org-1',
|
||||
creatoIl: new Date('2026-01-01'),
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/materiali/proposte/m-1')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ decisione: 'approvato' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.stato).toBe('approvato');
|
||||
expect(materialeUpdate).toHaveBeenCalledWith({ where: { id: 'm-1' }, data: { stato: 'approvato' } });
|
||||
});
|
||||
|
||||
test('un moderatore può rifiutare una proposta pending', async () => {
|
||||
materialeFindUnique.mockResolvedValueOnce({ id: 'm-2', stato: 'proposto' });
|
||||
materialeUpdate.mockResolvedValueOnce({
|
||||
id: 'm-2',
|
||||
nome: 'Zaino',
|
||||
categoria: 'equipaggiamento',
|
||||
unitaMisura: 'pz',
|
||||
stato: 'rifiutato',
|
||||
propostoDaOrgId: 'org-2',
|
||||
creatoIl: new Date('2026-01-01'),
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/materiali/proposte/m-2')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ decisione: 'rifiutato' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.stato).toBe('rifiutato');
|
||||
expect(materialeUpdate).toHaveBeenCalledWith({ where: { id: 'm-2' }, data: { stato: 'rifiutato' } });
|
||||
});
|
||||
|
||||
test('risponde 404 se la proposta non esiste', async () => {
|
||||
materialeFindUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/materiali/proposte/inesistente')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ decisione: 'approvato' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(materialeUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 409 se la proposta è già stata decisa', async () => {
|
||||
materialeFindUnique.mockResolvedValueOnce({ id: 'm-1', stato: 'approvato' });
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/materiali/proposte/m-1')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ decisione: 'rifiutato' });
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(materialeUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).patch('/materiali/proposte/m-1').send({ decisione: 'approvato' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(materialeUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
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 tipoEventoFindMany = jest.fn();
|
||||
const tipoEventoCreate = jest.fn();
|
||||
const tipoEventoUpdate = jest.fn();
|
||||
const tipoEventoDelete = jest.fn();
|
||||
|
||||
jest.mock('../../src/db/prisma', () => ({
|
||||
prisma: {
|
||||
tipoEvento: {
|
||||
findMany: (...args: unknown[]) => tipoEventoFindMany(...args),
|
||||
create: (...args: unknown[]) => tipoEventoCreate(...args),
|
||||
update: (...args: unknown[]) => tipoEventoUpdate(...args),
|
||||
delete: (...args: unknown[]) => tipoEventoDelete(...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 utenteToken(): string {
|
||||
return signToken({
|
||||
sub: 'user-1',
|
||||
realm_access: { roles: ['censito'] },
|
||||
organization: { 'gruppo-alfa': { id: 'org-1', roles: [] } },
|
||||
});
|
||||
}
|
||||
|
||||
function adminCatalogoToken(): string {
|
||||
return signToken({
|
||||
sub: 'admin-1',
|
||||
realm_access: { roles: ['moderatore'] },
|
||||
organization: { 'gruppo-omega': { id: 'org-9', roles: [] } },
|
||||
});
|
||||
}
|
||||
|
||||
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('GET /tipi-evento', () => {
|
||||
test('è pubblico e restituisce la lista', async () => {
|
||||
tipoEventoFindMany.mockResolvedValueOnce([{ id: 't-1', nome: 'Campo estivo' }]);
|
||||
|
||||
const response = await request(app).get('/tipi-evento');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([{ id: 't-1', nome: 'Campo estivo' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /tipi-evento', () => {
|
||||
test('un utente normale non può creare un tipo evento', async () => {
|
||||
const response = await request(app)
|
||||
.post('/tipi-evento')
|
||||
.set('Authorization', `Bearer ${utenteToken()}`)
|
||||
.send({ nome: 'Bivacco' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(tipoEventoCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un moderatore può creare un tipo evento', async () => {
|
||||
tipoEventoCreate.mockResolvedValueOnce({ id: 't-2', nome: 'Bivacco' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/tipi-evento')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ nome: 'Bivacco' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(tipoEventoCreate).toHaveBeenCalledWith({ data: { nome: 'Bivacco' } });
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).post('/tipi-evento').send({ nome: 'Bivacco' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(tipoEventoCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /tipi-evento/:id', () => {
|
||||
test('un utente normale non può modificare un tipo evento', async () => {
|
||||
const response = await request(app)
|
||||
.put('/tipi-evento/t-1')
|
||||
.set('Authorization', `Bearer ${utenteToken()}`)
|
||||
.send({ nome: 'Uscita di branco' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(tipoEventoUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un moderatore può modificare un tipo evento', async () => {
|
||||
tipoEventoUpdate.mockResolvedValueOnce({ id: 't-1', nome: 'Uscita di branco' });
|
||||
|
||||
const response = await request(app)
|
||||
.put('/tipi-evento/t-1')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ nome: 'Uscita di branco' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(tipoEventoUpdate).toHaveBeenCalledWith({ where: { id: 't-1' }, data: { nome: 'Uscita di branco' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /tipi-evento/:id', () => {
|
||||
test('un utente normale non può eliminare un tipo evento', async () => {
|
||||
const response = await request(app).delete('/tipi-evento/t-1').set('Authorization', `Bearer ${utenteToken()}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(tipoEventoDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un moderatore può eliminare un tipo evento', async () => {
|
||||
tipoEventoDelete.mockResolvedValueOnce({ id: 't-1', nome: 'Sede' });
|
||||
|
||||
const response = await request(app).delete('/tipi-evento/t-1').set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(tipoEventoDelete).toHaveBeenCalledWith({ where: { id: 't-1' } });
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).delete('/tipi-evento/t-1');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(tipoEventoDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user