From 4eca4639de3c4021d5daad319e81f88effdde8f4 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Mon, 2 Feb 2026 14:20:11 +0100 Subject: [PATCH 1/5] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 5651f404c..bc88cae91 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13446,6 +13446,11 @@ paths: - JWT: [] '/campaigns/{campaign}/finance/otherCosts': parameters: + - schema: + type: string + name: campaign + in: path + required: true - $ref: '#/components/parameters/campaign' get: summary: Your GET endpoint @@ -13624,6 +13629,41 @@ paths: mime_type: image/jpeg security: - JWT: [] + delete: + summary: Your DELETE endpoint + tags: [] + responses: + '200': + description: OK + '400': + description: Bad Request + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Internal Server Error + operationId: delete-campaigns-campaign-finance-otherCosts + x-stoplight: + id: p9q4g69c20okp + security: + - JWT: [] + requestBody: + content: + application/json: + schema: + type: object + required: + - cost_id + properties: + cost_id: + type: integer + x-stoplight: + id: b27nhd7f5ugfs + examples: + Example 1: + value: + cost_id: 80 servers: - url: 'https://api.app-quality.com' tags: From b39676761029f045503c1322b760897f0f9f2386 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 14:23:14 +0100 Subject: [PATCH 2/5] feat: add DELETE endpoint for campaign finance other costs --- src/schema.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/schema.ts b/src/schema.ts index 09a9f787f..b1f835100 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -826,6 +826,7 @@ export interface paths { get: operations["get-campaigns-campaign-finance-otherCosts"]; /** Create a new campaign cost */ post: operations["post-campaigns-campaign-finance-otherCosts"]; + delete: operations["delete-campaigns-campaign-finance-otherCosts"]; parameters: { path: { /** A campaign id */ @@ -5698,6 +5699,31 @@ export interface operations { }; }; }; + "delete-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: components["parameters"]["campaign"]; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: unknown; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + cost_id: number; + }; + }; + }; + }; } export interface external {} From b89e62ce1711c048e2c1cc707a9a867316a0c4a0 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 14:50:09 +0100 Subject: [PATCH 3/5] feat: implement DELETE endpoint for campaign finance other costs --- .../finance/otherCosts/_delete/index.spec.ts | 648 ++++++++++++++++++ .../finance/otherCosts/_delete/index.ts | 64 ++ 2 files changed, 712 insertions(+) create mode 100644 src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts create mode 100644 src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts new file mode 100644 index 000000000..4e2009f4a --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts @@ -0,0 +1,648 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; + +describe("DELETE /campaigns/campaignId/finance/otherCosts", () => { + beforeAll(async () => { + 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", + }, + ]); + 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", + }, + ]); + }); + + afterEach(async () => { + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().delete(); + await tryber.tables.WpAppqCampaignOtherCosts.do().delete(); + }); + + 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(); + }); + + describe("Authentication and Authorization", () => { + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + }); + + it("Should return 403 if user is not authenticated", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }); + expect(response.status).toBe(403); + }); + + it("Should return 403 if user does not have access to campaign", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[2]}'); + expect(response.status).toBe(403); + }); + + it("Should return 200 if logged in as admin", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + }); + + it("Should return 200 if logged in as olp with access to campaign", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .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: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + }); + + it("Should return 400 if cost_id is missing", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({}) + .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) + .delete("/campaigns/1/finance/otherCosts") + .send({ 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 not a number", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: "invalid" }) + .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) + .delete("/campaigns/1/finance/otherCosts") + .send({ 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) + .delete("/campaigns/1/finance/otherCosts") + .send({ 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", + }) + ); + }); + }); + + describe("Not Found ", () => { + it("Should return 404 if cost does not exist", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ 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) + .delete("/campaigns/1/finance/otherCosts") + .send({ 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", + }) + ); + }); + }); + + describe("Success - admin permissions", () => { + it("Should delete cost from database", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(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(0); + }); + + it("Should delete cost and all its attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost with attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://example.com/attachment1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://example.com/attachment2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 1, + url: "https://example.com/attachment3.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(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(0); + }); + + it("Should only delete specified cost, not others", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + 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) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const cost1 = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(cost1).toHaveLength(0); + + const cost2 = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 2 }) + .select(); + expect(cost2).toHaveLength(1); + + 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: 2, + description: "Cost to keep", + }) + ); + }); + + it("Should only delete attachments of the deleted cost", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + 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, + }, + ]); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://example.com/delete1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://example.com/delete2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 2, + url: "https://example.com/keep.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const attachmentsCost1 = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachmentsCost1).toHaveLength(0); + + const attachmentsCost2 = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 2 }) + .select(); + expect(attachmentsCost2).toHaveLength(1); + expect(attachmentsCost2[0]).toEqual( + expect.objectContaining({ + id: 3, + url: "https://example.com/keep.png", + }) + ); + + 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: 2, + description: "Cost to keep", + attachments: expect.arrayContaining([ + expect.objectContaining({ + id: 3, + url: "https://example.com/keep.png", + mimetype: "image/png", + }), + ]), + }) + ); + }); + + it("Should delete cost without attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost without attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(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(0); + }); + + it("Should delete correctly only one cost item", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + 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 deleteResponse = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(deleteResponse.status).toBe(200); + + 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: 2, + description: "Cost to keep", + }) + ); + }); + }); + + describe("Success - olp permissions", () => { + it("Should delete cost with olp permissions", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + 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(0); + }); + + it("Should delete cost and attachments ", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost with attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://example.com/attachment1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://example.com/attachment2.jpg", + mime_type: "image/jpeg", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(0); + + 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(0); + }); + + it("Should delete correctly only one cost item", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + 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 deleteResponse = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(deleteResponse.status).toBe(200); + + 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: 2, + description: "Cost to keep", + }) + ); + }); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts new file mode 100644 index 000000000..a3a750e74 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts @@ -0,0 +1,64 @@ +/** OPENAPI-CLASS: delete-campaigns-campaign-finance-otherCosts */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; + +export default class OtherCostsDeleteRoute extends CampaignRoute<{ + response: StoplightOperations["delete-campaigns-campaign-finance-otherCosts"]["responses"]["200"]; + parameters: StoplightOperations["delete-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; + body: StoplightOperations["delete-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; + } + + return true; + } + + protected async prepare(): Promise { + try { + const body = this.getBody(); + await this.deleteOtherCost(body.cost_id); + + return this.setSuccess(200, {}); + } catch (e) { + console.error("Error deleting other cost: ", e); + return this.setError(500, new OpenapiError("Error deleting 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 deleteOtherCost(costId: number): Promise { + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: costId }) + .delete(); + + await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: costId }) + .delete(); + } +} From 86ca6642c50ee014957697c5e9d7c22cf509db63 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 15:01:53 +0100 Subject: [PATCH 4/5] feat: implement S3 deletion for campaign finance other costs --- .../finance/otherCosts/_delete/index.spec.ts | 158 ++++++++++++++++++ .../finance/otherCosts/_delete/index.ts | 25 ++- 2 files changed, 180 insertions(+), 3 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts index 4e2009f4a..848c12edd 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts @@ -1,6 +1,9 @@ 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("DELETE /campaigns/campaignId/finance/otherCosts", () => { beforeAll(async () => { @@ -82,6 +85,7 @@ describe("DELETE /campaigns/campaignId/finance/otherCosts", () => { afterEach(async () => { await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().delete(); await tryber.tables.WpAppqCampaignOtherCosts.do().delete(); + jest.clearAllMocks(); }); afterAll(async () => { @@ -645,4 +649,158 @@ describe("DELETE /campaigns/campaignId/finance/otherCosts", () => { ); }); }); + + describe("S3 Deletion", () => { + it("Should not call deleteFromS3 if cost has no attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost without attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toBeCalledTimes(0); + }); + + it("Should call deleteFromS3 once for cost with one attachment", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost with one attachment", + 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", + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toBeCalledTimes(1); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + }); + }); + + it("Should call deleteFromS3 three times for cost with three attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost with multiple attachments", + 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) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toBeCalledTimes(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 only delete S3 files for the specified cost, not others", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + 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, + }, + ]); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/delete1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/delete2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 2, + url: "https://s3.eu-west-1.amazonaws.com/bucket/keep.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toBeCalledTimes(2); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/delete1.pdf", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/delete2.jpg", + }); + expect(deleteFromS3).not.toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/keep.png", + }); + }); + }); }); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts index a3a750e74..754e6eca3 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts @@ -3,6 +3,7 @@ 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 OtherCostsDeleteRoute extends CampaignRoute<{ response: StoplightOperations["delete-campaigns-campaign-finance-otherCosts"]["responses"]["200"]; @@ -53,9 +54,27 @@ export default class OtherCostsDeleteRoute extends CampaignRoute<{ } private async deleteOtherCost(costId: number): Promise { - await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() - .where({ cost_id: costId }) - .delete(); + 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 }); + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ id: attachment.id }) + .delete(); + } catch (e) { + console.error( + `Error deleting attachment from S3: ${attachment.url}`, + e + ); + throw new Error("Error deleting attachment from S3"); + } + } + } await tryber.tables.WpAppqCampaignOtherCosts.do() .where({ id: costId }) From cfa9e02b407ca88b6014c2db4f07d73bcd65c8f4 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 15:06:09 +0100 Subject: [PATCH 5/5] fix: ensure attachments are deleted from the database after S3 deletion --- .../campaignId/finance/otherCosts/_delete/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts index 754e6eca3..45234b047 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts @@ -63,9 +63,6 @@ export default class OtherCostsDeleteRoute extends CampaignRoute<{ for (const attachment of attachments) { try { await deleteFromS3({ url: attachment.url }); - await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() - .where({ id: attachment.id }) - .delete(); } catch (e) { console.error( `Error deleting attachment from S3: ${attachment.url}`, @@ -73,6 +70,9 @@ export default class OtherCostsDeleteRoute extends CampaignRoute<{ ); throw new Error("Error deleting attachment from S3"); } + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ id: attachment.id }) + .delete(); } }