-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
PER-9718 [back-end] Endpoint for admins to create environment configu…
…ration New endpoint to create a feature-flag. There are validations on the name and description + the name of the flag has to be unique. Unit and functional tests are covering all scenarios
- Loading branch information
iuliandanea
committed
Oct 22, 2024
1 parent
ed5dacd
commit e7a12bf
Showing
8 changed files
with
399 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
19 changes: 19 additions & 0 deletions
19
packages/api/src/feature_flag/queries/create_feature_flag.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
INSERT INTO feature_flag ( | ||
name, | ||
description, | ||
globally_enabled, | ||
created_at, | ||
updated_at | ||
) VALUES ( | ||
:name, | ||
:description, | ||
:globally_enabled, | ||
CURRENT_TIMESTAMP, | ||
CURRENT_TIMESTAMP | ||
) RETURNING | ||
id, | ||
name, | ||
description, | ||
globally_enabled as "globallyEnabled", | ||
created_at AS "createdAt", | ||
updated_at AS "updatedAt"; |
11 changes: 11 additions & 0 deletions
11
packages/api/src/feature_flag/queries/get_feature_flag.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
SELECT | ||
feature_flag.id, | ||
feature_flag.name, | ||
feature_flag.description, | ||
feature_flag.globally_enabled AS "globallyEnabled", | ||
feature_flag.created_at AS "createdAt", | ||
feature_flag.updated_at AS "updatedAt" | ||
FROM | ||
feature_flag | ||
WHERE | ||
feature_flag.name = :name |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,189 @@ | ||
import request from "supertest"; | ||
import createError from "http-errors"; | ||
import type { NextFunction, Request } from "express"; | ||
import { logger } from "@stela/logger"; | ||
import { db } from "../../database"; | ||
import { app } from "../../app"; | ||
import { verifyAdminAuthentication } from "../../middleware"; | ||
import type { CreateFeatureFlagRequest } from "../models"; | ||
|
||
jest.mock("../../middleware/authentication"); | ||
jest.mock("../../database"); | ||
jest.mock("@stela/logger"); | ||
|
||
const loadFixtures = async (): Promise<void> => { | ||
await db.sql("fixtures.create_test_feature_flags"); | ||
}; | ||
|
||
const clearDatabase = async (): Promise<void> => { | ||
await db.query("TRUNCATE feature_flag CASCADE"); | ||
}; | ||
|
||
describe("POST /feature-flag", () => { | ||
const agent = request(app); | ||
beforeEach(async () => { | ||
(verifyAdminAuthentication as jest.Mock).mockImplementation( | ||
(req: Request, __, next: NextFunction) => { | ||
(req.body as CreateFeatureFlagRequest).emailFromAuthToken = | ||
"test@permanent.org"; | ||
(req.body as CreateFeatureFlagRequest).adminSubjectFromAuthToken = | ||
"6b640c73-4963-47de-a096-4a05ff8dc5f5"; | ||
next(); | ||
} | ||
); | ||
jest.restoreAllMocks(); | ||
jest.clearAllMocks(); | ||
await loadFixtures(); | ||
await clearDatabase(); | ||
}); | ||
|
||
afterEach(async () => { | ||
jest.restoreAllMocks(); | ||
jest.clearAllMocks(); | ||
await clearDatabase(); | ||
}); | ||
|
||
test("should respond with a 200 status code", async () => { | ||
await agent | ||
.post("/api/v2/feature-flags") | ||
.send({ | ||
name: "TEST", | ||
description: "description", | ||
}) | ||
.expect(200); | ||
}); | ||
|
||
test("should respond with a 400 if feature flag already exists", async () => { | ||
await agent | ||
.post("/api/v2/feature-flags") | ||
.send({ | ||
name: "TEST", | ||
description: "description", | ||
}) | ||
.expect(200); | ||
|
||
// trying to create again the same feature flag should fail | ||
await agent | ||
.post("/api/v2/feature-flags") | ||
.send({ | ||
name: "TEST", | ||
description: "description", | ||
}) | ||
.expect(400); | ||
}); | ||
|
||
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.post("/api/v2/feature-flags").expect(401); | ||
}); | ||
|
||
test("should respond with 400 status code if missing emailFromAuthToken", async () => { | ||
(verifyAdminAuthentication as jest.Mock).mockImplementation( | ||
(req: Request, __, next: NextFunction) => { | ||
(req.body as CreateFeatureFlagRequest).adminSubjectFromAuthToken = | ||
"6b640c73-4963-47de-a096-4a05ff8dc5f5"; | ||
next(); | ||
} | ||
); | ||
await agent | ||
.post("/api/v2/feature-flags") | ||
.send({ | ||
name: "TEST", | ||
description: "description", | ||
}) | ||
.expect(400); | ||
}); | ||
|
||
test("should respond with 400 status code if emailFromAuthToken is not a string", async () => { | ||
(verifyAdminAuthentication as jest.Mock).mockImplementation( | ||
(req: Request, __, next: NextFunction) => { | ||
(req.body as { emailFromAuthToken: number }).emailFromAuthToken = 123; | ||
(req.body as CreateFeatureFlagRequest).adminSubjectFromAuthToken = | ||
"6b640c73-4963-47de-a096-4a05ff8dc5f5"; | ||
next(); | ||
} | ||
); | ||
await agent | ||
.post("/api/v2/feature-flags") | ||
.send({ | ||
name: "TEST", | ||
description: "description", | ||
}) | ||
.expect(400); | ||
}); | ||
|
||
test("should respond with 400 status name if code is missing", async () => { | ||
await agent | ||
.post("/api/v2/feature-flags") | ||
.send({ | ||
description: "description", | ||
}) | ||
.expect(400); | ||
}); | ||
|
||
test("should respond with 400 status code if name is not a string", async () => { | ||
await agent | ||
.post("/api/v2/feature-flags") | ||
.send({ | ||
name: 123, | ||
description: "description", | ||
}) | ||
.expect(400); | ||
}); | ||
|
||
test("should store the new feature flag in the database", async () => { | ||
await agent | ||
.post("/api/v2/feature-flags") | ||
.send({ | ||
name: "name", | ||
description: "description", | ||
}) | ||
.expect(200); | ||
const result = await db.query( | ||
`SELECT | ||
name, | ||
description, | ||
globally_enabled::boolean as "globallyEnabled" | ||
FROM | ||
feature_flag | ||
WHERE | ||
name = 'name'` | ||
); | ||
expect(result.rows.length).toBe(1); | ||
expect(result.rows[0]).toEqual({ | ||
name: "name", | ||
description: "description", | ||
globallyEnabled: true, | ||
}); | ||
}); | ||
|
||
test("should respond with 500 if the database call fails", async () => { | ||
jest.spyOn(db, "sql").mockImplementation(() => { | ||
throw new Error("SQL error"); | ||
}); | ||
await agent | ||
.post("/api/v2/feature-flags") | ||
.send({ | ||
name: "name", | ||
description: "description", | ||
}) | ||
.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 | ||
.post("/api/v2/feature-flags") | ||
.send({ | ||
name: "name", | ||
description: "description", | ||
}) | ||
.expect(500); | ||
expect(logger.error).toHaveBeenCalled(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import { logger } from "@stela/logger"; | ||
import createError from "http-errors"; | ||
import type { CreateFeatureFlagRequest, FeatureFlagRow } from "../models"; | ||
import { db } from "../../database"; | ||
|
||
export const createFeatureFlag = async ( | ||
featureFlagData: CreateFeatureFlagRequest | ||
): Promise<FeatureFlagRow> => { | ||
const featureFlagsWithName = async (name: string): Promise<boolean> => { | ||
const result = await db | ||
.sql<FeatureFlagRow>("feature_flag.queries.get_feature_flag", { | ||
name, | ||
}) | ||
.catch((err) => { | ||
logger.error(err); | ||
throw new createError.InternalServerError( | ||
"Failed to retrieve feature flags" | ||
); | ||
}); | ||
|
||
return result.rows.length > 0; | ||
}; | ||
|
||
if (await featureFlagsWithName(featureFlagData.name)) { | ||
throw new createError.BadRequest("Feature flag already exists"); | ||
} | ||
|
||
const result = await db | ||
.sql<FeatureFlagRow>("feature_flag.queries.create_feature_flag", { | ||
name: featureFlagData.name, | ||
description: featureFlagData.description, | ||
globally_enabled: true, | ||
}) | ||
.catch((err) => { | ||
logger.error(err); | ||
throw new createError.InternalServerError( | ||
"Failed to create feature flag" | ||
); | ||
}); | ||
|
||
if (result.rows[0] === undefined) { | ||
throw new createError.InternalServerError("Failed to create feature flag"); | ||
} | ||
|
||
return result.rows[0]; | ||
}; | ||
|
||
export const createFeatureService = { | ||
createFeatureFlag, | ||
}; |
Oops, something went wrong.