Fix magazzino
This commit is contained in:
@@ -7,7 +7,6 @@ import { tipiEventoRouter } from './routes/tipiEvento.routes';
|
||||
import { listeRouter } from './routes/liste.routes';
|
||||
import { magazzinoRouter } from './routes/magazzino.routes';
|
||||
import { gruppiMagazzinoRouter } from './routes/gruppiMagazzino.routes';
|
||||
import { eventiRouter } from './routes/eventi.routes';
|
||||
import { autocompleteRouter } from './routes/autocomplete.routes';
|
||||
import { notificheRouter } from './routes/notifiche.routes';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
@@ -27,7 +26,6 @@ app.use(tipiEventoRouter);
|
||||
app.use(listeRouter);
|
||||
app.use(magazzinoRouter);
|
||||
app.use(gruppiMagazzinoRouter);
|
||||
app.use(eventiRouter);
|
||||
app.use(autocompleteRouter);
|
||||
app.use(notificheRouter);
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AggiornaCheckInput, aggiornaCheckEvento, creaEvento, getDettaglioEvento } from '../services/eventi.service';
|
||||
import { HttpError } from '../errors';
|
||||
|
||||
interface PostEventoBody {
|
||||
nome?: unknown;
|
||||
listaId?: unknown;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
function parseData(value: unknown): Date {
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, "Il campo 'data' è obbligatorio ed è una stringa in formato data");
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new HttpError(400, "Il campo 'data' non è una data valida");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseCreateBody(body: PostEventoBody): { nome: string; listaId: string; data: Date } {
|
||||
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
|
||||
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
|
||||
}
|
||||
if (typeof body.listaId !== 'string' || body.listaId.trim().length === 0) {
|
||||
throw new HttpError(400, "Il campo 'listaId' è obbligatorio ed è una stringa non vuota");
|
||||
}
|
||||
|
||||
return { nome: body.nome, listaId: body.listaId, data: parseData(body.data) };
|
||||
}
|
||||
|
||||
export async function postEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const input = parseCreateBody(req.body ?? {});
|
||||
const evento = await creaEvento(req.auth!.orgId!, input);
|
||||
res.status(201).json(evento);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const evento = await getDettaglioEvento(req.params.id, req.auth!.orgId!);
|
||||
res.status(200).json(evento);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
interface CheckVoceBody {
|
||||
materialeId?: unknown;
|
||||
portato?: unknown;
|
||||
note?: unknown;
|
||||
}
|
||||
|
||||
interface PatchCheckBody {
|
||||
voci?: unknown;
|
||||
}
|
||||
|
||||
function parseCheckBody(body: PatchCheckBody): AggiornaCheckInput[] {
|
||||
if (!Array.isArray(body.voci) || body.voci.length === 0) {
|
||||
throw new HttpError(400, "Il campo 'voci' è obbligatorio ed è un array non vuoto");
|
||||
}
|
||||
|
||||
return body.voci.map((voce: CheckVoceBody) => {
|
||||
if (typeof voce.materialeId !== 'string' || voce.materialeId.trim().length === 0) {
|
||||
throw new HttpError(400, "Ogni voce deve avere un 'materialeId' valido");
|
||||
}
|
||||
if (voce.portato !== undefined && typeof voce.portato !== 'boolean') {
|
||||
throw new HttpError(400, "Il campo 'portato', se presente, deve essere un booleano");
|
||||
}
|
||||
if (voce.note !== undefined && voce.note !== null && typeof voce.note !== 'string') {
|
||||
throw new HttpError(400, "Il campo 'note', se presente, deve essere una stringa o null");
|
||||
}
|
||||
|
||||
return {
|
||||
materialeId: voce.materialeId,
|
||||
portato: voce.portato as boolean | undefined,
|
||||
note: voce.note as string | null | undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchEventoCheck(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const voci = parseCheckBody(req.body ?? {});
|
||||
const evento = await aggiornaCheckEvento(req.params.id, req.auth!.orgId!, voci);
|
||||
res.status(200).json(evento);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
const includeEvento = {
|
||||
lista: { include: { voci: { include: { materiale: true } } } },
|
||||
check: true,
|
||||
} satisfies Prisma.EventoInclude;
|
||||
|
||||
export type EventoConDettagli = Prisma.EventoGetPayload<{ include: typeof includeEvento }>;
|
||||
|
||||
export interface CreateEventoData {
|
||||
orgId: string;
|
||||
nome: string;
|
||||
listaId: string;
|
||||
data: Date;
|
||||
}
|
||||
|
||||
export interface UpsertCheckData {
|
||||
portato?: boolean;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export class EventiRepository {
|
||||
// id + orgId nella stessa where: un evento di un'altra org risulta
|
||||
// semplicemente "non trovato", mai un 403 che ne rivela l'esistenza.
|
||||
findByIdAndOrg(id: string, orgId: string): Promise<EventoConDettagli | null> {
|
||||
return prisma.evento.findFirst({ where: { id, orgId }, include: includeEvento });
|
||||
}
|
||||
|
||||
create(data: CreateEventoData): Promise<EventoConDettagli> {
|
||||
return prisma.evento.create({ data, include: includeEvento });
|
||||
}
|
||||
|
||||
upsertCheck(eventoId: string, materialeId: string, data: UpsertCheckData): Promise<void> {
|
||||
return prisma.eventoCheck
|
||||
.upsert({
|
||||
where: { eventoId_materialeId: { eventoId, materialeId } },
|
||||
create: { eventoId, materialeId, portato: data.portato ?? false, note: data.note ?? null },
|
||||
update: {
|
||||
...(data.portato !== undefined ? { portato: data.portato } : {}),
|
||||
...(data.note !== undefined ? { note: data.note } : {}),
|
||||
},
|
||||
})
|
||||
.then(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export const eventiRepository = new EventiRepository();
|
||||
@@ -113,13 +113,6 @@ export class ListeRepository {
|
||||
return prisma.lista.findFirst({ where: { id }, include: includeVoci });
|
||||
}
|
||||
|
||||
// Usato solo da eventi.service.ts (un Evento, sempre org-scoped, può collegarsi solo
|
||||
// a una lista 'gruppo' della propria org: le altre liste hanno orgId null e non
|
||||
// potranno mai combaciare). Non toccare la firma: è un contratto tra i due moduli.
|
||||
findByIdAndOrg(id: string, orgId: string): Promise<ListaConVoci | null> {
|
||||
return prisma.lista.findFirst({ where: { id, orgId }, include: includeVoci });
|
||||
}
|
||||
|
||||
create(data: CreateListaData, db: Db = prisma): Promise<ListaConVoci> {
|
||||
return db.lista.create({
|
||||
data: {
|
||||
|
||||
@@ -26,11 +26,6 @@ export interface UpdateMagazzinoVoceData {
|
||||
gruppoId?: string | null;
|
||||
}
|
||||
|
||||
export interface QuantitaPosseduta {
|
||||
materialeId: string;
|
||||
quantitaPosseduta: number;
|
||||
}
|
||||
|
||||
export class MagazzinoRepository {
|
||||
findAllByOrg(orgId: string): Promise<MagazzinoVoceConMateriale[]> {
|
||||
return prisma.magazzinoVoce.findMany({
|
||||
@@ -57,15 +52,6 @@ export class MagazzinoRepository {
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.magazzinoVoce.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// Usato per il join evento<->magazzino: quantità possedute dall'org per un
|
||||
// sottoinsieme di materiali (quelli della lista collegata all'evento).
|
||||
findQuantitaByOrgEMateriali(orgId: string, materialeIds: string[]): Promise<QuantitaPosseduta[]> {
|
||||
return prisma.magazzinoVoce.findMany({
|
||||
where: { orgId, materialeId: { in: materialeIds } },
|
||||
select: { materialeId: true, quantitaPosseduta: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const magazzinoRepository = new MagazzinoRepository();
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { verifyToken } from '../auth/verify-token.middleware';
|
||||
import { requireOrgId } from '../auth/require-org-id.middleware';
|
||||
import { getEvento, patchEventoCheck, postEvento } from '../controllers/eventi.controller';
|
||||
|
||||
export const eventiRouter = Router();
|
||||
|
||||
eventiRouter.post('/eventi', verifyToken, requireOrgId, postEvento);
|
||||
eventiRouter.get('/eventi/:id', verifyToken, requireOrgId, getEvento);
|
||||
eventiRouter.patch('/eventi/:id/check', verifyToken, requireOrgId, patchEventoCheck);
|
||||
@@ -1,109 +0,0 @@
|
||||
import { EventoConDettagli, eventiRepository } from '../repositories/eventi.repository';
|
||||
import { listeRepository } from '../repositories/liste.repository';
|
||||
import { magazzinoRepository } from '../repositories/magazzino.repository';
|
||||
import { HttpError } from '../errors';
|
||||
|
||||
export interface EventoVoceView {
|
||||
materialeId: string;
|
||||
nome: string;
|
||||
unitaMisura: string;
|
||||
quantitaRichiesta: number;
|
||||
quantitaPosseduta: number;
|
||||
portato: boolean;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface EventoDettaglioView {
|
||||
id: string;
|
||||
orgId: string;
|
||||
nome: string;
|
||||
listaId: string;
|
||||
data: Date;
|
||||
voci: EventoVoceView[];
|
||||
}
|
||||
|
||||
// Join fra le voci della lista collegata all'evento e il magazzino dell'org:
|
||||
// per ogni materiale della lista, quanto ne possiede l'org (0 se non tracciato)
|
||||
// e lo stato di check (di default "non portato", nessuna nota) finché non
|
||||
// viene aggiornato via PATCH /eventi/:id/check.
|
||||
async function buildDettaglioView(evento: EventoConDettagli): Promise<EventoDettaglioView> {
|
||||
const materialeIds = evento.lista.voci.map((v) => v.materialeId);
|
||||
const quantitaPossedute =
|
||||
materialeIds.length > 0 ? await magazzinoRepository.findQuantitaByOrgEMateriali(evento.orgId, materialeIds) : [];
|
||||
const magazzinoByMateriale = new Map(quantitaPossedute.map((m) => [m.materialeId, m.quantitaPosseduta]));
|
||||
const checkByMateriale = new Map(evento.check.map((c) => [c.materialeId, c]));
|
||||
|
||||
return {
|
||||
id: evento.id,
|
||||
orgId: evento.orgId,
|
||||
nome: evento.nome,
|
||||
listaId: evento.listaId,
|
||||
data: evento.data,
|
||||
voci: evento.lista.voci.map((v) => {
|
||||
const check = checkByMateriale.get(v.materialeId);
|
||||
return {
|
||||
materialeId: v.materialeId,
|
||||
nome: v.materiale.nome,
|
||||
unitaMisura: v.materiale.unitaMisura,
|
||||
quantitaRichiesta: v.quantita,
|
||||
quantitaPosseduta: magazzinoByMateriale.get(v.materialeId) ?? 0,
|
||||
portato: check?.portato ?? false,
|
||||
note: check?.note ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreaEventoInput {
|
||||
nome: string;
|
||||
listaId: string;
|
||||
data: Date;
|
||||
}
|
||||
|
||||
export async function creaEvento(orgId: string, input: CreaEventoInput): Promise<EventoDettaglioView> {
|
||||
const lista = await listeRepository.findByIdAndOrg(input.listaId, orgId);
|
||||
if (!lista) {
|
||||
throw new HttpError(400, 'La lista indicata non esiste o non appartiene alla tua organizzazione');
|
||||
}
|
||||
|
||||
const evento = await eventiRepository.create({ orgId, nome: input.nome, listaId: input.listaId, data: input.data });
|
||||
return buildDettaglioView(evento);
|
||||
}
|
||||
|
||||
export async function getDettaglioEvento(id: string, orgId: string): Promise<EventoDettaglioView> {
|
||||
const evento = await eventiRepository.findByIdAndOrg(id, orgId);
|
||||
if (!evento) {
|
||||
throw new HttpError(404, 'Evento non trovato');
|
||||
}
|
||||
return buildDettaglioView(evento);
|
||||
}
|
||||
|
||||
export interface AggiornaCheckInput {
|
||||
materialeId: string;
|
||||
portato?: boolean;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export async function aggiornaCheckEvento(
|
||||
id: string,
|
||||
orgId: string,
|
||||
voci: AggiornaCheckInput[],
|
||||
): Promise<EventoDettaglioView> {
|
||||
const evento = await eventiRepository.findByIdAndOrg(id, orgId);
|
||||
if (!evento) {
|
||||
throw new HttpError(404, 'Evento non trovato');
|
||||
}
|
||||
|
||||
const materialiDellaLista = new Set(evento.lista.voci.map((v) => v.materialeId));
|
||||
for (const voce of voci) {
|
||||
if (!materialiDellaLista.has(voce.materialeId)) {
|
||||
throw new HttpError(400, `Il materiale ${voce.materialeId} non fa parte della lista collegata a questo evento`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const voce of voci) {
|
||||
await eventiRepository.upsertCheck(id, voce.materialeId, { portato: voce.portato, note: voce.note });
|
||||
}
|
||||
|
||||
return getDettaglioEvento(id, orgId);
|
||||
}
|
||||
@@ -1,254 +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 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user