Sistemato magazzino
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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 categoriaFindMany = jest.fn();
|
||||
const categoriaFindUnique = jest.fn();
|
||||
const categoriaCreate = jest.fn();
|
||||
const categoriaUpdate = jest.fn();
|
||||
const categoriaDelete = jest.fn();
|
||||
|
||||
jest.mock('../../src/db/prisma', () => ({
|
||||
prisma: {
|
||||
categoria: {
|
||||
findMany: (...args: unknown[]) => categoriaFindMany(...args),
|
||||
findUnique: (...args: unknown[]) => categoriaFindUnique(...args),
|
||||
create: (...args: unknown[]) => categoriaCreate(...args),
|
||||
update: (...args: unknown[]) => categoriaUpdate(...args),
|
||||
delete: (...args: unknown[]) => categoriaDelete(...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(): 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 /categorie', () => {
|
||||
test('un utente normale non può accedere', async () => {
|
||||
const response = await request(app).get('/categorie').set('Authorization', `Bearer ${utenteToken()}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(categoriaFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un moderatore vede tutte le categorie', async () => {
|
||||
categoriaFindMany.mockResolvedValueOnce([{ id: 'c-1', nome: 'Cucina', stato: 'confermata' }]);
|
||||
|
||||
const response = await request(app).get('/categorie').set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([{ id: 'c-1', nome: 'Cucina', stato: 'confermata' }]);
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).get('/categorie');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(categoriaFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /categorie', () => {
|
||||
test('un utente normale non può creare una categoria direttamente', async () => {
|
||||
const response = await request(app)
|
||||
.post('/categorie')
|
||||
.set('Authorization', `Bearer ${utenteToken()}`)
|
||||
.send({ nome: 'Campeggio' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(categoriaCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un moderatore crea una categoria già confermata', async () => {
|
||||
categoriaCreate.mockResolvedValueOnce({ id: 'c-2', nome: 'Campeggio', stato: 'confermata' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/categorie')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ nome: 'Campeggio' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(categoriaCreate).toHaveBeenCalledWith({ data: { nome: 'Campeggio', stato: 'confermata' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /categorie/:id', () => {
|
||||
test('un moderatore può modificare una categoria', async () => {
|
||||
categoriaUpdate.mockResolvedValueOnce({ id: 'c-1', nome: 'Cucina da campo', stato: 'confermata' });
|
||||
|
||||
const response = await request(app)
|
||||
.put('/categorie/c-1')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ nome: 'Cucina da campo' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(categoriaUpdate).toHaveBeenCalledWith({ where: { id: 'c-1' }, data: { nome: 'Cucina da campo' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /categorie/:id', () => {
|
||||
test('un moderatore può eliminare una categoria', async () => {
|
||||
categoriaDelete.mockResolvedValueOnce({ id: 'c-1' });
|
||||
|
||||
const response = await request(app).delete('/categorie/c-1').set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(categoriaDelete).toHaveBeenCalledWith({ where: { id: 'c-1' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /categorie/proposte', () => {
|
||||
test("un utente autenticato con un'org propone una categoria, che nasce 'da_approvare'", async () => {
|
||||
categoriaCreate.mockResolvedValueOnce({
|
||||
id: 'c-3',
|
||||
nome: 'Escursionismo',
|
||||
stato: 'da_approvare',
|
||||
creatoDaOrgId: 'org-1',
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/categorie/proposte')
|
||||
.set('Authorization', `Bearer ${utenteToken('org-1')}`)
|
||||
.send({ nome: 'Escursionismo' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toMatchObject({ stato: 'da_approvare', creatoDaOrgId: 'org-1' });
|
||||
expect(categoriaCreate).toHaveBeenCalledWith({
|
||||
data: { nome: 'Escursionismo', stato: 'da_approvare', creatoDaOrgId: 'org-1' },
|
||||
});
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).post('/categorie/proposte').send({ nome: 'Escursionismo' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(categoriaCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /categorie/:id/approva', () => {
|
||||
test('un moderatore può approvare una categoria proposta', async () => {
|
||||
categoriaFindUnique.mockResolvedValueOnce({ id: 'c-3', nome: 'Escursionismo', stato: 'da_approvare' });
|
||||
categoriaUpdate.mockResolvedValueOnce({ id: 'c-3', nome: 'Escursionismo', stato: 'confermata' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/categorie/c-3/approva')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.stato).toBe('confermata');
|
||||
expect(categoriaUpdate).toHaveBeenCalledWith({ where: { id: 'c-3' }, data: { stato: 'confermata' } });
|
||||
});
|
||||
|
||||
test('risponde 409 se la categoria è già confermata', async () => {
|
||||
categoriaFindUnique.mockResolvedValueOnce({ id: 'c-1', nome: 'Cucina', stato: 'confermata' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/categorie/c-1/approva')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(categoriaUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 404 se la categoria non esiste', async () => {
|
||||
categoriaFindUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/categorie/inesistente/approva')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(categoriaUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /categorie/:id/rifiuta', () => {
|
||||
test('un moderatore può rifiutare una categoria proposta, che viene eliminata', async () => {
|
||||
categoriaFindUnique.mockResolvedValueOnce({ id: 'c-3', nome: 'Escursionismo', stato: 'da_approvare' });
|
||||
categoriaDelete.mockResolvedValueOnce({ id: 'c-3' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/categorie/c-3/rifiuta')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(categoriaDelete).toHaveBeenCalledWith({ where: { id: 'c-3' } });
|
||||
});
|
||||
|
||||
test('risponde 409 se la categoria è già confermata', async () => {
|
||||
categoriaFindUnique.mockResolvedValueOnce({ id: 'c-1', nome: 'Cucina', stato: 'confermata' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/categorie/c-1/rifiuta')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(categoriaDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,21 +15,18 @@ const listaCreate = jest.fn();
|
||||
const listaUpdate = jest.fn();
|
||||
const listaDelete = jest.fn();
|
||||
const listaVoceDeleteMany = jest.fn();
|
||||
const listaSottoListaDeleteMany = jest.fn();
|
||||
const listaSottoListaFindMany = 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.
|
||||
// $transaction espone lo stesso client mockato usato fuori transazione, così i test
|
||||
// possono asserire sull'unica lista di chiamate indipendentemente dal fatto che
|
||||
// passino per una transazione o meno (creaLista/aggiornaLista aprono sempre una
|
||||
// propria transazione per orchestrare fork + aggancio sotto-liste atomicamente).
|
||||
const transactionFn = jest.fn(async (callback: (tx: unknown) => unknown) =>
|
||||
callback({
|
||||
lista: { update: listaUpdate, delete: listaDelete },
|
||||
lista: { create: listaCreate, update: listaUpdate, delete: listaDelete, findMany: listaFindMany, findFirst: listaFindFirst },
|
||||
listaVoce: { deleteMany: listaVoceDeleteMany },
|
||||
listaModello: { update: listaModelloUpdate },
|
||||
listaModelloVoce: { deleteMany: listaModelloVoceDeleteMany },
|
||||
listaSottoLista: { deleteMany: listaSottoListaDeleteMany, findMany: listaSottoListaFindMany },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -39,16 +36,15 @@ jest.mock('../../src/db/prisma', () => ({
|
||||
findMany: (...args: unknown[]) => listaFindMany(...args),
|
||||
findFirst: (...args: unknown[]) => listaFindFirst(...args),
|
||||
create: (...args: unknown[]) => listaCreate(...args),
|
||||
update: (...args: unknown[]) => listaUpdate(...args),
|
||||
delete: (...args: unknown[]) => listaDelete(...args),
|
||||
},
|
||||
listaVoce: {
|
||||
deleteMany: (...args: unknown[]) => listaVoceDeleteMany(...args),
|
||||
},
|
||||
listaModello: {
|
||||
findUnique: (...args: unknown[]) => listaModelloFindUnique(...args),
|
||||
update: (...args: unknown[]) => listaModelloUpdate(...args),
|
||||
},
|
||||
listaModelloVoce: {
|
||||
deleteMany: (...args: unknown[]) => listaModelloVoceDeleteMany(...args),
|
||||
listaSottoLista: {
|
||||
deleteMany: (...args: unknown[]) => listaSottoListaDeleteMany(...args),
|
||||
findMany: (...args: unknown[]) => listaSottoListaFindMany(...args),
|
||||
},
|
||||
$transaction: (...args: unknown[]) => transactionFn(...(args as [(tx: unknown) => unknown])),
|
||||
},
|
||||
@@ -68,14 +64,24 @@ function signToken(payload: object): string {
|
||||
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
|
||||
}
|
||||
|
||||
function tokenOrg(orgId: string): string {
|
||||
function tokenOrgUser(orgId: string, sub: string): string {
|
||||
return signToken({
|
||||
sub: 'user-1',
|
||||
sub,
|
||||
realm_access: { roles: ['censito'] },
|
||||
organization: { gruppo: { id: orgId, roles: [] } },
|
||||
});
|
||||
}
|
||||
|
||||
function tokenOrg(orgId: string): string {
|
||||
return tokenOrgUser(orgId, 'user-1');
|
||||
}
|
||||
|
||||
// Utente autenticato ma senza alcuna organizzazione attiva sul token: deve comunque
|
||||
// poter creare/gestire le proprie liste personali (bozza/privato/pubblico).
|
||||
function tokenSenzaOrg(sub = 'user-1'): string {
|
||||
return signToken({ sub, realm_access: { roles: ['censito'] } });
|
||||
}
|
||||
|
||||
function adminCatalogoToken(): string {
|
||||
return signToken({
|
||||
sub: 'admin-1',
|
||||
@@ -88,6 +94,30 @@ function materialeJoin(id: string, nome: string, unitaMisura: string) {
|
||||
return { id, nome, categoria: 'x', unitaMisura, stato: 'approvato', propostoDaOrgId: 'org-seed', creatoIl: new Date() };
|
||||
}
|
||||
|
||||
// Mirror dell'include usato da liste.repository.ts: le assert sulle chiamate a
|
||||
// prisma.lista.create/findFirst/findMany lo confrontano per intero.
|
||||
const INCLUDE_VOCI = {
|
||||
voci: { include: { materiale: true } },
|
||||
sottoListe: { include: { sottoLista: { include: { voci: { include: { materiale: true } } } } } },
|
||||
};
|
||||
|
||||
function listaVuota(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'l-1',
|
||||
nome: 'Lista',
|
||||
orgId: null,
|
||||
stato: 'bozza',
|
||||
statoModerazione: null,
|
||||
tipoEventoId: null,
|
||||
parentId: null,
|
||||
creataDaUserId: 'user-1',
|
||||
creataIl: new Date(),
|
||||
voci: [],
|
||||
sottoListe: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
|
||||
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
|
||||
@@ -103,24 +133,24 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('GET /liste', () => {
|
||||
test('restituisce solo le liste dell\'org corrente, ricavata dal token', async () => {
|
||||
test('utente con org: filtra su proprie liste (qualunque stato) + liste di gruppo della propria org', async () => {
|
||||
listaFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(listaFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { orgId: 'org-a' } }),
|
||||
expect.objectContaining({
|
||||
where: { OR: [{ creataDaUserId: 'user-1' }, { stato: 'gruppo', orgId: 'org-a' }] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('org diverse ottengono query filtrate su org_id diversi', async () => {
|
||||
listaFindMany.mockResolvedValue([]);
|
||||
test('utente senza org: filtra solo sulle proprie liste', async () => {
|
||||
listaFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
|
||||
await request(app).get('/liste').set('Authorization', `Bearer ${tokenSenzaOrg()}`);
|
||||
|
||||
expect(listaFindMany).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: { orgId: 'org-a' } }));
|
||||
expect(listaFindMany).toHaveBeenNthCalledWith(2, expect.objectContaining({ where: { orgId: 'org-b' } }));
|
||||
expect(listaFindMany).toHaveBeenCalledWith(expect.objectContaining({ where: { OR: [{ creataDaUserId: 'user-1' }] } }));
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
@@ -129,11 +159,90 @@ describe('GET /liste', () => {
|
||||
expect(response.status).toBe(401);
|
||||
expect(listaFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('marca inAttesaConferma solo per il creatore della lista, mai per altri membri dell\'org', async () => {
|
||||
listaFindMany.mockResolvedValueOnce([
|
||||
listaVuota({
|
||||
stato: 'gruppo',
|
||||
orgId: 'org-a',
|
||||
creataDaUserId: 'user-1',
|
||||
voci: [{ materialeId: 'm-1', quantita: 1, materiale: { ...materialeJoin('m-1', 'Tenda', 'pz'), stato: 'proposto' } }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const response = await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body[0].voci[0].inAttesaConferma).toBe(true);
|
||||
});
|
||||
|
||||
test('non marca inAttesaConferma per chi non ha creato la lista e non è moderatore', async () => {
|
||||
listaFindMany.mockResolvedValueOnce([
|
||||
listaVuota({
|
||||
stato: 'gruppo',
|
||||
orgId: 'org-a',
|
||||
creataDaUserId: 'un-altro-utente',
|
||||
voci: [{ materialeId: 'm-1', quantita: 1, materiale: { ...materialeJoin('m-1', 'Tenda', 'pz'), stato: 'proposto' } }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const response = await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body[0].voci[0].inAttesaConferma).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /liste/pubbliche', () => {
|
||||
test('nessun token richiesto: restituisce le liste pubbliche già approvate', async () => {
|
||||
listaFindMany.mockResolvedValueOnce([
|
||||
listaVuota({ id: 'l-pub', stato: 'pubblico', statoModerazione: 'approvato', creataDaUserId: 'chiunque' }),
|
||||
]);
|
||||
|
||||
const response = await request(app).get('/liste/pubbliche');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { stato: 'pubblico', statoModerazione: 'approvato' } }),
|
||||
);
|
||||
expect(response.body[0].id).toBe('l-pub');
|
||||
});
|
||||
|
||||
test('filtra per tipoEventoId quando richiesto', async () => {
|
||||
listaFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
await request(app).get('/liste/pubbliche').query({ tipoEventoId: 't-1' });
|
||||
|
||||
expect(listaFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { stato: 'pubblico', statoModerazione: 'approvato', tipoEventoId: 't-1' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /liste/pubbliche/:id', () => {
|
||||
test('restituisce la lista pubblica approvata (deep-link diretto al catalogo)', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-pub', stato: 'pubblico', statoModerazione: 'approvato' }));
|
||||
|
||||
const response = await request(app).get('/liste/pubbliche/l-pub');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'l-pub', stato: 'pubblico', statoModerazione: 'approvato' } }),
|
||||
);
|
||||
});
|
||||
|
||||
test('risponde 404 se non esiste o non è ancora approvata', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app).get('/liste/pubbliche/inesistente');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
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: [] });
|
||||
test('crea una lista bozza personale (senza orgId) ignorando un org_id inviato dal client, anche con org attiva', async () => {
|
||||
listaCreate.mockResolvedValueOnce(listaVuota({ nome: 'Lista vuota' }));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste')
|
||||
@@ -141,54 +250,279 @@ describe('POST /liste', () => {
|
||||
.send({ nome: 'Lista vuota', orgId: 'org-spoofed' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.orgId).toBeNull();
|
||||
expect(listaCreate).toHaveBeenCalledWith({
|
||||
data: { nome: 'Lista vuota', orgId: 'org-a', voci: { create: [] } },
|
||||
include: { voci: { include: { materiale: true } } },
|
||||
data: {
|
||||
nome: 'Lista vuota',
|
||||
orgId: null,
|
||||
creataDaUserId: 'user-1',
|
||||
stato: 'bozza',
|
||||
statoModerazione: null,
|
||||
tipoEventoId: null,
|
||||
parentId: null,
|
||||
voci: { create: [] },
|
||||
sottoListe: { create: [] },
|
||||
},
|
||||
include: INCLUDE_VOCI,
|
||||
});
|
||||
});
|
||||
|
||||
test('crea una lista pubblica anche senza organizzazione attiva sul token, in attesa di approvazione', async () => {
|
||||
listaCreate.mockResolvedValueOnce(listaVuota({ stato: 'pubblico', statoModerazione: 'proposto', orgId: null }));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste')
|
||||
.set('Authorization', `Bearer ${tokenSenzaOrg()}`)
|
||||
.send({ nome: 'Campo estivo', stato: 'pubblico', voci: [{ materialeId: 'm-1', quantita: 2 }] });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(listaCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
nome: 'Campo estivo',
|
||||
orgId: null,
|
||||
creataDaUserId: 'user-1',
|
||||
stato: 'pubblico',
|
||||
statoModerazione: 'proposto',
|
||||
tipoEventoId: null,
|
||||
parentId: null,
|
||||
voci: { create: [{ materialeId: 'm-1', quantita: 2 }] },
|
||||
sottoListe: { create: [] },
|
||||
},
|
||||
include: INCLUDE_VOCI,
|
||||
});
|
||||
});
|
||||
|
||||
test('un moderatore può creare direttamente una lista pubblica già approvata (bypass della coda)', async () => {
|
||||
listaCreate.mockResolvedValueOnce(listaVuota({ stato: 'pubblico', statoModerazione: 'approvato' }));
|
||||
|
||||
await request(app)
|
||||
.post('/liste')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ nome: 'Campo estivo', stato: 'pubblico' });
|
||||
|
||||
expect(listaCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ statoModerazione: 'approvato' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('risponde 400 se si crea una lista di gruppo senza organizzazione attiva', async () => {
|
||||
const response = await request(app)
|
||||
.post('/liste')
|
||||
.set('Authorization', `Bearer ${tokenSenzaOrg()}`)
|
||||
.send({ nome: 'Campo estivo', stato: 'gruppo' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(listaCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('crea una lista di gruppo con l\'org attiva sul token', async () => {
|
||||
listaCreate.mockResolvedValueOnce(listaVuota({ stato: 'gruppo', orgId: 'org-a' }));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Campo estivo', stato: 'gruppo' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(listaCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ orgId: 'org-a', stato: 'gruppo', statoModerazione: null }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('risponde 400 se lo stato indicato non è valido', async () => {
|
||||
const response = await request(app)
|
||||
.post('/liste')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Campo estivo', stato: 'inesistente' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(listaCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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') }],
|
||||
});
|
||||
describe('POST /liste — sotto-liste', () => {
|
||||
test('aggancia una lista personale propria come sotto-lista', async () => {
|
||||
listaSottoListaFindMany.mockResolvedValueOnce([]); // nessuna con proprie sotto-liste
|
||||
listaFindMany.mockResolvedValueOnce([{ id: 'l-esistente' }]); // visibile come candidata
|
||||
listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-nuova' }));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste/da-modello/lm-1')
|
||||
.post('/liste')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Campo estivo', stato: 'bozza', sottoListeIds: ['l-esistente'] });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(listaFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
id: { in: ['l-esistente'] },
|
||||
OR: [{ creataDaUserId: 'user-1' }, { stato: 'pubblico', statoModerazione: 'approvato' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(listaCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ sottoListe: { create: [{ sottoListaId: 'l-esistente' }] } }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('una lista personale (non di gruppo) non può agganciare una lista di gruppo come sotto-lista', async () => {
|
||||
// Il candidato è di gruppo: la query "visibili" con permettiGruppo=false non lo include mai,
|
||||
// quindi il mock restituisce vuoto per simulare "non trovato/non utilizzabile".
|
||||
listaFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Campo estivo', stato: 'bozza', sottoListeIds: ['l-gruppo-altrui'] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(listaCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('una lista di gruppo può agganciare sia una propria lista personale sia una lista di gruppo della stessa org', async () => {
|
||||
listaSottoListaFindMany.mockResolvedValueOnce([]);
|
||||
listaFindMany.mockResolvedValueOnce([{ id: 'l-personale' }, { id: 'l-gruppo' }]);
|
||||
listaCreate.mockResolvedValueOnce(listaVuota({ stato: 'gruppo', orgId: 'org-a' }));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Campo estivo', stato: 'gruppo', sottoListeIds: ['l-personale', 'l-gruppo'] });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(listaFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
id: { in: ['l-personale', 'l-gruppo'] },
|
||||
OR: [
|
||||
{ creataDaUserId: 'user-1' },
|
||||
{ stato: 'gruppo', orgId: 'org-a' },
|
||||
{ stato: 'pubblico', statoModerazione: 'approvato' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('rifiuta una sotto-lista che ha già proprie sotto-liste (nesting a due livelli)', async () => {
|
||||
listaFindMany.mockResolvedValueOnce([{ id: 'l-annidata' }]);
|
||||
listaSottoListaFindMany.mockResolvedValueOnce([{ listaId: 'l-annidata' }]);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Campo estivo', stato: 'bozza', sottoListeIds: ['l-annidata'] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(listaCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rifiuta l\'auto-riferimento su update', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1' }));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ sottoListeIds: ['l-1'] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(listaUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /liste/da-fork/:id', () => {
|
||||
test('copia nome/voci dalla lista pubblica di origine in una nuova lista bozza personale, salvando parentId', async () => {
|
||||
const vociSorgente = [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }];
|
||||
listaFindFirst.mockResolvedValueOnce({
|
||||
id: 'l-pub-1',
|
||||
nome: 'Kit campo estivo',
|
||||
orgId: null,
|
||||
stato: 'pubblico',
|
||||
statoModerazione: 'approvato',
|
||||
tipoEventoId: 't-1',
|
||||
parentId: null,
|
||||
creataDaUserId: 'autore-originale',
|
||||
creataIl: new Date(),
|
||||
voci: vociSorgente,
|
||||
sottoListe: [],
|
||||
});
|
||||
listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-2', nome: 'Kit campo estivo', parentId: 'l-pub-1' }));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste/da-fork/l-pub-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(JSON.stringify(response.body)).not.toMatch(/listaModello/i);
|
||||
expect(response.body.parentId).toBe('l-pub-1');
|
||||
expect(listaFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'l-pub-1', stato: 'pubblico', statoModerazione: 'approvato' } }),
|
||||
);
|
||||
|
||||
const callArg = listaCreate.mock.calls[0][0];
|
||||
expect(callArg.data).toEqual({
|
||||
nome: 'Kit campo estivo',
|
||||
orgId: 'org-a',
|
||||
orgId: null,
|
||||
creataDaUserId: 'user-1',
|
||||
stato: 'bozza',
|
||||
statoModerazione: null,
|
||||
tipoEventoId: 't-1',
|
||||
parentId: 'l-pub-1',
|
||||
voci: { create: [{ materialeId: 'm-1', quantita: 2 }] },
|
||||
sottoListe: { create: [] },
|
||||
});
|
||||
// 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);
|
||||
// stesso array (né gli stessi oggetti) restituiti dalla lista di origine.
|
||||
expect(callArg.data.voci.create).not.toBe(vociSorgente);
|
||||
});
|
||||
|
||||
test('risponde 404 se la lista modello non esiste', async () => {
|
||||
listaModelloFindUnique.mockResolvedValueOnce(null);
|
||||
test('include anche i materiali delle sotto-liste, sommando le quantità con quelli di primo livello', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce({
|
||||
id: 'l-pub-1',
|
||||
nome: 'Kit campo estivo',
|
||||
orgId: null,
|
||||
stato: 'pubblico',
|
||||
statoModerazione: 'approvato',
|
||||
tipoEventoId: 't-1',
|
||||
parentId: null,
|
||||
creataDaUserId: 'autore-originale',
|
||||
creataIl: new Date(),
|
||||
voci: [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }],
|
||||
sottoListe: [
|
||||
{
|
||||
sottoLista: {
|
||||
id: 'l-pub-2',
|
||||
nome: 'Kit pronto soccorso',
|
||||
voci: [
|
||||
{ materialeId: 'm-1', quantita: 1, materiale: materialeJoin('m-1', 'Tenda', 'pz') },
|
||||
{ materialeId: 'm-2', quantita: 3, materiale: materialeJoin('m-2', 'Garza', 'pz') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-2', nome: 'Kit campo estivo' }));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste/da-modello/inesistente')
|
||||
.post('/liste/da-fork/l-pub-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const callArg = listaCreate.mock.calls[0][0];
|
||||
expect(callArg.data.voci.create).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ materialeId: 'm-1', quantita: 3 },
|
||||
{ materialeId: 'm-2', quantita: 3 },
|
||||
]),
|
||||
);
|
||||
expect(callArg.data.voci.create).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('risponde 404 se la lista di origine non esiste o non è pubblica approvata', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/liste/da-fork/inesistente')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -196,33 +530,149 @@ describe('POST /liste/da-modello/:listaModelloId', () => {
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).post('/liste/da-modello/lm-1');
|
||||
const response = await request(app).post('/liste/da-fork/l-pub-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);
|
||||
describe('GET /liste/proposte', () => {
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).get('/liste/proposte');
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-b')}`)
|
||||
.send({ nome: 'Nome modificato' });
|
||||
expect(response.status).toBe(401);
|
||||
expect(listaFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(listaFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'l-1', orgId: 'org-b' } }),
|
||||
test('risponde 403 per un utente senza ruolo moderatore', async () => {
|
||||
const response = await request(app).get('/liste/proposte').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(listaFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('elenca le liste pubbliche in attesa di decisione, in ordine FIFO', async () => {
|
||||
listaFindMany.mockResolvedValueOnce([listaVuota({ id: 'l-p1', stato: 'pubblico', statoModerazione: 'proposto' })]);
|
||||
|
||||
const response = await request(app).get('/liste/proposte').set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { stato: 'pubblico', statoModerazione: 'proposto' },
|
||||
orderBy: { creataIl: 'asc' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /liste/proposte/:id', () => {
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).patch('/liste/proposte/l-1').send({ decisione: 'approvato' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
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: [] });
|
||||
test('risponde 403 per un utente senza ruolo moderatore', async () => {
|
||||
const response = await request(app)
|
||||
.patch('/liste/proposte/l-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ decisione: 'approvato' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(listaUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 404 se la proposta non esiste', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/liste/proposte/inesistente')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ decisione: 'approvato' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(listaUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 409 se la proposta è già stata decisa', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ stato: 'pubblico', statoModerazione: 'approvato' }));
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/liste/proposte/l-1')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ decisione: 'approvato' });
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(listaUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('approva una proposta', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'proposto' }));
|
||||
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'approvato' }));
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/liste/proposte/l-1')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ decisione: 'approvato' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'l-1' },
|
||||
data: { statoModerazione: 'approvato' },
|
||||
include: INCLUDE_VOCI,
|
||||
});
|
||||
});
|
||||
|
||||
test('rifiuta una proposta', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'proposto' }));
|
||||
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'rifiutato' }));
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/liste/proposte/l-1')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
|
||||
.send({ decisione: 'rifiutato' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { statoModerazione: 'rifiutato' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /liste/:id — ownership', () => {
|
||||
test('non si può modificare la lista personale di un altro utente (risponde 404, non 403, per non rivelarne l\'esistenza)', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', creataDaUserId: 'un-altro-utente' }));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Nome modificato' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(listaFindFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'l-1' } }));
|
||||
expect(listaUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('non si può modificare una lista di gruppo di un\'altra org', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(
|
||||
listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-b', creataDaUserId: 'un-altro-utente' }),
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Nome modificato' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(listaUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('il creatore può modificare la propria lista personale', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', nome: 'Vecchio nome' }));
|
||||
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', nome: 'Nuovo nome' }));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
@@ -235,80 +685,179 @@ describe('PUT /liste/:id — isolamento tra org', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('un membro dell\'org (non il creatore) può modificare una lista di gruppo della propria org', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(
|
||||
listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', creataDaUserId: 'un-altro-utente' }),
|
||||
);
|
||||
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', nome: 'Nuovo nome' }));
|
||||
|
||||
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).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
test('un membro dell\'org (non il creatore) non può cambiare lo stato di una lista di gruppo altrui', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(
|
||||
listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', creataDaUserId: 'un-altro-utente' }),
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ stato: 'privato' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(listaUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un membro dell\'org (non il creatore) può modificare voci/nome di una lista di gruppo senza cambiarne lo stato', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(
|
||||
listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', creataDaUserId: 'un-altro-utente' }),
|
||||
);
|
||||
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', nome: 'Nuovo nome' }));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Nuovo nome', stato: 'gruppo' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('il creatore può cambiare lo stato della propria lista di gruppo', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', creataDaUserId: 'user-1' }));
|
||||
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'privato', orgId: null }));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ stato: 'privato' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /liste/:id — isolamento tra org', () => {
|
||||
test('un\'org non può eliminare una lista di un\'altra org', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(null);
|
||||
describe('PUT /liste/:id — promozione a pubblico e ricalcolo della moderazione', () => {
|
||||
test('promuovere una bozza a pubblico forza statoModerazione a "proposto"', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'bozza', statoModerazione: null }));
|
||||
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'proposto' }));
|
||||
|
||||
const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
.set('Authorization', `Bearer ${tokenSenzaOrg()}`)
|
||||
.send({ stato: 'pubblico' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ stato: 'pubblico', statoModerazione: 'proposto' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('un update banale (solo nome) su una lista già pubblica non resetta una moderazione già decisa', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'approvato' }));
|
||||
listaUpdate.mockResolvedValueOnce(
|
||||
listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'approvato', nome: 'Nuovo nome' }),
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
.set('Authorization', `Bearer ${tokenSenzaOrg()}`)
|
||||
.send({ nome: 'Nuovo nome' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ statoModerazione: 'approvato' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('modificare le voci di una lista già pubblica riporta statoModerazione a "proposto"', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'approvato' }));
|
||||
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'proposto' }));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-1')
|
||||
.set('Authorization', `Bearer ${tokenSenzaOrg()}`)
|
||||
.send({ voci: [] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ statoModerazione: 'proposto' }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /liste/:id — ownership', () => {
|
||||
test('non si può eliminare la lista personale di un altro utente', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', creataDaUserId: 'un-altro-utente' }));
|
||||
|
||||
const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
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: [] });
|
||||
test('il creatore può eliminare la propria lista', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1' }));
|
||||
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(listaSottoListaDeleteMany).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',
|
||||
describe('fork: lineage e indipendenza tra lista di origine e lista forkata', () => {
|
||||
test('la lista forkata ha parentId popolato verso la lista pubblica di origine', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce({
|
||||
id: 'l-pub-1',
|
||||
nome: 'Kit',
|
||||
orgId: null,
|
||||
stato: 'pubblico',
|
||||
statoModerazione: 'approvato',
|
||||
tipoEventoId: null,
|
||||
parentId: null,
|
||||
creataDaUserId: 'altro-utente',
|
||||
creataIl: new Date(),
|
||||
voci: [],
|
||||
sottoListe: [],
|
||||
});
|
||||
listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-figlia', parentId: 'l-pub-1' }));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-2')
|
||||
.post('/liste/da-fork/l-pub-1')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.parentId).toBe('l-pub-1');
|
||||
});
|
||||
|
||||
test('modificare la lista figlia tocca solo la sua riga: nessuna scrittura sulla lista di origine', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-figlia', parentId: 'l-pub-1' }));
|
||||
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-figlia', parentId: 'l-pub-1', nome: 'Personalizzata' }));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/liste/l-figlia')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Kit campo estivo (personalizzato)', voci: [] });
|
||||
.send({ nome: 'Personalizzata', voci: [] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(listaVoceDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-2' } });
|
||||
expect(listaUpdate).toHaveBeenCalled();
|
||||
expect(listaModelloVoceDeleteMany).not.toHaveBeenCalled();
|
||||
expect(listaModelloUpdate).not.toHaveBeenCalled();
|
||||
expect(listaUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(listaUpdate).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'l-figlia' } }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ 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 tipoEventoFindUnique = jest.fn();
|
||||
const tipoEventoCreate = jest.fn();
|
||||
const tipoEventoUpdate = jest.fn();
|
||||
const tipoEventoDelete = jest.fn();
|
||||
@@ -18,6 +19,7 @@ jest.mock('../../src/db/prisma', () => ({
|
||||
prisma: {
|
||||
tipoEvento: {
|
||||
findMany: (...args: unknown[]) => tipoEventoFindMany(...args),
|
||||
findUnique: (...args: unknown[]) => tipoEventoFindUnique(...args),
|
||||
create: (...args: unknown[]) => tipoEventoCreate(...args),
|
||||
update: (...args: unknown[]) => tipoEventoUpdate(...args),
|
||||
delete: (...args: unknown[]) => tipoEventoDelete(...args),
|
||||
@@ -39,11 +41,11 @@ function signToken(payload: object): string {
|
||||
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
|
||||
}
|
||||
|
||||
function utenteToken(): string {
|
||||
function utenteToken(orgId = 'org-1'): string {
|
||||
return signToken({
|
||||
sub: 'user-1',
|
||||
realm_access: { roles: ['censito'] },
|
||||
organization: { 'gruppo-alfa': { id: 'org-1', roles: [] } },
|
||||
organization: { 'gruppo-alfa': { id: orgId, roles: [] } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,13 +72,49 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('GET /tipi-evento', () => {
|
||||
test('è pubblico e restituisce la lista', async () => {
|
||||
tipoEventoFindMany.mockResolvedValueOnce([{ id: 't-1', nome: 'Campo estivo' }]);
|
||||
test('è pubblico e restituisce solo i tipi evento confermati', async () => {
|
||||
tipoEventoFindMany.mockResolvedValueOnce([{ id: 't-1', nome: 'Campo estivo', stato: 'confermata' }]);
|
||||
|
||||
const response = await request(app).get('/tipi-evento');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([{ id: 't-1', nome: 'Campo estivo' }]);
|
||||
expect(response.body).toEqual([{ id: 't-1', nome: 'Campo estivo', stato: 'confermata' }]);
|
||||
expect(tipoEventoFindMany).toHaveBeenCalledWith({
|
||||
where: { stato: 'confermata' },
|
||||
orderBy: { nome: 'asc' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /tipi-evento/moderazione', () => {
|
||||
test('un utente normale non può accedere', async () => {
|
||||
const response = await request(app)
|
||||
.get('/tipi-evento/moderazione')
|
||||
.set('Authorization', `Bearer ${utenteToken()}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(tipoEventoFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un moderatore vede tutti i tipi evento, incluse le proposte', async () => {
|
||||
tipoEventoFindMany.mockResolvedValueOnce([
|
||||
{ id: 't-1', nome: 'Campo estivo', stato: 'confermata' },
|
||||
{ id: 't-2', nome: 'Bivacco', stato: 'da_approvare' },
|
||||
]);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/tipi-evento/moderazione')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).get('/tipi-evento/moderazione');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(tipoEventoFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,8 +129,8 @@ describe('POST /tipi-evento', () => {
|
||||
expect(tipoEventoCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('un moderatore può creare un tipo evento', async () => {
|
||||
tipoEventoCreate.mockResolvedValueOnce({ id: 't-2', nome: 'Bivacco' });
|
||||
test('un moderatore crea un tipo evento già confermato', async () => {
|
||||
tipoEventoCreate.mockResolvedValueOnce({ id: 't-2', nome: 'Bivacco', stato: 'confermata' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/tipi-evento')
|
||||
@@ -100,7 +138,7 @@ describe('POST /tipi-evento', () => {
|
||||
.send({ nome: 'Bivacco' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(tipoEventoCreate).toHaveBeenCalledWith({ data: { nome: 'Bivacco' } });
|
||||
expect(tipoEventoCreate).toHaveBeenCalledWith({ data: { nome: 'Bivacco', stato: 'confermata' } });
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
@@ -111,6 +149,35 @@ describe('POST /tipi-evento', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /tipi-evento/proposte', () => {
|
||||
test("un utente autenticato con un'org propone un tipo evento, che nasce 'da_approvare'", async () => {
|
||||
tipoEventoCreate.mockResolvedValueOnce({
|
||||
id: 't-3',
|
||||
nome: 'Uscita notturna',
|
||||
stato: 'da_approvare',
|
||||
creatoDaOrgId: 'org-1',
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/tipi-evento/proposte')
|
||||
.set('Authorization', `Bearer ${utenteToken('org-1')}`)
|
||||
.send({ nome: 'Uscita notturna' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toMatchObject({ stato: 'da_approvare', creatoDaOrgId: 'org-1' });
|
||||
expect(tipoEventoCreate).toHaveBeenCalledWith({
|
||||
data: { nome: 'Uscita notturna', stato: 'da_approvare', creatoDaOrgId: 'org-1' },
|
||||
});
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).post('/tipi-evento/proposte').send({ nome: 'Uscita notturna' });
|
||||
|
||||
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)
|
||||
@@ -159,3 +226,65 @@ describe('DELETE /tipi-evento/:id', () => {
|
||||
expect(tipoEventoDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /tipi-evento/:id/approva', () => {
|
||||
test('un moderatore può approvare un tipo evento proposto', async () => {
|
||||
tipoEventoFindUnique.mockResolvedValueOnce({ id: 't-3', nome: 'Uscita notturna', stato: 'da_approvare' });
|
||||
tipoEventoUpdate.mockResolvedValueOnce({ id: 't-3', nome: 'Uscita notturna', stato: 'confermata' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/tipi-evento/t-3/approva')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.stato).toBe('confermata');
|
||||
expect(tipoEventoUpdate).toHaveBeenCalledWith({ where: { id: 't-3' }, data: { stato: 'confermata' } });
|
||||
});
|
||||
|
||||
test('risponde 409 se il tipo evento è già confermato', async () => {
|
||||
tipoEventoFindUnique.mockResolvedValueOnce({ id: 't-1', nome: 'Campo estivo', stato: 'confermata' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/tipi-evento/t-1/approva')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(tipoEventoUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 404 se il tipo evento non esiste', async () => {
|
||||
tipoEventoFindUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/tipi-evento/inesistente/approva')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(tipoEventoUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /tipi-evento/:id/rifiuta', () => {
|
||||
test('un moderatore può rifiutare un tipo evento proposto, che viene eliminato', async () => {
|
||||
tipoEventoFindUnique.mockResolvedValueOnce({ id: 't-3', nome: 'Uscita notturna', stato: 'da_approvare' });
|
||||
tipoEventoDelete.mockResolvedValueOnce({ id: 't-3' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/tipi-evento/t-3/rifiuta')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(tipoEventoDelete).toHaveBeenCalledWith({ where: { id: 't-3' } });
|
||||
});
|
||||
|
||||
test('risponde 409 se il tipo evento è già confermato', async () => {
|
||||
tipoEventoFindUnique.mockResolvedValueOnce({ id: 't-1', nome: 'Campo estivo', stato: 'confermata' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/tipi-evento/t-1/rifiuta')
|
||||
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(tipoEventoDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user