From ba38f8cc7ce8978167a6de9167b9af2ca2af25d3 Mon Sep 17 00:00:00 2001 From: iuliandanea Date: Fri, 25 Oct 2024 18:10:51 +0300 Subject: [PATCH] PER-9883 [back-end] Delete feature flag endpoint New endpoint to delete a feature-flag Unit and functional tests are covering all scenarios --- .../api/docs/present/paths/feature_flags.yaml | 19 +++++ .../api/src/feature_flag/controller.test.ts | 85 ++++++++++++++++++- packages/api/src/feature_flag/controller.ts | 24 +++++- packages/api/src/feature_flag/models.ts | 5 ++ .../queries/delete_feature_flag.sql | 6 ++ .../api/src/feature_flag/service/delete.ts | 28 ++++++ packages/api/src/feature_flag/validators.ts | 13 +++ 7 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/feature_flag/queries/delete_feature_flag.sql create mode 100644 packages/api/src/feature_flag/service/delete.ts diff --git a/packages/api/docs/present/paths/feature_flags.yaml b/packages/api/docs/present/paths/feature_flags.yaml index c80fc71..8a4be20 100644 --- a/packages/api/docs/present/paths/feature_flags.yaml +++ b/packages/api/docs/present/paths/feature_flags.yaml @@ -114,3 +114,22 @@ feature-flags/{id}: $ref: "../../shared/errors.yaml#/400" "500": $ref: "../../shared/errors.yaml#/500" + delete: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + summary: Delete a feature flag + security: + - bearerHttpAuthentication: [] + operationId: delete-feature-flags + responses: + "204": + description: Empty response if delete was successful + "400": + $ref: "../../shared/errors.yaml#/400" + "500": + $ref: "../../shared/errors.yaml#/500" diff --git a/packages/api/src/feature_flag/controller.test.ts b/packages/api/src/feature_flag/controller.test.ts index a88def9..aa014f6 100644 --- a/packages/api/src/feature_flag/controller.test.ts +++ b/packages/api/src/feature_flag/controller.test.ts @@ -8,7 +8,11 @@ import { extractUserIsAdminFromAuthToken, verifyAdminAuthentication, } from "../middleware"; -import type { CreateFeatureFlagRequest, FeatureFlagRequest } from "./models"; +import type { + CreateFeatureFlagRequest, + DeleteFeatureFlagRequest, + FeatureFlagRequest, +} from "./models"; jest.mock("../database"); jest.mock("../middleware"); @@ -283,3 +287,82 @@ describe("POST /feature-flag", () => { expect(logger.error).toHaveBeenCalled(); }); }); + +describe("DELETE /feature-flag/1bdf2da6-026b-4e8e-9b57-a86b1817be5d", () => { + const agent = request(app); + beforeEach(async () => { + (verifyAdminAuthentication as jest.Mock).mockImplementation( + (req: Request, __, next: NextFunction) => { + (req.body as DeleteFeatureFlagRequest).emailFromAuthToken = + "test@permanent.org"; + (req.body as DeleteFeatureFlagRequest).adminSubjectFromAuthToken = + "6b640c73-4963-47de-a096-4a05ff8dc5f5"; + next(); + } + ); + jest.restoreAllMocks(); + jest.clearAllMocks(); + await clearDatabase(); + await loadFixtures(); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + await clearDatabase(); + }); + + test("should respond with a 200 status code", async () => { + await agent + .delete("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d") + .send({}) + .expect(204); + }); + + test("should respond with 401 status code if lacking admin authentication", async () => { + (verifyAdminAuthentication as jest.Mock).mockImplementation( + (_: Request, __, next: NextFunction) => { + next(new createError.Unauthorized("You aren't logged in")); + } + ); + await agent + .delete("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d") + .expect(401); + }); + + test("should delete the feature flag in the database", async () => { + await agent + .delete("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d") + .send({}) + .expect(204); + const result = await db.query( + `SELECT + name + FROM + feature_flag + WHERE + id = '1bdf2da6-026b-4e8e-9b57-a86b1817be5d'` + ); + expect(result.rows.length).toBe(0); + }); + + test("should respond with 500 if the database call fails", async () => { + jest.spyOn(db, "sql").mockImplementation(() => { + throw new Error("SQL error"); + }); + await agent + .delete("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d") + .send({}) + .expect(500); + }); + + test("should log the error if the database call fails", async () => { + const testError = new Error("SQL error"); + jest.spyOn(db, "sql").mockRejectedValueOnce(testError); + await agent + .delete("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d") + .send({}) + .expect(500); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/feature_flag/controller.ts b/packages/api/src/feature_flag/controller.ts index 009e7e3..6b21080 100644 --- a/packages/api/src/feature_flag/controller.ts +++ b/packages/api/src/feature_flag/controller.ts @@ -4,11 +4,15 @@ import type { Request, Response, NextFunction } from "express"; import { logger } from "@stela/logger"; import { featureService } from "./service"; import { createFeatureService } from "./service/create"; +import { + validateCreateFeatureFlagRequest, + validateDeleteFeatureFlagParams, +} from "./validators"; +import { deleteFeatureService } from "./service/delete"; import { extractUserIsAdminFromAuthToken, verifyAdminAuthentication, } from "../middleware"; -import { validateCreateFeatureFlagRequest } from "./validators"; import { validateIsAdminFromAuthentication } from "../validators/shared"; import { isValidationError } from "../validators/validator_util"; @@ -52,3 +56,21 @@ featureController.post( } } ); + +featureController.delete( + "/:featureId", + verifyAdminAuthentication, + async (req: Request, res: Response, next: NextFunction) => { + try { + validateDeleteFeatureFlagParams(req.params); + await deleteFeatureService.deleteFeatureFlag(req.params.featureId); + res.status(204).send(); + } catch (err) { + if (isValidationError(err)) { + res.status(400).json({ error: err.message }); + return; + } + next(err); + } + } +); diff --git a/packages/api/src/feature_flag/models.ts b/packages/api/src/feature_flag/models.ts index 08234ba..3edd3f8 100644 --- a/packages/api/src/feature_flag/models.ts +++ b/packages/api/src/feature_flag/models.ts @@ -21,3 +21,8 @@ export interface CreateFeatureFlagRequest { export interface FeatureFlagRequest { admin: boolean; } + +export interface DeleteFeatureFlagRequest { + emailFromAuthToken: string; + adminSubjectFromAuthToken: string; +} diff --git a/packages/api/src/feature_flag/queries/delete_feature_flag.sql b/packages/api/src/feature_flag/queries/delete_feature_flag.sql new file mode 100644 index 0000000..31b3f50 --- /dev/null +++ b/packages/api/src/feature_flag/queries/delete_feature_flag.sql @@ -0,0 +1,6 @@ +DELETE FROM +feature_flag +WHERE + id = :featureFlagId +RETURNING +id AS "featureFlagId"; diff --git a/packages/api/src/feature_flag/service/delete.ts b/packages/api/src/feature_flag/service/delete.ts new file mode 100644 index 0000000..441328f --- /dev/null +++ b/packages/api/src/feature_flag/service/delete.ts @@ -0,0 +1,28 @@ +import { logger } from "@stela/logger"; +import createError from "http-errors"; +import { db } from "../../database"; + +export const deleteFeatureFlag = async ( + featureFlagId: string +): Promise => { + const result = await db + .sql("feature_flag.queries.delete_feature_flag", { + featureFlagId, + }) + .catch((err) => { + logger.error(err); + throw new createError.InternalServerError( + "Failed to delete feature flag" + ); + }); + + if (result.rows[0] === undefined) { + throw new createError.NotFound("Feature Flag not found"); + } + + return featureFlagId; +}; + +export const deleteFeatureService = { + deleteFeatureFlag, +}; diff --git a/packages/api/src/feature_flag/validators.ts b/packages/api/src/feature_flag/validators.ts index 9a3fff1..93fa7b7 100644 --- a/packages/api/src/feature_flag/validators.ts +++ b/packages/api/src/feature_flag/validators.ts @@ -17,3 +17,16 @@ export function validateCreateFeatureFlagRequest( throw validation.error; } } + +export function validateDeleteFeatureFlagParams( + data: unknown +): asserts data is { featureId: string } { + const validation = Joi.object() + .keys({ + featureId: Joi.string().uuid().required(), + }) + .validate(data); + if (validation.error) { + throw validation.error; + } +}