From fa87f05bfa570689428d61e0e9ccf935de657d0f Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Mon, 2 Feb 2026 16:45:59 +0100 Subject: [PATCH 1/8] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 148 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 142 insertions(+), 6 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 91c079801..c445f0c62 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13444,13 +13444,8 @@ paths: id: 02e8ns5xdhecm security: - JWT: [] - '/campaigns/{campaign}/finance/otherCosts': + /campaigns/finance/otherCosts: parameters: - - schema: - type: string - name: campaign - in: path - required: true - $ref: '#/components/parameters/campaign' get: summary: Your GET endpoint @@ -13688,6 +13683,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: From 69a0806b22a42f33703ea5d1358f9d591a4d68ef Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Mon, 2 Feb 2026 17:02:08 +0100 Subject: [PATCH 2/8] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index c445f0c62..5823a5715 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13444,9 +13444,14 @@ paths: id: 02e8ns5xdhecm security: - JWT: [] - /campaigns/finance/otherCosts: + '/campaigns/{campaign}/finance/otherCosts': parameters: - - $ref: '#/components/parameters/campaign' + - description: A campaign id + in: path + name: campaign + required: true + schema: + type: string get: summary: Your GET endpoint tags: [] From 167765d29f750c412f8a5942942546e506f457fe Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 17:40:16 +0100 Subject: [PATCH 3/8] feat: add patch operation for campaign finance other costs and update parameters --- src/schema.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 3d6b88bdc..61c2ae705 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,57 @@ 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; + /** @description ID of existing supplier (mutually exclusive with new_supplier_name) */ + supplier_id?: number; + /** @description Name for new supplier (mutually exclusive with supplier_id) */ + new_supplier_name?: string; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + cost_id: number; + }; + }; + }; + }; } export interface external {} From b90bcc98a30a34b6bde53b23435181807b32c0c9 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 17:40:34 +0100 Subject: [PATCH 4/8] feat: update campaign finance parameters to include new_supplier_name and remove supplier_id --- src/reference/openapi.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 5823a5715..079897112 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13787,7 +13787,6 @@ paths: required: - description - type_id - - supplier_id - cost - attachments - cost_id @@ -13798,6 +13797,10 @@ paths: type: integer supplier_id: type: integer + description: ID of existing supplier (mutually exclusive with new_supplier_name) + new_supplier_name: + type: string + description: Name for new supplier (mutually exclusive with supplier_id) cost: type: number attachments: @@ -13829,6 +13832,16 @@ paths: mime_type: application/pdf - url: 'https://esempio.com/immagini/danno.jpg' mime_type: image/jpeg + Example 2 (New Supplier): + value: + description: Riparazione hardware ufficio + type_id: 3 + cost_id: 2 + new_supplier_name: Nuovo Fornitore SRL + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf servers: - url: 'https://api.app-quality.com' tags: From 01e5d5b71106f4d03e972f75a7e03af20e0da4e6 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 17:43:21 +0100 Subject: [PATCH 5/8] feat: implement PATCH route for updating campaign finance other costs with validation and supplier management --- .../finance/otherCosts/_patch/index.spec.ts | 965 ++++++++++++++++++ .../finance/otherCosts/_patch/index.ts | 252 +++++ 2 files changed, 1217 insertions(+) create mode 100644 src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts create mode 100644 src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts 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..a14e13d02 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts @@ -0,0 +1,965 @@ +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 payload = { ...validPayload }; + delete (payload as any).cost_id; + + 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 payload = { ...validPayload }; + delete (payload as any).description; + + 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 payload = { ...validPayload }; + delete (payload as any).type_id; + + 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 payload = { ...validPayload }; + delete (payload as any).cost; + + 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 payload = { ...validPayload }; + delete (payload as any).attachments; + + 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", + }) + ); + }); + + it("Should return 400 if both supplier_id and new_supplier_name are provided", 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: 105, + new_supplier_name: "New Supplier", + }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Cannot provide both supplier_id and new_supplier_name", + }) + ); + }); + + it("Should return 400 if neither supplier_id nor new_supplier_name are provided", 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 payload = { ...validPayload }; + delete (payload as any).supplier_id; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Either supplier_id or new_supplier_name must be provided", + }) + ); + }); + }); + + 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); + }); + + it("Should create new supplier when new_supplier_name is provided", 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 payload = { + ...validPayload, + new_supplier_name: "Nuovo Fornitore SRL", + }; + delete (payload as any).supplier_id; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const newSupplier = + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ name: "Nuovo Fornitore SRL" }) + .first(); + expect(newSupplier).toBeDefined(); + expect(newSupplier?.name).toBe("Nuovo Fornitore SRL"); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(updatedCost?.supplier_id).toBe(newSupplier?.id); + }); + + it("Should reuse existing supplier if new_supplier_name matches existing name", 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 payload = { + ...validPayload, + new_supplier_name: "Supplier 1", // This already exists in the database + }; + delete (payload as any).supplier_id; + + const initialSupplierCount = + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .count("id") + .as("count") + .first(); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + // Verify no new supplier was created + const finalSupplierCount = + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .count("id") + .as("count") + .first(); + expect(Number(finalSupplierCount?.count)).toBe( + Number(initialSupplierCount?.count) + ); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(updatedCost?.supplier_id).toBe(1); // Should use Supplier 1's ID + }); + }); + + 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", + }) + ); + }); + + it("Should create new supplier with olp permissions when new_supplier_name is provided", 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 payload = { + ...validPayload, + new_supplier_name: "Fornitore OLP Test", + }; + delete (payload as any).supplier_id; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + + const newSupplier = + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ name: "Fornitore OLP Test" }) + .first(); + expect(newSupplier).toBeDefined(); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(updatedCost?.supplier_id).toBe(newSupplier?.id); + }); + }); + + 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..74847f16b --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -0,0 +1,252 @@ +/** 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; + } + + const costExists = await this.costExistsInCampaign(body.cost_id); + if (!costExists) { + this.setError(404, new OpenapiError("Cost not found for this campaign")); + return false; + } + + const typeExists = await this.typeExists(body.type_id); + if (!typeExists) { + this.setError(404, new OpenapiError("Type not found")); + return false; + } + + // Validate supplier: either supplier_id OR new_supplier_name, but not both + const hasSupplier = + body.supplier_id !== undefined && body.supplier_id !== null; + const hasNewSupplierName = + body.new_supplier_name !== undefined && + body.new_supplier_name !== null && + body.new_supplier_name.trim() !== ""; + + if (!hasSupplier && !hasNewSupplierName) { + this.setError( + 400, + new OpenapiError( + "Either supplier_id or new_supplier_name must be provided" + ) + ); + return false; + } + + if (hasSupplier && hasNewSupplierName) { + this.setError( + 400, + new OpenapiError( + "Cannot provide both supplier_id and new_supplier_name" + ) + ); + return false; + } + + if (hasSupplier) { + const supplierExists = await this.supplierExists(body.supplier_id!); + if (!supplierExists) { + 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); + + let supplierId: number; + if (body.supplier_id !== undefined && body.supplier_id !== null) { + supplierId = body.supplier_id; + } else if (body.new_supplier_name) { + supplierId = await this.createOrGetSupplier(body.new_supplier_name); + } else { + throw new Error("No supplier information provided"); + } + + await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: body.cost_id }) + .update({ + description: body.description, + cost: body.cost, + type_id: body.type_id, + supplier_id: supplierId, + }); + + if (body.attachments && body.attachments.length > 0) { + await this.createAttachments(body.cost_id, body.attachments); + } + } + + private async createOrGetSupplier(supplierName: string): Promise { + const existingSupplier = + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ name: supplierName }) + .first(); + + if (existingSupplier) { + return existingSupplier.id; + } + + const result = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .insert({ + name: supplierName, + created_by: this.getWordpressId(), + created_on: tryber.fn.now(), + }) + .returning("id"); + + const id = result[0]?.id ?? result[0]; + if (!id) throw new Error("Error creating supplier"); + + return id; + } + + 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, + })), + }; + } +} From dd2c7453a61886c89dde3641149cddc30fa40c26 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 00:09:48 +0100 Subject: [PATCH 6/8] feat: update campaign finance schema to require supplier_id and remove new_supplier_name --- src/reference/openapi.yml | 15 +-------------- src/schema.ts | 5 +---- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 079897112..5823a5715 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13787,6 +13787,7 @@ paths: required: - description - type_id + - supplier_id - cost - attachments - cost_id @@ -13797,10 +13798,6 @@ paths: type: integer supplier_id: type: integer - description: ID of existing supplier (mutually exclusive with new_supplier_name) - new_supplier_name: - type: string - description: Name for new supplier (mutually exclusive with supplier_id) cost: type: number attachments: @@ -13832,16 +13829,6 @@ paths: mime_type: application/pdf - url: 'https://esempio.com/immagini/danno.jpg' mime_type: image/jpeg - Example 2 (New Supplier): - value: - description: Riparazione hardware ufficio - type_id: 3 - cost_id: 2 - new_supplier_name: Nuovo Fornitore SRL - cost: 250.5 - attachments: - - url: 'https://esempio.com/documenti/fattura.pdf' - mime_type: application/pdf servers: - url: 'https://api.app-quality.com' tags: diff --git a/src/schema.ts b/src/schema.ts index 61c2ae705..c5a54a6b3 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5763,10 +5763,7 @@ export interface operations { "application/json": { description: string; type_id: number; - /** @description ID of existing supplier (mutually exclusive with new_supplier_name) */ - supplier_id?: number; - /** @description Name for new supplier (mutually exclusive with supplier_id) */ - new_supplier_name?: string; + supplier_id: number; cost: number; attachments: { url: string; From 36fece47c59f5dd8c37ba345652342c5874a2dd7 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 00:09:59 +0100 Subject: [PATCH 7/8] refactor: simplify supplier validation and remove new_supplier_name handling in other costs patch --- .../finance/otherCosts/_patch/index.spec.ts | 190 ++---------------- .../finance/otherCosts/_patch/index.ts | 71 +------ 2 files changed, 20 insertions(+), 241 deletions(-) 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 a14e13d02..238b4c419 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts @@ -191,8 +191,7 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }); it("Should return 400 if cost_id is missing", async () => { - const payload = { ...validPayload }; - delete (payload as any).cost_id; + const { cost_id, ...payload } = validPayload; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") @@ -238,8 +237,7 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }); it("Should return 400 if description is missing", async () => { - const payload = { ...validPayload }; - delete (payload as any).description; + const { description, ...payload } = validPayload; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") @@ -250,8 +248,18 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }); it("Should return 400 if type_id is missing", async () => { - const payload = { ...validPayload }; - delete (payload as any).type_id; + 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") @@ -262,8 +270,7 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }); it("Should return 400 if cost is missing", async () => { - const payload = { ...validPayload }; - delete (payload as any).cost; + const { cost, ...payload } = validPayload; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") @@ -274,8 +281,7 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }); it("Should return 400 if attachments is missing", async () => { - const payload = { ...validPayload }; - delete (payload as any).attachments; + const { attachments, ...payload } = validPayload; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") @@ -389,57 +395,6 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }) ); }); - - it("Should return 400 if both supplier_id and new_supplier_name are provided", 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: 105, - new_supplier_name: "New Supplier", - }) - .set("Authorization", "Bearer admin"); - expect(response.status).toBe(400); - expect(response.body).toEqual( - expect.objectContaining({ - message: "Cannot provide both supplier_id and new_supplier_name", - }) - ); - }); - - it("Should return 400 if neither supplier_id nor new_supplier_name are provided", 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 payload = { ...validPayload }; - delete (payload as any).supplier_id; - - const response = await request(app) - .patch("/campaigns/1/finance/otherCosts") - .send(payload) - .set("Authorization", "Bearer admin"); - expect(response.status).toBe(400); - expect(response.body).toEqual( - expect.objectContaining({ - message: "Either supplier_id or new_supplier_name must be provided", - }) - ); - }); }); describe("Success - admin permissions", () => { @@ -733,85 +688,6 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { expect(getResponse.status).toBe(200); expect(getResponse.body.items).toHaveLength(2); }); - - it("Should create new supplier when new_supplier_name is provided", 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 payload = { - ...validPayload, - new_supplier_name: "Nuovo Fornitore SRL", - }; - delete (payload as any).supplier_id; - - const response = await request(app) - .patch("/campaigns/1/finance/otherCosts") - .send(payload) - .set("Authorization", "Bearer admin"); - expect(response.status).toBe(200); - - const newSupplier = - await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() - .where({ name: "Nuovo Fornitore SRL" }) - .first(); - expect(newSupplier).toBeDefined(); - expect(newSupplier?.name).toBe("Nuovo Fornitore SRL"); - - const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() - .where({ id: 1 }) - .first(); - expect(updatedCost?.supplier_id).toBe(newSupplier?.id); - }); - - it("Should reuse existing supplier if new_supplier_name matches existing name", 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 payload = { - ...validPayload, - new_supplier_name: "Supplier 1", // This already exists in the database - }; - delete (payload as any).supplier_id; - - const initialSupplierCount = - await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() - .count("id") - .as("count") - .first(); - - const response = await request(app) - .patch("/campaigns/1/finance/otherCosts") - .send(payload) - .set("Authorization", "Bearer admin"); - expect(response.status).toBe(200); - - // Verify no new supplier was created - const finalSupplierCount = - await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() - .count("id") - .as("count") - .first(); - expect(Number(finalSupplierCount?.count)).toBe( - Number(initialSupplierCount?.count) - ); - - const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() - .where({ id: 1 }) - .first(); - expect(updatedCost?.supplier_id).toBe(1); // Should use Supplier 1's ID - }); }); describe("Success - olp permissions", () => { @@ -892,40 +768,6 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }) ); }); - - it("Should create new supplier with olp permissions when new_supplier_name is provided", 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 payload = { - ...validPayload, - new_supplier_name: "Fornitore OLP Test", - }; - delete (payload as any).supplier_id; - - const response = await request(app) - .patch("/campaigns/1/finance/otherCosts") - .send(payload) - .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); - expect(response.status).toBe(200); - - const newSupplier = - await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() - .where({ name: "Fornitore OLP Test" }) - .first(); - expect(newSupplier).toBeDefined(); - - const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() - .where({ id: 1 }) - .first(); - expect(updatedCost?.supplier_id).toBe(newSupplier?.id); - }); }); describe("Error Handling", () => { diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts index 74847f16b..b6d2c4430 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -37,42 +37,12 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ return false; } - // Validate supplier: either supplier_id OR new_supplier_name, but not both - const hasSupplier = - body.supplier_id !== undefined && body.supplier_id !== null; - const hasNewSupplierName = - body.new_supplier_name !== undefined && - body.new_supplier_name !== null && - body.new_supplier_name.trim() !== ""; - - if (!hasSupplier && !hasNewSupplierName) { - this.setError( - 400, - new OpenapiError( - "Either supplier_id or new_supplier_name must be provided" - ) - ); + const supplierExists = await this.supplierExists(body.supplier_id); + if (!supplierExists) { + this.setError(404, new OpenapiError("Supplier not found")); return false; } - if (hasSupplier && hasNewSupplierName) { - this.setError( - 400, - new OpenapiError( - "Cannot provide both supplier_id and new_supplier_name" - ) - ); - return false; - } - - if (hasSupplier) { - const supplierExists = await this.supplierExists(body.supplier_id!); - if (!supplierExists) { - this.setError(404, new OpenapiError("Supplier not found")); - return false; - } - } - return true; } @@ -116,22 +86,13 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ ): Promise { await this.deleteExistingAttachments(body.cost_id); - let supplierId: number; - if (body.supplier_id !== undefined && body.supplier_id !== null) { - supplierId = body.supplier_id; - } else if (body.new_supplier_name) { - supplierId = await this.createOrGetSupplier(body.new_supplier_name); - } else { - throw new Error("No supplier information provided"); - } - await tryber.tables.WpAppqCampaignOtherCosts.do() .where({ id: body.cost_id }) .update({ description: body.description, cost: body.cost, type_id: body.type_id, - supplier_id: supplierId, + supplier_id: body.supplier_id, }); if (body.attachments && body.attachments.length > 0) { @@ -139,30 +100,6 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ } } - private async createOrGetSupplier(supplierName: string): Promise { - const existingSupplier = - await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() - .where({ name: supplierName }) - .first(); - - if (existingSupplier) { - return existingSupplier.id; - } - - const result = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() - .insert({ - name: supplierName, - created_by: this.getWordpressId(), - created_on: tryber.fn.now(), - }) - .returning("id"); - - const id = result[0]?.id ?? result[0]; - if (!id) throw new Error("Error creating supplier"); - - return id; - } - private async deleteExistingAttachments(costId: number): Promise { const attachments = await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() From fba01971c20ac6eeda4b95cc0e649e9cc2eac893 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 09:54:48 +0100 Subject: [PATCH 8/8] refactor: streamline validation checks for cost, type, and supplier in other costs patch --- .../campaignId/finance/otherCosts/_patch/index.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts index b6d2c4430..dd364f669 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -25,20 +25,17 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ return false; } - const costExists = await this.costExistsInCampaign(body.cost_id); - if (!costExists) { + if (!(await this.costExistsInCampaign(body.cost_id))) { this.setError(404, new OpenapiError("Cost not found for this campaign")); return false; } - const typeExists = await this.typeExists(body.type_id); - if (!typeExists) { + if (!(await this.typeExists(body.type_id))) { this.setError(404, new OpenapiError("Type not found")); return false; } - const supplierExists = await this.supplierExists(body.supplier_id); - if (!supplierExists) { + if (!(await this.supplierExists(body.supplier_id))) { this.setError(404, new OpenapiError("Supplier not found")); return false; }