diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 91c079801..5823a5715 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13446,12 +13446,12 @@ paths: - JWT: [] '/campaigns/{campaign}/finance/otherCosts': parameters: - - schema: - type: string - name: campaign + - description: A campaign id in: path + name: campaign required: true - - $ref: '#/components/parameters/campaign' + schema: + type: string get: summary: Your GET endpoint tags: [] @@ -13688,6 +13688,147 @@ paths: Example 1: value: cost_id: 80 + patch: + summary: Your PATCH endpoint + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + 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: 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 + 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 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 new file mode 100644 index 000000000..238b4c419 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts @@ -0,0 +1,807 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; +import deleteFromS3 from "@src/features/deleteFromS3"; + +jest.mock("@src/features/deleteFromS3"); + +describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { + beforeAll(async () => { + (deleteFromS3 as jest.Mock).mockResolvedValue(undefined); + + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + { + id: 2, + name: "Jane", + surname: "Doe", + wp_user_id: 2, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert([{ ID: 1 }, { ID: 2 }]); + await tryber.tables.WpAppqEvdCampaign.do().insert([ + { + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + { + id: 2, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "Another campaign", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsType.do().insert([ + { + id: 1, + name: "Type 1", + }, + { + id: 2, + name: "Type 2", + }, + { + id: 3, + name: "Type 3", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + { + id: 2, + name: "Supplier 2", + created_by: 1, + created_on: "2024-01-02 11:00:00", + }, + { + id: 105, + name: "Supplier 105", + created_by: 1, + created_on: "2024-01-03 12:00:00", + }, + ]); + }); + + afterEach(async () => { + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().delete(); + await tryber.tables.WpAppqCampaignOtherCosts.do().delete(); + jest.clearAllMocks(); + }); + + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + await tryber.tables.WpAppqCampaignOtherCostsType.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + 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", + }, + ], + }; + + describe("Authentication and Authorization", () => { + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + }); + + it("Should return 403 if user is not logged in", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload); + expect(response.status).toBe(403); + }); + + it("Should return 403 if user is not admin and does not have olp permissions", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if user has olp permissions for different campaign", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[2]}'); + expect(response.status).toBe(403); + }); + + it("Should allow access with admin permissions", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + }); + + it("Should allow access with olp permissions for the campaign", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + }); + }); + + describe("Input Validation", () => { + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + }); + + it("Should return 400 if cost_id is missing", async () => { + const { cost_id, ...payload } = validPayload; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + 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 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + 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 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "cost_id must be a positive number", + }) + ); + }); + + 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 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "cost_id must be a positive number", + }) + ); + }); + + it("Should return 400 if description is missing", async () => { + const { description, ...payload } = validPayload; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .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 response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .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 response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .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 response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .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 response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + 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" }], + }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + 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" }], + }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + }); + + describe("Resource Validation", () => { + 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 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Cost not found for this campaign", + }) + ); + }); + + it("Should return 404 if cost belongs to another campaign", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 10, + campaign_id: 2, + description: "Cost for another campaign", + cost: 50.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, 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", + }) + ); + }); + + it("Should return 404 if type_id does not exist", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, type_id: 999 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Type not found", + }) + ); + }); + + it("Should return 404 if supplier_id does not exist", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, supplier_id: 999 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Supplier not found", + }) + ); + }); + }); + + describe("Success - admin permissions", () => { + it("Should update cost in database", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + + expect(updatedCost).toEqual( + expect.objectContaining({ + id: 1, + campaign_id: 1, + description: "Riparazione hardware ufficio", + cost: 250.5, + type_id: 3, + supplier_id: 105, + }) + ); + }); + + it("Should update cost and replace attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://old.com/old1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://old.com/old2.jpg", + mime_type: "image/jpeg", + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + + expect(attachments).toHaveLength(2); + expect(attachments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + cost_id: 1, + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }), + expect.objectContaining({ + cost_id: 1, + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }), + ]) + ); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 1, + description: "Riparazione hardware ufficio", + cost: 250.5, + type: { + name: "Type 3", + id: 3, + }, + supplier: { + name: "Supplier 105", + id: 105, + }, + }) + ); + expect(getResponse.body.items[0].attachments).toHaveLength(2); + }); + + it("Should delete old attachments from S3 when updating", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert({ + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/old-file.pdf", + mime_type: "application/pdf", + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toHaveBeenCalledTimes(1); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/old-file.pdf", + }); + }); + + it("Should delete multiple old attachments from S3 when updating", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file3.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toHaveBeenCalledTimes(3); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file2.jpg", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file3.png", + }); + }); + + it("Should update cost without old attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(2); + expect(deleteFromS3).not.toHaveBeenCalled(); + }); + + it("Should update cost with empty attachments array", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert({ + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/old-file.pdf", + mime_type: "application/pdf", + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, 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); + }); + + it("Should only update specified cost, not others", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to update", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(updatedCost).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + cost: 250.5, + }) + ); + + const untouchedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 2 }) + .first(); + expect(untouchedCost).toEqual( + expect.objectContaining({ + description: "Cost to keep", + cost: 200.0, + }) + ); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(2); + }); + }); + + describe("Success - olp permissions", () => { + it("Should update cost with olp permissions", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(updatedCost).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + cost: 250.5, + }) + ); + }); + + it("Should update cost and replace attachments with olp permissions", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://old.com/old1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://old.com/old2.jpg", + mime_type: "image/jpeg", + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(2); + expect(deleteFromS3).toHaveBeenCalledTimes(2); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 1, + description: "Riparazione hardware ufficio", + }) + ); + }); + }); + + describe("Error Handling", () => { + it("Should return 500 if S3 deletion fails", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert({ + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file.pdf", + mime_type: "application/pdf", + }); + + (deleteFromS3 as jest.Mock).mockRejectedValueOnce( + new Error("S3 deletion failed") + ); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(500); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Error updating other cost", + }) + ); + }); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts new file mode 100644 index 000000000..dd364f669 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -0,0 +1,186 @@ +/** OPENAPI-CLASS: patch-campaigns-campaign-finance-otherCosts */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; +import deleteFromS3 from "@src/features/deleteFromS3"; + +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"]; + body: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + return false; + } + + const body = this.getBody(); + + if (body.cost_id <= 0) { + this.setError(400, new OpenapiError("cost_id must be a positive number")); + return false; + } + + if (!(await this.costExistsInCampaign(body.cost_id))) { + this.setError(404, new OpenapiError("Cost not found for this campaign")); + return false; + } + + if (!(await this.typeExists(body.type_id))) { + this.setError(404, new OpenapiError("Type not found")); + return false; + } + + if (!(await this.supplierExists(body.supplier_id))) { + this.setError(404, new OpenapiError("Supplier not found")); + return false; + } + + return true; + } + + protected async prepare(): Promise { + try { + const body = this.getBody(); + await this.updateOtherCost(body); + + const updatedCost = await this.getUpdatedCost(body.cost_id); + + return this.setSuccess(200, updatedCost); + } catch (e) { + console.error("Error updating other cost: ", e); + return this.setError(500, new OpenapiError("Error updating other cost")); + } + } + + private async costExistsInCampaign(costId: number): Promise { + const cost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: costId, campaign_id: this.cp_id }) + .first(); + return cost !== undefined; + } + + private async typeExists(typeId: number): Promise { + const type = await tryber.tables.WpAppqCampaignOtherCostsType.do() + .where({ id: typeId }) + .first(); + return type !== undefined; + } + + private async supplierExists(supplierId: number): Promise { + const supplier = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ id: supplierId }) + .first(); + return supplier !== undefined; + } + + private async updateOtherCost( + body: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"] + ): Promise { + await this.deleteExistingAttachments(body.cost_id); + + await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: body.cost_id }) + .update({ + description: body.description, + cost: body.cost, + type_id: body.type_id, + supplier_id: body.supplier_id, + }); + + if (body.attachments && body.attachments.length > 0) { + await this.createAttachments(body.cost_id, body.attachments); + } + } + + private async deleteExistingAttachments(costId: number): Promise { + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .select("url", "id") + .where({ cost_id: costId }); + + if (attachments.length > 0) { + for (const attachment of attachments) { + try { + await deleteFromS3({ url: attachment.url }); + } catch (e) { + console.error( + `Error deleting attachment from S3: ${attachment.url}`, + e + ); + throw new Error("Error deleting attachment from S3"); + } + } + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: costId }) + .delete(); + } + } + + private async createAttachments( + costId: number, + attachments: { url: string; mime_type: string }[] + ): Promise { + const attachmentsData = attachments.map((attachment) => ({ + cost_id: costId, + url: attachment.url, + mime_type: attachment.mime_type, + })); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert( + attachmentsData + ); + } + + private async getUpdatedCost(costId: number) { + const cost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .select( + tryber + .ref("id") + .withSchema("wp_appq_campaign_other_costs") + .as("cost_id"), + "description", + "type_id", + "supplier_id", + "cost" + ) + .where({ id: costId }) + .first(); + + if (!cost) { + throw new Error("Cost not found after update"); + } + + const type = await tryber.tables.WpAppqCampaignOtherCostsType.do() + .select("id", "name") + .where({ id: cost.type_id }) + .first(); + + const supplier = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .select("id", "name") + .where({ id: cost.supplier_id }) + .first(); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .select("id", "url", "mime_type") + .where({ cost_id: costId }); + + return { + description: cost.description, + type: type?.name || "", + cost_id: cost.cost_id, + supplier: supplier?.name || "", + cost: cost.cost, + attachments: attachments.map((a) => ({ + url: a.url, + mime_type: a.mime_type, + })), + }; + } +} diff --git a/src/schema.ts b/src/schema.ts index 3d6b88bdc..c5a54a6b3 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -827,10 +827,11 @@ export interface paths { /** Create a new campaign cost */ post: operations["post-campaigns-campaign-finance-otherCosts"]; delete: operations["delete-campaigns-campaign-finance-otherCosts"]; + patch: operations["patch-campaigns-campaign-finance-otherCosts"]; parameters: { path: { /** A campaign id */ - campaign: components["parameters"]["campaign"]; + campaign: string; }; }; }; @@ -5630,7 +5631,7 @@ export interface operations { parameters: { path: { /** A campaign id */ - campaign: components["parameters"]["campaign"]; + campaign: string; }; }; responses: { @@ -5672,7 +5673,7 @@ export interface operations { parameters: { path: { /** A campaign id */ - campaign: components["parameters"]["campaign"]; + campaign: string; }; }; responses: { @@ -5704,7 +5705,7 @@ export interface operations { parameters: { path: { /** A campaign id */ - campaign: components["parameters"]["campaign"]; + campaign: string; }; }; responses: { @@ -5725,6 +5726,54 @@ export interface operations { }; }; }; + "patch-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + description: string; + type: string; + cost_id: number; + supplier: string; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + }; + }; + }; + /** Bad Request */ + 400: unknown; + /** Forbidden */ + 403: unknown; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + cost_id: number; + }; + }; + }; + }; } export interface external {}