diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 5823a5715..1793dcd36 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13600,57 +13600,59 @@ paths: content: application/json: schema: - type: object - x-examples: - Example 1: - description: Riparazione hardware ufficio - type_id: 3 - supplier_id: 105 - cost: 250.5 + type: array + items: + type: object + x-examples: + Example 1: + description: Riparazione hardware ufficio + type_id: 3 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg + required: + - description + - type_id + - supplier_id + - cost + - attachments + properties: + description: + type: string + type_id: + type: integer + supplier_id: + type: integer + cost: + type: number attachments: - - url: 'https://esempio.com/documenti/fattura.pdf' - mime_type: application/pdf - - url: 'https://esempio.com/immagini/danno.jpg' - mime_type: image/jpeg - required: - - description - - type_id - - supplier_id - - cost - - attachments - properties: - description: - type: string - type_id: - type: integer - supplier_id: - type: integer - cost: - type: number - attachments: - type: array - items: - type: object - required: - - url - - mime_type - properties: - url: - type: string - mime_type: - type: string + type: array + items: + type: object + required: + - url + - mime_type + properties: + url: + type: string + mime_type: + type: string examples: Example 1: value: - description: Riparazione hardware ufficio - type_id: 3 - supplier_id: 105 - cost: 250.5 - attachments: - - url: 'https://esempio.com/documenti/fattura.pdf' - mime_type: application/pdf - - url: 'https://esempio.com/immagini/danno.jpg' - mime_type: image/jpeg + - description: Riparazione hardware ufficio + type_id: 3 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg security: - JWT: [] delete: @@ -13697,12 +13699,90 @@ paths: content: application/json: schema: + type: array + items: + type: object + x-examples: + Example 1: + description: Riparazione hardware ufficio + type_id: 3 + cost_id: 2 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg + required: + - description + - type + - cost_id + - supplier + - cost + - attachments + properties: + description: + type: string + type: + type: string + x-stoplight: + id: q54ltj77jcyf0 + cost_id: + type: integer + supplier: + type: string + x-stoplight: + id: 5aunsjh1dxfq1 + cost: + type: number + attachments: + type: array + items: + type: object + required: + - url + - mime_type + properties: + url: + type: string + mime_type: + type: string + examples: + Example 1: + value: + - description: description + type: Type 1 + cost_id: 10 + supplier: Supplier + cost: 104 + attachments: + - url: 'https://example.com/' + mime_type: image/jpg + '400': + description: Bad Request + '403': + description: Forbidden + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Internal Server Error + operationId: patch-campaigns-campaign-finance-otherCosts + x-stoplight: + id: mwhcb91voxivy + security: + - JWT: [] + requestBody: + content: + application/json: + schema: + type: array + items: type: object x-examples: Example 1: description: Riparazione hardware ufficio type_id: 3 - cost_id: 2 supplier_id: 105 cost: 250.5 attachments: @@ -13712,24 +13792,18 @@ paths: mime_type: image/jpeg required: - description - - type - - cost_id - - supplier + - type_id + - supplier_id - cost - attachments + - cost_id properties: description: type: string - type: - type: string - x-stoplight: - id: q54ltj77jcyf0 - cost_id: + type_id: + type: integer + supplier_id: type: integer - supplier: - type: string - x-stoplight: - id: 5aunsjh1dxfq1 cost: type: number attachments: @@ -13744,91 +13818,23 @@ paths: type: string mime_type: type: string - examples: - Example 1: - value: - description: description - type: Type 1 - cost_id: 10 - supplier: Supplier - cost: 104 - attachments: - - url: 'https://example.com/' - mime_type: image/jpg - '400': - description: Bad Request - '403': - description: Forbidden - '404': - $ref: '#/components/responses/NotFound' - '500': - description: Internal Server Error - operationId: patch-campaigns-campaign-finance-otherCosts - x-stoplight: - id: mwhcb91voxivy - security: - - JWT: [] - requestBody: - content: - application/json: - schema: - type: object - x-examples: - Example 1: - description: Riparazione hardware ufficio - type_id: 3 - supplier_id: 105 - cost: 250.5 - attachments: - - url: 'https://esempio.com/documenti/fattura.pdf' - mime_type: application/pdf - - url: 'https://esempio.com/immagini/danno.jpg' - mime_type: image/jpeg - required: - - description - - type_id - - supplier_id - - cost - - attachments - - cost_id - properties: - description: - type: string - type_id: - type: integer - supplier_id: - type: integer - cost: - type: number - attachments: - type: array - items: - type: object - required: - - url - - mime_type - properties: - url: - type: string - mime_type: - type: string - cost_id: - type: integer - x-stoplight: - id: drnv0dayw8k18 + cost_id: + type: integer + x-stoplight: + id: drnv0dayw8k18 examples: Example 1: value: - description: Riparazione hardware ufficio - type_id: 3 - cost_id: 2 - supplier_id: 105 - cost: 250.5 - attachments: - - url: 'https://esempio.com/documenti/fattura.pdf' - mime_type: application/pdf - - url: 'https://esempio.com/immagini/danno.jpg' - mime_type: image/jpeg + - description: Riparazione hardware ufficio + type_id: 3 + cost_id: 2 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg servers: - url: 'https://api.app-quality.com' tags: diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts index 56bb2b697..7df3e00a0 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts @@ -108,23 +108,25 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { await tryber.tables.WpAppqEvdProfile.do().delete(); }); - const validPayload = { - description: "Riparazione hardware ufficio", - type_id: 3, - cost_id: 1, - supplier_id: 105, - cost: 250.5, - attachments: [ - { - url: "https://esempio.com/documenti/fattura.pdf", - mime_type: "application/pdf", - }, - { - url: "https://esempio.com/immagini/danno.jpg", - mime_type: "image/jpeg", - }, - ], - }; + const validPayload = [ + { + description: "Riparazione hardware ufficio", + type_id: 3, + cost_id: 1, + supplier_id: 105, + cost: 250.5, + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }, + ], + }, + ]; describe("Authentication and Authorization", () => { beforeEach(async () => { @@ -190,12 +192,32 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }); }); + it("Should return 400 if body is not an array", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload[0]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if array is empty", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.message).toBe( + "Body must be a non-empty array of cost items" + ); + }); + it("Should return 400 if cost_id is missing", async () => { - const { cost_id, ...payload } = validPayload; + const { cost_id, ...item } = validPayload[0]; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send(payload) + .send([item]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); @@ -204,7 +226,7 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if cost_id is null", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost_id: null }) + .send([{ ...validPayload[0], cost_id: null }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); @@ -213,12 +235,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if cost_id is zero", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost_id: 0 }) + .send([{ ...validPayload[0], cost_id: 0 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "cost_id must be a positive number", + message: "Item 1: cost_id must be a positive number", }) ); }); @@ -226,66 +248,66 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if cost_id is negative", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost_id: -1 }) + .send([{ ...validPayload[0], cost_id: -1 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "cost_id must be a positive number", + message: "Item 1: cost_id must be a positive number", }) ); }); it("Should return 400 if description is missing", async () => { - const { description, ...payload } = validPayload; + const { description, ...item } = validPayload[0]; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send(payload) + .send([item]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); }); it("Should return 400 if type_id is missing", async () => { - const { type_id, ...payload } = validPayload; + const { type_id, ...item } = validPayload[0]; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send(payload) + .send([item]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); }); it("Should return 400 if supplier_id is missing", async () => { - const { supplier_id, ...payload } = validPayload; + const { supplier_id, ...item } = validPayload[0]; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send(payload) + .send([item]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); }); it("Should return 400 if cost is missing", async () => { - const { cost, ...payload } = validPayload; + const { cost, ...item } = validPayload[0]; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send(payload) + .send([item]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); }); it("Should return 400 if attachments is missing", async () => { - const { attachments, ...payload } = validPayload; + const { attachments, ...item } = validPayload[0]; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send(payload) + .send([item]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); @@ -294,19 +316,23 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if attachments is an empty array", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, attachments: [] }) + .send([{ ...validPayload[0], attachments: [] }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); - expect(response.body.message).toBe("At least one attachment is required"); + expect(response.body.message).toBe( + "Item 1: At least one attachment is required" + ); }); it("Should return 400 if attachments array item is missing url", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - attachments: [{ mime_type: "application/pdf" }], - }) + .send([ + { + ...validPayload[0], + attachments: [{ mime_type: "application/pdf" }], + }, + ]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); @@ -315,10 +341,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if attachments array item is missing mime_type", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - attachments: [{ url: "https://example.com/file.pdf" }], - }) + .send([ + { + ...validPayload[0], + attachments: [{ url: "https://example.com/file.pdf" }], + }, + ]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); @@ -329,12 +357,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { it("Should return 404 if cost_id does not exist", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost_id: 999 }) + .send([{ ...validPayload[0], cost_id: 999 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(404); expect(response.body).toEqual( expect.objectContaining({ - message: "Cost not found for this campaign", + message: "Item 1: Cost not found for this campaign", }) ); }); @@ -351,12 +379,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost_id: 10 }) + .send([{ ...validPayload[0], cost_id: 10 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(404); expect(response.body).toEqual( expect.objectContaining({ - message: "Cost not found for this campaign", + message: "Item 1: Cost not found for this campaign", }) ); }); @@ -373,12 +401,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, type_id: 999 }) + .send([{ ...validPayload[0], type_id: 999 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(404); expect(response.body).toEqual( expect.objectContaining({ - message: "Type not found", + message: "Item 1: Type not found", }) ); }); @@ -395,12 +423,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, supplier_id: 999 }) + .send([{ ...validPayload[0], supplier_id: 999 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(404); expect(response.body).toEqual( expect.objectContaining({ - message: "Supplier not found", + message: "Item 1: Supplier not found", }) ); }); @@ -422,6 +450,15 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { .send(validPayload) .set("Authorization", "Bearer admin"); expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + expect(response.body[0]).toEqual( + expect.objectContaining({ + cost_id: 1, + description: "Riparazione hardware ufficio", + cost: 250.5, + }) + ); const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() .where({ id: 1 }) @@ -469,6 +506,8 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { .send(validPayload) .set("Authorization", "Bearer admin"); expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); const attachments = await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() @@ -633,16 +672,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, attachments: [] }) + .send([{ ...validPayload[0], attachments: [] }]) .set("Authorization", "Bearer admin"); - expect(response.status).toBe(200); - - const attachments = - await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() - .where({ cost_id: 1 }) - .select(); - expect(attachments).toHaveLength(0); - expect(deleteFromS3).toHaveBeenCalledTimes(1); + expect(response.status).toBe(400); + expect(response.body.message).toBe( + "Item 1: At least one attachment is required" + ); }); it("Should only update specified cost, not others", async () => { @@ -670,6 +705,8 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { .send(validPayload) .set("Authorization", "Bearer admin"); expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() .where({ id: 1 }) @@ -697,6 +734,63 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { expect(getResponse.status).toBe(200); expect(getResponse.body.items).toHaveLength(2); }); + + it("Should update multiple costs in single request", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "First cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Second cost", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([ + validPayload[0], + { + ...validPayload[0], + cost_id: 2, + description: "Updated second cost", + cost: 300.0, + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + + const cost1 = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(cost1).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + cost: 250.5, + }) + ); + + const cost2 = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 2 }) + .first(); + expect(cost2).toEqual( + expect.objectContaining({ + description: "Updated second cost", + cost: 300.0, + }) + ); + }); }); describe("Success - olp permissions", () => { @@ -715,6 +809,8 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { .send(validPayload) .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() .where({ id: 1 }) @@ -757,6 +853,8 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { .send(validPayload) .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); const attachments = await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() @@ -808,7 +906,7 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { expect(response.status).toBe(500); expect(response.body).toEqual( expect.objectContaining({ - message: "Error updating other cost", + message: "Error updating other costs", }) ); }); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts index c90f41c22..76a9e3246 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -5,6 +5,15 @@ import { tryber } from "@src/features/database"; import OpenapiError from "@src/features/OpenapiError"; import deleteFromS3 from "@src/features/deleteFromS3"; +type OtherCostItem = { + cost_id: number; + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { url: string; mime_type: string }[]; +}; + export default class OtherCostsPatchRoute extends CampaignRoute<{ response: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["responses"]["200"]["content"]["application/json"]; parameters: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; @@ -20,32 +29,52 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ const body = this.getBody(); - if (body.attachments.length === 0) { + if (!Array.isArray(body) || body.length === 0) { this.setError( 400, - new OpenapiError("At least one attachment is required") + new OpenapiError("Body must be a non-empty array of cost items") ); return false; } - if (body.cost_id <= 0) { - this.setError(400, new OpenapiError("cost_id must be a positive number")); - return false; - } + for (const item of body) { + const i = body.indexOf(item); + if (item.attachments.length === 0) { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: At least one attachment is required`) + ); + return false; + } - if (!(await this.costExistsInCampaign(body.cost_id))) { - this.setError(404, new OpenapiError("Cost not found for this campaign")); - return false; - } + if (item.cost_id <= 0) { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: cost_id must be a positive number`) + ); + return false; + } - if (!(await this.typeExists(body.type_id))) { - this.setError(404, new OpenapiError("Type not found")); - return false; - } + if (!(await this.costExistsInCampaign(item.cost_id))) { + this.setError( + 404, + new OpenapiError(`Item ${i + 1}: Cost not found for this campaign`) + ); + return false; + } - if (!(await this.supplierExists(body.supplier_id))) { - this.setError(404, new OpenapiError("Supplier not found")); - return false; + if (!(await this.typeExists(item.type_id))) { + this.setError(404, new OpenapiError(`Item ${i + 1}: Type not found`)); + return false; + } + + if (!(await this.supplierExists(item.supplier_id))) { + this.setError( + 404, + new OpenapiError(`Item ${i + 1}: Supplier not found`) + ); + return false; + } } return true; @@ -54,14 +83,18 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ protected async prepare(): Promise { try { const body = this.getBody(); - await this.updateOtherCost(body); + const updatedCosts = []; - const updatedCost = await this.getUpdatedCost(body.cost_id); + for (const item of body) { + await this.updateOtherCost(item); + const updatedCost = await this.getUpdatedCost(item.cost_id); + updatedCosts.push(updatedCost); + } - return this.setSuccess(200, updatedCost); + return this.setSuccess(200, updatedCosts); } catch (e) { - console.error("Error updating other cost: ", e); - return this.setError(500, new OpenapiError("Error updating other cost")); + console.error("Error updating other costs: ", e); + return this.setError(500, new OpenapiError("Error updating other costs")); } } @@ -86,23 +119,21 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ return supplier !== undefined; } - private async updateOtherCost( - body: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"] - ): Promise { - await this.updateAttachments(); + private async updateOtherCost(item: OtherCostItem): Promise { + await this.updateAttachments(item); await tryber.tables.WpAppqCampaignOtherCosts.do() - .where({ id: body.cost_id }) + .where({ id: item.cost_id }) .update({ - description: body.description, - cost: body.cost, - type_id: body.type_id, - supplier_id: body.supplier_id, + description: item.description, + cost: item.cost, + type_id: item.type_id, + supplier_id: item.supplier_id, }); } - private async updateAttachments(): Promise { - const { cost_id, attachments } = this.getBody(); + private async updateAttachments(item: OtherCostItem): Promise { + const { cost_id, attachments } = item; const existingAttachments = await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() .select("id", "url", "mime_type") diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts index ce842050b..2b0294d73 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts @@ -102,22 +102,24 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { await tryber.tables.WpAppqEvdProfile.do().delete(); }); - const validPayload = { - description: "Riparazione hardware ufficio", - type_id: 3, - supplier_id: 105, - cost: 250.5, - attachments: [ - { - url: "https://esempio.com/documenti/fattura.pdf", - mime_type: "application/pdf", - }, - { - url: "https://esempio.com/immagini/danno.jpg", - mime_type: "image/jpeg", - }, - ], - }; + const validPayload = [ + { + description: "Riparazione hardware ufficio", + type_id: 3, + supplier_id: 105, + cost: 250.5, + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }, + ], + }, + ]; describe("Not enough permissions", () => { it("Should return 403 if logged out", async () => { @@ -145,15 +147,37 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { }); describe("Validation errors", () => { + it("Should return 400 if body is not an array", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload[0]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if body is an empty array", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Body must be a non-empty array of cost items", + }) + ); + }); + it("Should return 400 if description is empty", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, description: "" }) + .send([{ ...validPayload[0], description: "" }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Description should not be empty", + message: "Item 1: Description should not be empty", }) ); }); @@ -161,12 +185,12 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if description is only whitespace", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, description: " " }) + .send([{ ...validPayload[0], description: " " }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Description should not be empty", + message: "Item 1: Description should not be empty", }) ); }); @@ -174,12 +198,12 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if cost is 0", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost: 0 }) + .send([{ ...validPayload[0], cost: 0 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Cost must be greater than 0", + message: "Item 1: Cost must be greater than 0", }) ); }); @@ -187,12 +211,12 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if cost is negative", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost: -10 }) + .send([{ ...validPayload[0], cost: -10 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Cost must be greater than 0", + message: "Item 1: Cost must be greater than 0", }) ); }); @@ -200,12 +224,12 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if type_id does not exist", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, type_id: 999 }) + .send([{ ...validPayload[0], type_id: 999 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Type not found", + message: "Item 1: Type not found", }) ); }); @@ -213,12 +237,12 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if supplier_id does not exist", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, supplier_id: 999 }) + .send([{ ...validPayload[0], supplier_id: 999 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Supplier not found", + message: "Item 1: Supplier not found", }) ); }); @@ -226,12 +250,12 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if attachments array is empty", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, attachments: [] }) + .send([{ ...validPayload[0], attachments: [] }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "At least one attachment is required", + message: "Item 1: At least one attachment is required", }) ); }); @@ -239,20 +263,22 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if attachment url is empty", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - attachments: [ - { - url: "", - mime_type: "application/pdf", - }, - ], - }) + .send([ + { + ...validPayload[0], + attachments: [ + { + url: "", + mime_type: "application/pdf", + }, + ], + }, + ]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Attachment URL is required", + message: "Item 1: Attachment URL is required", }) ); }); @@ -260,20 +286,35 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if attachment mime_type is empty", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - attachments: [ - { - url: "https://esempio.com/documenti/fattura.pdf", - mime_type: "", - }, - ], + .send([ + { + ...validPayload[0], + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "", + }, + ], + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Attachment mime_type is required", }) + ); + }); + + it("Should return 400 for second item with invalid data", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([validPayload[0], { ...validPayload[0], description: "" }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Attachment mime_type is required", + message: "Item 2: Description should not be empty", }) ); }); @@ -338,15 +379,17 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should create cost with single attachment", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - attachments: [ - { - url: "https://esempio.com/documenti/fattura.pdf", - mime_type: "application/pdf", - }, - ], - }) + .send([ + { + ...validPayload[0], + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }, + ], + }, + ]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(201); @@ -360,23 +403,25 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should create cost with multiple attachments", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - attachments: [ - { - url: "https://esempio.com/documenti/fattura1.pdf", - mime_type: "application/pdf", - }, - { - url: "https://esempio.com/documenti/fattura2.pdf", - mime_type: "application/pdf", - }, - { - url: "https://esempio.com/immagini/danno.jpg", - mime_type: "image/jpeg", - }, - ], - }) + .send([ + { + ...validPayload[0], + attachments: [ + { + url: "https://esempio.com/documenti/fattura1.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/documenti/fattura2.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }, + ], + }, + ]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(201); @@ -396,11 +441,13 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { const response2 = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - description: "Second cost", - cost: 100.0, - }) + .send([ + { + ...validPayload[0], + description: "Second cost", + cost: 100.0, + }, + ]) .set("Authorization", "Bearer admin"); expect(response2.status).toBe(201); @@ -411,10 +458,36 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { expect(getResponse.body.items).toHaveLength(2); }); + it("Should create multiple costs in single request", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([ + validPayload[0], + { + ...validPayload[0], + description: "Second cost", + cost: 100.0, + }, + { + ...validPayload[0], + description: "Third cost", + cost: 150.0, + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(3); + }); + it("Should accept decimal cost values", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost: 123.456 }) + .send([{ ...validPayload[0], cost: 123.456 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(201); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts index 537d1d4ca..d65478f94 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts @@ -4,6 +4,14 @@ import CampaignRoute from "@src/features/routes/CampaignRoute"; import { tryber } from "@src/features/database"; import OpenapiError from "@src/features/OpenapiError"; +type OtherCostItem = { + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { url: string; mime_type: string }[]; +}; + export default class OtherCostsPostRoute extends CampaignRoute<{ response: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["responses"]["201"]; parameters: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; @@ -19,51 +27,70 @@ export default class OtherCostsPostRoute extends CampaignRoute<{ const body = this.getBody(); - // Validate description - if (!body.description || body.description.trim() === "") { - this.setError(400, new OpenapiError("Description should not be empty")); + if (!Array.isArray(body) || body.length === 0) { + this.setError( + 400, + new OpenapiError("Body must be a non-empty array of cost items") + ); return false; } - // Validate cost - if (body.cost <= 0) { - this.setError(400, new OpenapiError("Cost must be greater than 0")); - return false; - } + for (const item of body) { + const i = body.indexOf(item); - // Validate type_id exists - if (!(await this.typeExists(body.type_id))) { - this.setError(400, new OpenapiError("Type not found")); - return false; - } + if (!item.description || item.description.trim() === "") { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Description should not be empty`) + ); + return false; + } - // Validate supplier_id exists - if (!(await this.supplierExists(body.supplier_id))) { - this.setError(400, new OpenapiError("Supplier not found")); - return false; - } + if (item.cost <= 0) { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Cost must be greater than 0`) + ); + return false; + } - // Validate attachments - if (!body.attachments || body.attachments.length === 0) { - this.setError( - 400, - new OpenapiError("At least one attachment is required") - ); - return false; - } + if (!(await this.typeExists(item.type_id))) { + this.setError(400, new OpenapiError(`Item ${i + 1}: Type not found`)); + return false; + } - for (const attachment of body.attachments) { - if (!attachment.url || attachment.url.trim() === "") { - this.setError(400, new OpenapiError("Attachment URL is required")); + if (!(await this.supplierExists(item.supplier_id))) { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Supplier not found`) + ); return false; } - if (!attachment.mime_type || attachment.mime_type.trim() === "") { + + if (!item.attachments || item.attachments.length === 0) { this.setError( 400, - new OpenapiError("Attachment mime_type is required") + new OpenapiError(`Item ${i + 1}: At least one attachment is required`) ); return false; } + + for (const attachment of item.attachments) { + if (!attachment.url || attachment.url.trim() === "") { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Attachment URL is required`) + ); + return false; + } + if (!attachment.mime_type || attachment.mime_type.trim() === "") { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Attachment mime_type is required`) + ); + return false; + } + } } return true; @@ -72,26 +99,27 @@ export default class OtherCostsPostRoute extends CampaignRoute<{ protected async prepare(): Promise { try { const body = this.getBody(); - const costId = await this.createOtherCost(body); - await this.createAttachments(costId, body.attachments); - return this.setSuccess(201, undefined); + for (const item of body) { + const costId = await this.createOtherCost(item); + await this.createAttachments(costId, item.attachments); + } + + return this.setSuccess(201, {}); } catch (e) { - console.error("Error creating other cost: ", e); - return this.setError(500, new OpenapiError("Error creating other cost")); + console.error("Error creating other costs: ", e); + return this.setError(500, new OpenapiError("Error creating other costs")); } } - private async createOtherCost( - body: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"] - ): Promise { + private async createOtherCost(item: OtherCostItem): Promise { const result = await tryber.tables.WpAppqCampaignOtherCosts.do() .insert({ campaign_id: this.cp_id, - description: body.description, - cost: body.cost, - type_id: body.type_id, - supplier_id: body.supplier_id, + description: item.description, + cost: item.cost, + type_id: item.type_id, + supplier_id: item.supplier_id, }) .returning("id"); diff --git a/src/schema.ts b/src/schema.ts index c5a54a6b3..eb8245458 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5697,7 +5697,7 @@ export interface operations { url: string; mime_type: string; }[]; - }; + }[]; }; }; }; @@ -5747,7 +5747,7 @@ export interface operations { url: string; mime_type: string; }[]; - }; + }[]; }; }; /** Bad Request */ @@ -5770,7 +5770,7 @@ export interface operations { mime_type: string; }[]; cost_id: number; - }; + }[]; }; }; };