feature/IO-3701-Harness-Replacement - Implement
This commit is contained in:
121
server/admin/adminops.feature-flags.test.js
Normal file
121
server/admin/adminops.feature-flags.test.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Creates the minimal Express response mock needed by admin route handlers.
|
||||
*/
|
||||
const createResponse = () => {
|
||||
const res = {
|
||||
body: null,
|
||||
code: 200,
|
||||
json: vi.fn((body) => {
|
||||
res.body = body;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn((code) => {
|
||||
res.code = code;
|
||||
return res;
|
||||
})
|
||||
};
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads admin operations with CommonJS dependency cache overrides for isolated route tests.
|
||||
*/
|
||||
const loadAdminOps = async ({ request }) => {
|
||||
const adminOpsPath = require.resolve("./adminops");
|
||||
const graphqlClientPath = require.resolve("../graphql-client/graphql-client");
|
||||
const loggerPath = require.resolve("../utils/logger");
|
||||
const socketEventsPath = require.resolve("../feature-flags/socket-events");
|
||||
|
||||
delete require.cache[adminOpsPath];
|
||||
require.cache[graphqlClientPath] = {
|
||||
exports: {
|
||||
client: {
|
||||
request
|
||||
}
|
||||
}
|
||||
};
|
||||
require.cache[loggerPath] = {
|
||||
exports: {
|
||||
log: vi.fn()
|
||||
}
|
||||
};
|
||||
require.cache[socketEventsPath] = {
|
||||
exports: {
|
||||
emitFeatureFlagsChanged: vi.fn()
|
||||
}
|
||||
};
|
||||
|
||||
return require("./adminops");
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete require.cache[require.resolve("./adminops")];
|
||||
delete require.cache[require.resolve("../graphql-client/graphql-client")];
|
||||
delete require.cache[require.resolve("../utils/logger")];
|
||||
delete require.cache[require.resolve("../feature-flags/socket-events")];
|
||||
});
|
||||
|
||||
describe("feature flag admin delete guard", () => {
|
||||
it("does not delete a feature flag assigned to shops", async () => {
|
||||
const request = vi.fn(async () => ({
|
||||
bodyshop_feature_flags_aggregate: {
|
||||
aggregate: {
|
||||
count: 2
|
||||
}
|
||||
}
|
||||
}));
|
||||
const { deleteFeatureFlag } = await loadAdminOps({ request });
|
||||
const req = {
|
||||
params: { name: "Enhanced_Payroll" },
|
||||
user: { email: "admin@example.com" }
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await deleteFeatureFlag(req, res);
|
||||
|
||||
expect(res.code).toBe(409);
|
||||
expect(res.body).toEqual({
|
||||
error: "Feature flag Enhanced_Payroll is assigned to 2 shops. Remove shop assignments before deleting it.",
|
||||
assignmentCount: 2
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deletes a feature flag with no shop assignments", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
bodyshop_feature_flags_aggregate: {
|
||||
aggregate: {
|
||||
count: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
delete_feature_flags_by_pk: {
|
||||
name: "Unused_Flag"
|
||||
}
|
||||
});
|
||||
const { deleteFeatureFlag } = await loadAdminOps({ request });
|
||||
const req = {
|
||||
params: { name: "Unused_Flag" },
|
||||
sessionUtils: {
|
||||
invalidateAllBodyshopFeatureFlagsInRedis: vi.fn()
|
||||
},
|
||||
user: { email: "admin@example.com" }
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await deleteFeatureFlag(req, res);
|
||||
|
||||
expect(res.body).toEqual({ name: "Unused_Flag" });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(req.sessionUtils.invalidateAllBodyshopFeatureFlagsInRedis).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
const logger = require("../utils/logger");
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
const {
|
||||
sanitizeFeatureFlagCreatePayload,
|
||||
sanitizeFeatureFlagUpdatePayload
|
||||
} = require("../feature-flags/admin-payload");
|
||||
const { emitFeatureFlagsChanged } = require("../feature-flags/socket-events");
|
||||
|
||||
exports.createAssociation = async (req, res) => {
|
||||
logger.log("admin-create-association", "debug", req.user.email, null, {
|
||||
@@ -112,12 +117,455 @@ exports.updateCounter = async (req, res) => {
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists feature flag definitions for the admin UI, optionally including inactive flags.
|
||||
*/
|
||||
exports.getFeatureFlags = async (req, res) => {
|
||||
logger.log("admin-get-feature-flags", "debug", req.user.email, null, {
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const includeInactive = req.query?.includeInactive === "true";
|
||||
const result = await client.request(
|
||||
`query GET_FEATURE_FLAGS($where: feature_flags_bool_exp!) {
|
||||
feature_flags(where: $where, order_by: { name: asc }) {
|
||||
name
|
||||
description
|
||||
default_treatment
|
||||
active
|
||||
created_at
|
||||
updated_at
|
||||
bodyshop_feature_flags_aggregate {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ where: includeInactive ? {} : { active: { _eq: true } } }
|
||||
);
|
||||
res.json(result.feature_flags || []);
|
||||
} catch (error) {
|
||||
logger.log("admin-get-feature-flags-error", "error", req.user.email, null, {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a global feature flag definition and invalidates all runtime flag caches.
|
||||
*/
|
||||
exports.createFeatureFlag = async (req, res) => {
|
||||
logger.log("admin-create-feature-flag", "debug", req.user.email, null, {
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const featureFlag = sanitizeFeatureFlagCreatePayload(req.body);
|
||||
const result = await client.request(
|
||||
`mutation CREATE_FEATURE_FLAG($featureFlag: feature_flags_insert_input!) {
|
||||
insert_feature_flags_one(object: $featureFlag) {
|
||||
name
|
||||
description
|
||||
default_treatment
|
||||
active
|
||||
}
|
||||
}`,
|
||||
{ featureFlag }
|
||||
);
|
||||
await req.sessionUtils?.invalidateAllBodyshopFeatureFlagsInRedis?.();
|
||||
emitFeatureFlagsChanged({ req, source: "admin", table: "feature_flags", name: featureFlag.name });
|
||||
res.status(201).json(result.insert_feature_flags_one);
|
||||
} catch (error) {
|
||||
logger.log("admin-create-feature-flag-error", "error", req.user.email, null, {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates editable fields on a global feature flag definition.
|
||||
*/
|
||||
exports.updateFeatureFlag = async (req, res) => {
|
||||
logger.log("admin-update-feature-flag", "debug", req.user.email, null, {
|
||||
name: req.params.name,
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const featureFlag = sanitizeFeatureFlagUpdatePayload(req.body);
|
||||
if (Object.keys(featureFlag).length === 0) {
|
||||
return res.status(400).json({ error: "No editable feature flag fields were provided." });
|
||||
}
|
||||
|
||||
const result = await client.request(
|
||||
`mutation UPDATE_FEATURE_FLAG($name: String!, $featureFlag: feature_flags_set_input!) {
|
||||
update_feature_flags_by_pk(pk_columns: { name: $name }, _set: $featureFlag) {
|
||||
name
|
||||
description
|
||||
default_treatment
|
||||
active
|
||||
}
|
||||
}`,
|
||||
{ name: req.params.name, featureFlag }
|
||||
);
|
||||
await req.sessionUtils?.invalidateAllBodyshopFeatureFlagsInRedis?.();
|
||||
emitFeatureFlagsChanged({ req, source: "admin", table: "feature_flags", name: req.params.name });
|
||||
res.json(result.update_feature_flags_by_pk);
|
||||
} catch (error) {
|
||||
logger.log("admin-update-feature-flag-error", "error", req.user.email, null, {
|
||||
name: req.params.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes an unassigned feature flag definition after confirming no bodyshop assignments exist.
|
||||
*/
|
||||
exports.deleteFeatureFlag = async (req, res) => {
|
||||
logger.log("admin-delete-feature-flag", "debug", req.user.email, null, {
|
||||
name: req.params.name,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const assignmentResult = await client.request(
|
||||
`query COUNT_FEATURE_FLAG_ASSIGNMENTS($name: String!) {
|
||||
bodyshop_feature_flags_aggregate(where: { name: { _eq: $name } }) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ name: req.params.name }
|
||||
);
|
||||
const assignmentCount = assignmentResult.bodyshop_feature_flags_aggregate?.aggregate?.count || 0;
|
||||
|
||||
if (assignmentCount > 0) {
|
||||
return res.status(409).json({
|
||||
error: `Feature flag ${req.params.name} is assigned to ${assignmentCount} shop${
|
||||
assignmentCount === 1 ? "" : "s"
|
||||
}. Remove shop assignments before deleting it.`,
|
||||
assignmentCount
|
||||
});
|
||||
}
|
||||
|
||||
const result = await client.request(
|
||||
`mutation DELETE_FEATURE_FLAG($name: String!) {
|
||||
delete_feature_flags_by_pk(name: $name) {
|
||||
name
|
||||
}
|
||||
}`,
|
||||
{ name: req.params.name }
|
||||
);
|
||||
await req.sessionUtils?.invalidateAllBodyshopFeatureFlagsInRedis?.();
|
||||
emitFeatureFlagsChanged({ req, source: "admin", table: "feature_flags", name: req.params.name });
|
||||
res.json(result.delete_feature_flags_by_pk);
|
||||
} catch (error) {
|
||||
logger.log("admin-delete-feature-flag-error", "error", req.user.email, null, {
|
||||
name: req.params.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists feature flag assignments for one bodyshop in the admin UI.
|
||||
*/
|
||||
exports.getBodyshopFeatureFlags = async (req, res) => {
|
||||
const bodyshopId = req.params.bodyshopId;
|
||||
|
||||
logger.log("admin-get-bodyshop-feature-flags", "debug", req.user.email, null, {
|
||||
bodyshopId,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.request(
|
||||
`query GET_BODYSHOP_FEATURE_FLAGS($bodyshopid: uuid!) {
|
||||
bodyshop_feature_flags(where: { bodyshopid: { _eq: $bodyshopid } }, order_by: { name: asc }) {
|
||||
name
|
||||
treatment
|
||||
config
|
||||
activeDate
|
||||
deactiveDate
|
||||
}
|
||||
}`,
|
||||
{ bodyshopid: bodyshopId }
|
||||
);
|
||||
|
||||
res.json(result.bodyshop_feature_flags || []);
|
||||
} catch (error) {
|
||||
logger.log("admin-get-bodyshop-feature-flags-error", "error", req.user.email, null, {
|
||||
bodyshopId,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists bodyshop assignments for one feature flag definition.
|
||||
*/
|
||||
exports.getFeatureFlagBodyshops = async (req, res) => {
|
||||
const name = req.params.name;
|
||||
|
||||
logger.log("admin-get-feature-flag-bodyshops", "debug", req.user.email, null, {
|
||||
name,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.request(
|
||||
`query GET_FEATURE_FLAG_BODYSHOPS($name: String!) {
|
||||
bodyshop_feature_flags(where: { name: { _eq: $name } }, order_by: { bodyshop: { shopname: asc } }) {
|
||||
id
|
||||
bodyshopid
|
||||
name
|
||||
treatment
|
||||
config
|
||||
activeDate
|
||||
deactiveDate
|
||||
bodyshop {
|
||||
id
|
||||
shopname
|
||||
imexshopid
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ name }
|
||||
);
|
||||
|
||||
res.json(result.bodyshop_feature_flags || []);
|
||||
} catch (error) {
|
||||
logger.log("admin-get-feature-flag-bodyshops-error", "error", req.user.email, null, {
|
||||
name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the set of bodyshops assigned to one feature flag and refreshes affected clients.
|
||||
*/
|
||||
exports.updateFeatureFlagBodyshops = async (req, res) => {
|
||||
const name = req.params.name;
|
||||
const assignments = Array.isArray(req.body?.assignments) ? req.body.assignments : [];
|
||||
const bodyshopIds = assignments.map((assignment) => assignment.bodyshopid).filter(Boolean);
|
||||
const objects = assignments
|
||||
.filter((assignment) => assignment.bodyshopid)
|
||||
.map((assignment) => ({
|
||||
bodyshopid: assignment.bodyshopid,
|
||||
name,
|
||||
treatment: assignment.treatment || "on",
|
||||
config: assignment.config ?? null,
|
||||
activeDate: assignment.activeDate || null,
|
||||
deactiveDate: assignment.deactiveDate || null
|
||||
}));
|
||||
|
||||
logger.log("admin-update-feature-flag-bodyshops", "debug", req.user.email, null, {
|
||||
name,
|
||||
assignmentCount: objects.length,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.request(
|
||||
`mutation UPDATE_FEATURE_FLAG_BODYSHOPS(
|
||||
$name: String!,
|
||||
$bodyshopIds: [uuid!]!,
|
||||
$objects: [bodyshop_feature_flags_insert_input!]!
|
||||
) {
|
||||
delete_bodyshop_feature_flags(where: { name: { _eq: $name }, bodyshopid: { _nin: $bodyshopIds } }) {
|
||||
affected_rows
|
||||
returning {
|
||||
bodyshopid
|
||||
}
|
||||
}
|
||||
insert_bodyshop_feature_flags(
|
||||
objects: $objects,
|
||||
on_conflict: {
|
||||
constraint: bodyshop_feature_flags_bodyshopid_name_key,
|
||||
update_columns: [treatment, config, activeDate, deactiveDate]
|
||||
}
|
||||
) {
|
||||
affected_rows
|
||||
returning {
|
||||
bodyshopid
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ name, bodyshopIds, objects }
|
||||
);
|
||||
|
||||
const changedBodyshopIds = [
|
||||
...(result.delete_bodyshop_feature_flags?.returning || []),
|
||||
...(result.insert_bodyshop_feature_flags?.returning || [])
|
||||
].map((row) => row.bodyshopid);
|
||||
|
||||
await Promise.all(
|
||||
Array.from(new Set(changedBodyshopIds)).map(async (bodyshopId) => {
|
||||
await req.sessionUtils?.invalidateBodyshopFeatureFlagsInRedis?.(bodyshopId);
|
||||
emitFeatureFlagsChanged({ req, bodyshopId, source: "admin", table: "bodyshop_feature_flags", name });
|
||||
})
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.log("admin-update-feature-flag-bodyshops-error", "error", req.user.email, null, {
|
||||
name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
assignmentCount: objects.length,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
const isPlainObject = (value) => value && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
/**
|
||||
* Normalizes legacy feature flag values into persisted treatment strings.
|
||||
*/
|
||||
const normalizeTreatment = (value, fallback = "on") => {
|
||||
if (typeof value === "string") {
|
||||
return value.trim() || fallback;
|
||||
}
|
||||
|
||||
if (value === false) return "off";
|
||||
if (value === true) return "on";
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a legacy bodyshop feature flag map into table rows.
|
||||
*/
|
||||
const normalizeFeatureFlagRows = (featureFlags = {}) =>
|
||||
Object.entries(featureFlags)
|
||||
.filter(([name]) => Boolean(name))
|
||||
.map(([name, value]) => {
|
||||
if (isPlainObject(value)) {
|
||||
return {
|
||||
name,
|
||||
treatment: value.enabled === false ? "off" : normalizeTreatment(value.treatment),
|
||||
config: value.config ?? null,
|
||||
activeDate: value.activeDate || null,
|
||||
deactiveDate: value.deactiveDate || null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
treatment: normalizeTreatment(value),
|
||||
config: null,
|
||||
activeDate: null,
|
||||
deactiveDate: null
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Separates feature flag assignment edits from the bodyshop update payload.
|
||||
*/
|
||||
const splitBodyshopUpdate = (bodyshop) => {
|
||||
const featureFlags = bodyshop?.featureFlags || bodyshop?.features?.featureFlags;
|
||||
const nextBodyshop = { ...bodyshop };
|
||||
|
||||
delete nextBodyshop.featureFlags;
|
||||
|
||||
if (bodyshop?.features && hasOwn(bodyshop.features, "featureFlags")) {
|
||||
nextBodyshop.features = { ...bodyshop.features };
|
||||
delete nextBodyshop.features.featureFlags;
|
||||
}
|
||||
|
||||
return { bodyshop: nextBodyshop, featureFlags };
|
||||
};
|
||||
|
||||
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||
|
||||
/**
|
||||
* Upserts or clears all feature flag assignments for one bodyshop.
|
||||
*/
|
||||
async function saveBodyshopFeatureFlags({ bodyshopId, featureFlags }) {
|
||||
if (!isPlainObject(featureFlags)) return null;
|
||||
|
||||
const rows = normalizeFeatureFlagRows(featureFlags);
|
||||
const flagNames = rows.map((row) => row.name);
|
||||
const objects = rows.map((row) => ({
|
||||
bodyshopid: bodyshopId,
|
||||
name: row.name,
|
||||
treatment: row.treatment,
|
||||
config: row.config,
|
||||
activeDate: row.activeDate,
|
||||
deactiveDate: row.deactiveDate
|
||||
}));
|
||||
|
||||
if (objects.length === 0) {
|
||||
return client.request(
|
||||
`mutation DELETE_BODYSHOP_FEATURE_FLAGS($bodyshopid: uuid!) {
|
||||
delete_bodyshop_feature_flags(where: { bodyshopid: { _eq: $bodyshopid } }) {
|
||||
affected_rows
|
||||
}
|
||||
}`,
|
||||
{ bodyshopid: bodyshopId }
|
||||
);
|
||||
}
|
||||
|
||||
return client.request(
|
||||
`mutation UPSERT_BODYSHOP_FEATURE_FLAGS(
|
||||
$bodyshopid: uuid!,
|
||||
$flagNames: [String!]!,
|
||||
$objects: [bodyshop_feature_flags_insert_input!]!
|
||||
) {
|
||||
delete_bodyshop_feature_flags(where: { bodyshopid: { _eq: $bodyshopid }, name: { _nin: $flagNames } }) {
|
||||
affected_rows
|
||||
}
|
||||
insert_bodyshop_feature_flags(
|
||||
objects: $objects,
|
||||
on_conflict: {
|
||||
constraint: bodyshop_feature_flags_bodyshopid_name_key,
|
||||
update_columns: [treatment, config, activeDate, deactiveDate]
|
||||
}
|
||||
) {
|
||||
affected_rows
|
||||
}
|
||||
}`,
|
||||
{ bodyshopid: bodyshopId, flagNames, objects }
|
||||
);
|
||||
}
|
||||
|
||||
exports.updateShop = async (req, res) => {
|
||||
logger.log("admin-update-shop", "debug", req.user.email, null, {
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
const { id, bodyshop } = req.body;
|
||||
const { id } = req.body;
|
||||
const { bodyshop, featureFlags } = splitBodyshopUpdate(req.body.bodyshop);
|
||||
|
||||
try {
|
||||
const result = await client.request(
|
||||
@@ -132,6 +580,13 @@ exports.updateShop = async (req, res) => {
|
||||
bodyshop
|
||||
}
|
||||
);
|
||||
|
||||
const featureFlagResult = await saveBodyshopFeatureFlags({ bodyshopId: id, featureFlags });
|
||||
if (featureFlagResult) {
|
||||
await req.sessionUtils?.invalidateBodyshopFeatureFlagsInRedis?.(id);
|
||||
emitFeatureFlagsChanged({ req, bodyshopId: id, source: "admin", table: "bodyshop_feature_flags" });
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json(error);
|
||||
|
||||
44
server/feature-flags/admin-payload.js
Normal file
44
server/feature-flags/admin-payload.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const CREATE_FIELDS = ["name", "description", "default_treatment", "active"];
|
||||
const UPDATE_FIELDS = ["description", "default_treatment", "active"];
|
||||
|
||||
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||
|
||||
/**
|
||||
* Trims stable feature flag identity fields while leaving free-form text unchanged.
|
||||
*/
|
||||
const normalizeStringField = (key, value) => {
|
||||
if (value == null) return value;
|
||||
if (key !== "name" && key !== "default_treatment") return value;
|
||||
return typeof value === "string" ? value.trim() : value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whitelists fields accepted by feature flag definition create/update admin endpoints.
|
||||
*/
|
||||
const pickFeatureFlagFields = (input, allowedFields) => {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return allowedFields.reduce((payload, field) => {
|
||||
if (hasOwn(input, field)) {
|
||||
payload[field] = normalizeStringField(field, input[field]);
|
||||
}
|
||||
return payload;
|
||||
}, {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a safe payload for creating a feature flag definition.
|
||||
*/
|
||||
const sanitizeFeatureFlagCreatePayload = (input) => pickFeatureFlagFields(input, CREATE_FIELDS);
|
||||
|
||||
/**
|
||||
* Builds a safe payload for updating editable feature flag definition fields.
|
||||
*/
|
||||
const sanitizeFeatureFlagUpdatePayload = (input) => pickFeatureFlagFields(input, UPDATE_FIELDS);
|
||||
|
||||
module.exports = {
|
||||
sanitizeFeatureFlagCreatePayload,
|
||||
sanitizeFeatureFlagUpdatePayload
|
||||
};
|
||||
53
server/feature-flags/admin-payload.test.js
Normal file
53
server/feature-flags/admin-payload.test.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
sanitizeFeatureFlagCreatePayload,
|
||||
sanitizeFeatureFlagUpdatePayload
|
||||
} = require("./admin-payload");
|
||||
|
||||
describe("feature flag admin payload sanitizing", () => {
|
||||
it("keeps only editable create fields and trims stable text keys", () => {
|
||||
expect(
|
||||
sanitizeFeatureFlagCreatePayload({
|
||||
name: " TEST_FLAG ",
|
||||
description: "Manual test flag",
|
||||
default_treatment: " variant-a ",
|
||||
active: false,
|
||||
created_at: "2026-05-19T00:00:00.000Z",
|
||||
bodyshop_feature_flags_aggregate: { aggregate: { count: 3 } }
|
||||
})
|
||||
).toEqual({
|
||||
name: "TEST_FLAG",
|
||||
description: "Manual test flag",
|
||||
default_treatment: "variant-a",
|
||||
active: false
|
||||
});
|
||||
});
|
||||
|
||||
it("strips name from update payloads so flag keys cannot be renamed", () => {
|
||||
expect(
|
||||
sanitizeFeatureFlagUpdatePayload({
|
||||
name: "Renamed_Flag",
|
||||
description: null,
|
||||
default_treatment: " on ",
|
||||
active: true,
|
||||
updated_at: "2026-05-19T00:00:00.000Z"
|
||||
})
|
||||
).toEqual({
|
||||
description: null,
|
||||
default_treatment: "on",
|
||||
active: true
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty update payload when no editable fields are present", () => {
|
||||
expect(
|
||||
sanitizeFeatureFlagUpdatePayload({
|
||||
name: "Only_Name",
|
||||
created_at: "2026-05-19T00:00:00.000Z"
|
||||
})
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
54
server/feature-flags/export-harness-feature-flags.test.js
Normal file
54
server/feature-flags/export-harness-feature-flags.test.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildImportSql, normalizeTreatment, sqlString } = require("../../scripts/export-harness-feature-flags");
|
||||
|
||||
describe("Harness feature flag exporter", () => {
|
||||
it("preserves custom treatment names while normalizing booleans and known treatments", () => {
|
||||
expect(normalizeTreatment(true)).toBe("on");
|
||||
expect(normalizeTreatment(false)).toBe("off");
|
||||
expect(normalizeTreatment(" ON ")).toBe("on");
|
||||
expect(normalizeTreatment("false")).toBe("off");
|
||||
expect(normalizeTreatment("control")).toBe("control");
|
||||
expect(normalizeTreatment("variant-a")).toBe("variant-a");
|
||||
expect(normalizeTreatment(" custom treatment ")).toBe("custom treatment");
|
||||
expect(normalizeTreatment(null)).toBe("control");
|
||||
expect(normalizeTreatment("")).toBe("control");
|
||||
});
|
||||
|
||||
it("escapes SQL string values", () => {
|
||||
expect(sqlString("Dave's Shop")).toBe("'Dave''s Shop'");
|
||||
});
|
||||
|
||||
it("escapes custom treatments in generated import SQL", () => {
|
||||
const sql = buildImportSql([
|
||||
{
|
||||
customerKey: "SHOP'1",
|
||||
imexshopid: "SHOP'1",
|
||||
name: "Demo'Flag",
|
||||
treatment: "pilot's-choice",
|
||||
config: { text: "Dave's config" }
|
||||
}
|
||||
]);
|
||||
|
||||
expect(sql).toContain("('SHOP''1', 'Demo''Flag', 'pilot''s-choice'");
|
||||
expect(sql).toContain(`'{"text":"Dave''s config"}'::jsonb`);
|
||||
});
|
||||
|
||||
it("includes an unmatched feature flag report query", () => {
|
||||
const sql = buildImportSql([
|
||||
{
|
||||
customerKey: "SHOP1",
|
||||
imexshopid: "SHOP1",
|
||||
name: "Missing_Flag",
|
||||
treatment: "on",
|
||||
config: null
|
||||
}
|
||||
]);
|
||||
|
||||
expect(sql).toContain('AS "unmatched_feature_flag"');
|
||||
expect(sql).toContain('LEFT JOIN "public"."feature_flags"');
|
||||
expect(sql).toContain("('Missing_Flag')");
|
||||
});
|
||||
});
|
||||
144
server/feature-flags/feature-flags.js
Normal file
144
server/feature-flags/feature-flags.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const logger = require("../utils/logger");
|
||||
const { CHECK_BODYSHOP_ACCESS, GET_BODYSHOP_FEATURE_FLAGS } = require("../graphql-client/queries");
|
||||
const { emitFeatureFlagsChanged } = require("./socket-events");
|
||||
|
||||
/**
|
||||
* Indicates whether verbose feature flag route logging should be enabled.
|
||||
*/
|
||||
const isDevelopment = () => process.env.NODE_ENV === "development";
|
||||
|
||||
/**
|
||||
* Combines global feature flag definitions with per-bodyshop assignments into the runtime flag map.
|
||||
*/
|
||||
const toFlagMap = ({ feature_flags: definitions = [], bodyshop_feature_flags: assignments = [] }) => {
|
||||
const flags = definitions.reduce((acc, definition) => {
|
||||
acc[definition.name] = {
|
||||
treatment: definition.default_treatment || "off",
|
||||
config: null
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
for (const assignment of assignments) {
|
||||
flags[assignment.name] = {
|
||||
treatment: assignment.treatment,
|
||||
config: assignment.config ?? null,
|
||||
activeDate: assignment.activeDate ?? null,
|
||||
deactiveDate: assignment.deactiveDate ?? null
|
||||
};
|
||||
}
|
||||
|
||||
return flags;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies that the authenticated user can read the requested bodyshop through Hasura permissions.
|
||||
*/
|
||||
async function assertBodyshopAccess({ req, bodyshopId }) {
|
||||
const result = await req.userGraphQLClient.request(CHECK_BODYSHOP_ACCESS, { id: bodyshopId });
|
||||
|
||||
if (!result.bodyshops_by_pk?.id) {
|
||||
const error = new Error("Feature flag bodyshop access denied");
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves runtime feature flags for one bodyshop with Redis read-through caching.
|
||||
*/
|
||||
async function getBodyshopFeatureFlags(req, res) {
|
||||
const bodyshopId = req.params.bodyshopId;
|
||||
const {
|
||||
getBodyshopFeatureFlagsCacheVersion,
|
||||
getBodyshopFeatureFlagsFromRedis,
|
||||
setBodyshopFeatureFlagsInRedis
|
||||
} = req.sessionUtils || {};
|
||||
|
||||
try {
|
||||
await assertBodyshopAccess({ req, bodyshopId });
|
||||
|
||||
const cacheVersion = getBodyshopFeatureFlagsCacheVersion
|
||||
? await getBodyshopFeatureFlagsCacheVersion()
|
||||
: null;
|
||||
const cachedFlags = getBodyshopFeatureFlagsFromRedis
|
||||
? await getBodyshopFeatureFlagsFromRedis(bodyshopId, cacheVersion)
|
||||
: null;
|
||||
|
||||
if (cachedFlags) {
|
||||
if (isDevelopment()) {
|
||||
logger.log("feature-flags-route-hit", "DEBUG", req.user?.email, null, {
|
||||
bodyshopId,
|
||||
source: "redis",
|
||||
flagCount: Object.keys(cachedFlags.flags || {}).length
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ ...cachedFlags, source: "redis" });
|
||||
}
|
||||
|
||||
const result = await req.userGraphQLClient.request(GET_BODYSHOP_FEATURE_FLAGS, { bodyshopid: bodyshopId });
|
||||
const payload = {
|
||||
bodyshopId,
|
||||
flags: toFlagMap(result),
|
||||
cachedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (setBodyshopFeatureFlagsInRedis) {
|
||||
await setBodyshopFeatureFlagsInRedis(bodyshopId, payload, cacheVersion);
|
||||
}
|
||||
|
||||
if (isDevelopment()) {
|
||||
logger.log("feature-flags-route-hit", "DEBUG", req.user?.email, null, {
|
||||
bodyshopId,
|
||||
source: "database",
|
||||
flagCount: Object.keys(payload.flags || {}).length
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ ...payload, source: "database" });
|
||||
} catch (error) {
|
||||
const statusCode = error.statusCode || 500;
|
||||
logger.log("get-bodyshop-feature-flags-error", "ERROR", req.user?.email, null, {
|
||||
bodyshopId,
|
||||
statusCode,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
return res.status(statusCode).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Hasura/admin cache invalidation events and notifies connected clients.
|
||||
*/
|
||||
async function invalidateBodyshopFeatureFlags(req, res) {
|
||||
const bodyshopId = req.body?.event?.data?.new?.bodyshopid || req.body?.event?.data?.old?.bodyshopid || req.body?.bodyshopid;
|
||||
const tableName = req.body?.event?.table?.name;
|
||||
const flagName = req.body?.event?.data?.new?.name || req.body?.event?.data?.old?.name || req.body?.name || null;
|
||||
|
||||
try {
|
||||
if (bodyshopId && req.sessionUtils?.invalidateBodyshopFeatureFlagsInRedis) {
|
||||
await req.sessionUtils.invalidateBodyshopFeatureFlagsInRedis(bodyshopId);
|
||||
emitFeatureFlagsChanged({ req, bodyshopId, source: "hasura", table: tableName, name: flagName });
|
||||
return res.status(200).json({ ok: true, bodyshopId });
|
||||
}
|
||||
|
||||
const invalidated = await req.sessionUtils?.invalidateAllBodyshopFeatureFlagsInRedis?.();
|
||||
emitFeatureFlagsChanged({ req, source: "hasura", table: tableName, name: flagName });
|
||||
return res.status(200).json({ ok: true, table: tableName, cacheVersion: invalidated || 0 });
|
||||
} catch (error) {
|
||||
logger.log("invalidate-bodyshop-feature-flags-error", "ERROR", "feature-flags", null, {
|
||||
bodyshopId,
|
||||
tableName,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBodyshopFeatureFlags,
|
||||
invalidateBodyshopFeatureFlags
|
||||
};
|
||||
197
server/feature-flags/feature-flags.test.js
Normal file
197
server/feature-flags/feature-flags.test.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { getBodyshopFeatureFlags, invalidateBodyshopFeatureFlags } = require("./feature-flags");
|
||||
|
||||
/**
|
||||
* Creates the minimal Express response mock needed by feature flag route tests.
|
||||
*/
|
||||
const createResponse = () => {
|
||||
const res = {
|
||||
body: null,
|
||||
code: 200,
|
||||
json: vi.fn((body) => {
|
||||
res.body = body;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn((code) => {
|
||||
res.code = code;
|
||||
return res;
|
||||
})
|
||||
};
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
describe("feature flag runtime route", () => {
|
||||
it("returns cached flags without re-querying runtime assignments", async () => {
|
||||
const req = {
|
||||
params: { bodyshopId: "shop-1" },
|
||||
sessionUtils: {
|
||||
getBodyshopFeatureFlagsCacheVersion: vi.fn(async () => "7"),
|
||||
getBodyshopFeatureFlagsFromRedis: vi.fn(async () => ({
|
||||
bodyshopId: "shop-1",
|
||||
flags: {
|
||||
Demo: { treatment: "on", config: null }
|
||||
}
|
||||
}))
|
||||
},
|
||||
user: { email: "tester@example.com" },
|
||||
userGraphQLClient: {
|
||||
request: vi.fn(async () => ({ bodyshops_by_pk: { id: "shop-1" } }))
|
||||
}
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await getBodyshopFeatureFlags(req, res);
|
||||
|
||||
expect(req.sessionUtils.getBodyshopFeatureFlagsFromRedis).toHaveBeenCalledWith("shop-1", "7");
|
||||
expect(req.userGraphQLClient.request).toHaveBeenCalledTimes(1);
|
||||
expect(res.body).toEqual({
|
||||
bodyshopId: "shop-1",
|
||||
flags: {
|
||||
Demo: { treatment: "on", config: null }
|
||||
},
|
||||
source: "redis"
|
||||
});
|
||||
});
|
||||
|
||||
it("merges active definitions with bodyshop assignments on cache miss", async () => {
|
||||
const req = {
|
||||
params: { bodyshopId: "shop-1" },
|
||||
sessionUtils: {
|
||||
getBodyshopFeatureFlagsCacheVersion: vi.fn(async () => "3"),
|
||||
getBodyshopFeatureFlagsFromRedis: vi.fn(async () => null),
|
||||
setBodyshopFeatureFlagsInRedis: vi.fn()
|
||||
},
|
||||
user: { email: "tester@example.com" },
|
||||
userGraphQLClient: {
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ bodyshops_by_pk: { id: "shop-1" } })
|
||||
.mockResolvedValueOnce({
|
||||
feature_flags: [
|
||||
{ name: "Default_Off", default_treatment: "off" },
|
||||
{ name: "Default_Custom", default_treatment: "variant-a" }
|
||||
],
|
||||
bodyshop_feature_flags: [
|
||||
{
|
||||
name: "Default_Off",
|
||||
treatment: "on",
|
||||
config: { limit: 10 },
|
||||
activeDate: "2026-05-19T15:00:00.000Z",
|
||||
deactiveDate: null
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await getBodyshopFeatureFlags(req, res);
|
||||
|
||||
expect(req.sessionUtils.setBodyshopFeatureFlagsInRedis).toHaveBeenCalledWith(
|
||||
"shop-1",
|
||||
expect.objectContaining({
|
||||
bodyshopId: "shop-1",
|
||||
flags: {
|
||||
Default_Off: {
|
||||
treatment: "on",
|
||||
config: { limit: 10 },
|
||||
activeDate: "2026-05-19T15:00:00.000Z",
|
||||
deactiveDate: null
|
||||
},
|
||||
Default_Custom: {
|
||||
treatment: "variant-a",
|
||||
config: null
|
||||
}
|
||||
}
|
||||
}),
|
||||
"3"
|
||||
);
|
||||
expect(res.body).toEqual(
|
||||
expect.objectContaining({
|
||||
bodyshopId: "shop-1",
|
||||
flags: {
|
||||
Default_Off: {
|
||||
treatment: "on",
|
||||
config: { limit: 10 },
|
||||
activeDate: "2026-05-19T15:00:00.000Z",
|
||||
deactiveDate: null
|
||||
},
|
||||
Default_Custom: {
|
||||
treatment: "variant-a",
|
||||
config: null
|
||||
}
|
||||
},
|
||||
source: "database"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature flag cache invalidation route", () => {
|
||||
it("invalidates one bodyshop when a bodyshop assignment event is received", async () => {
|
||||
const emit = vi.fn();
|
||||
const req = {
|
||||
body: {
|
||||
event: {
|
||||
table: { name: "bodyshop_feature_flags" },
|
||||
data: {
|
||||
new: { bodyshopid: "shop-1" }
|
||||
}
|
||||
}
|
||||
},
|
||||
ioHelpers: {
|
||||
getBodyshopRoom: vi.fn(() => "bodyshop-room-shop-1")
|
||||
},
|
||||
ioRedis: {
|
||||
to: vi.fn(() => ({ emit }))
|
||||
},
|
||||
sessionUtils: {
|
||||
invalidateBodyshopFeatureFlagsInRedis: vi.fn()
|
||||
}
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await invalidateBodyshopFeatureFlags(req, res);
|
||||
|
||||
expect(req.sessionUtils.invalidateBodyshopFeatureFlagsInRedis).toHaveBeenCalledWith("shop-1");
|
||||
expect(req.ioRedis.to).toHaveBeenCalledWith("bodyshop-room-shop-1");
|
||||
expect(emit).toHaveBeenCalledWith(
|
||||
"feature-flags-changed",
|
||||
expect.objectContaining({ bodyshopId: "shop-1", scope: "bodyshop", source: "hasura", table: "bodyshop_feature_flags" })
|
||||
);
|
||||
expect(res.body).toEqual({ ok: true, bodyshopId: "shop-1" });
|
||||
});
|
||||
|
||||
it("bumps global cache version when a feature definition event is received", async () => {
|
||||
const req = {
|
||||
body: {
|
||||
event: {
|
||||
table: { name: "feature_flags" },
|
||||
data: {
|
||||
new: { name: "Demo" }
|
||||
}
|
||||
}
|
||||
},
|
||||
ioRedis: {
|
||||
emit: vi.fn()
|
||||
},
|
||||
sessionUtils: {
|
||||
invalidateAllBodyshopFeatureFlagsInRedis: vi.fn(async () => 12)
|
||||
}
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await invalidateBodyshopFeatureFlags(req, res);
|
||||
|
||||
expect(req.sessionUtils.invalidateAllBodyshopFeatureFlagsInRedis).toHaveBeenCalled();
|
||||
expect(req.ioRedis.emit).toHaveBeenCalledWith(
|
||||
"feature-flags-changed",
|
||||
expect.objectContaining({ name: "Demo", scope: "global", source: "hasura", table: "feature_flags" })
|
||||
);
|
||||
expect(res.body).toEqual({ ok: true, table: "feature_flags", cacheVersion: 12 });
|
||||
});
|
||||
});
|
||||
40
server/feature-flags/socket-events.js
Normal file
40
server/feature-flags/socket-events.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const FEATURE_FLAGS_CHANGED_EVENT = "feature-flags-changed";
|
||||
|
||||
/**
|
||||
* Creates the Socket.IO payload used to tell browsers that feature flags changed.
|
||||
*/
|
||||
const createFeatureFlagsChangedPayload = ({ bodyshopId = null, source = "unknown", table = null, name = null } = {}) => ({
|
||||
bodyshopId,
|
||||
changedAt: new Date().toISOString(),
|
||||
name,
|
||||
scope: bodyshopId ? "bodyshop" : "global",
|
||||
source,
|
||||
table
|
||||
});
|
||||
|
||||
/**
|
||||
* Emits a feature-flag change event globally or to one bodyshop room.
|
||||
*/
|
||||
const emitFeatureFlagsChanged = ({ req, bodyshopId = null, source = "unknown", table = null, name = null } = {}) => {
|
||||
const io = req?.ioRedis;
|
||||
if (!io) return null;
|
||||
|
||||
const payload = createFeatureFlagsChangedPayload({ bodyshopId, source, table, name });
|
||||
|
||||
if (bodyshopId) {
|
||||
const room = req?.ioHelpers?.getBodyshopRoom
|
||||
? req.ioHelpers.getBodyshopRoom(bodyshopId)
|
||||
: `bodyshop-broadcast-room:${bodyshopId}`;
|
||||
io.to(room).emit(FEATURE_FLAGS_CHANGED_EVENT, payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
io.emit(FEATURE_FLAGS_CHANGED_EVENT, payload);
|
||||
return payload;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
FEATURE_FLAGS_CHANGED_EVENT,
|
||||
createFeatureFlagsChangedPayload,
|
||||
emitFeatureFlagsChanged
|
||||
};
|
||||
60
server/feature-flags/socket-events.test.js
Normal file
60
server/feature-flags/socket-events.test.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
FEATURE_FLAGS_CHANGED_EVENT,
|
||||
createFeatureFlagsChangedPayload,
|
||||
emitFeatureFlagsChanged
|
||||
} = require("./socket-events");
|
||||
|
||||
describe("feature flag socket events", () => {
|
||||
it("creates a global payload when no bodyshop id is provided", () => {
|
||||
expect(createFeatureFlagsChangedPayload({ source: "admin", table: "feature_flags", name: "Demo" })).toEqual(
|
||||
expect.objectContaining({
|
||||
bodyshopId: null,
|
||||
name: "Demo",
|
||||
scope: "global",
|
||||
source: "admin",
|
||||
table: "feature_flags"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("emits bodyshop-scoped changes to the bodyshop room", () => {
|
||||
const emit = vi.fn();
|
||||
const req = {
|
||||
ioHelpers: {
|
||||
getBodyshopRoom: vi.fn(() => "bodyshop-room-shop-1")
|
||||
},
|
||||
ioRedis: {
|
||||
to: vi.fn(() => ({ emit }))
|
||||
}
|
||||
};
|
||||
|
||||
const payload = emitFeatureFlagsChanged({
|
||||
req,
|
||||
bodyshopId: "shop-1",
|
||||
source: "hasura",
|
||||
table: "bodyshop_feature_flags",
|
||||
name: "Demo"
|
||||
});
|
||||
|
||||
expect(req.ioRedis.to).toHaveBeenCalledWith("bodyshop-room-shop-1");
|
||||
expect(emit).toHaveBeenCalledWith(FEATURE_FLAGS_CHANGED_EVENT, payload);
|
||||
expect(payload).toEqual(expect.objectContaining({ bodyshopId: "shop-1", scope: "bodyshop" }));
|
||||
});
|
||||
|
||||
it("broadcasts global changes to all sockets", () => {
|
||||
const req = {
|
||||
ioRedis: {
|
||||
emit: vi.fn()
|
||||
}
|
||||
};
|
||||
|
||||
const payload = emitFeatureFlagsChanged({ req, source: "admin", table: "feature_flags" });
|
||||
|
||||
expect(req.ioRedis.emit).toHaveBeenCalledWith(FEATURE_FLAGS_CHANGED_EVENT, payload);
|
||||
expect(payload).toEqual(expect.objectContaining({ bodyshopId: null, scope: "global" }));
|
||||
});
|
||||
});
|
||||
@@ -2968,6 +2968,30 @@ exports.GET_BODYSHOP_BY_ID = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports.CHECK_BODYSHOP_ACCESS = `
|
||||
query CHECK_BODYSHOP_ACCESS($id: uuid!) {
|
||||
bodyshops_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
exports.GET_BODYSHOP_FEATURE_FLAGS = `
|
||||
query GET_BODYSHOP_FEATURE_FLAGS($bodyshopid: uuid!) {
|
||||
feature_flags(where: { active: { _eq: true } }, order_by: { name: asc }) {
|
||||
name
|
||||
default_treatment
|
||||
}
|
||||
bodyshop_feature_flags(where: { bodyshopid: { _eq: $bodyshopid } }, order_by: { name: asc }) {
|
||||
name
|
||||
treatment
|
||||
config
|
||||
activeDate
|
||||
deactiveDate
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
exports.GET_BODYSHOP_WATCHERS_BY_ID = `
|
||||
query GET_BODYSHOP_BY_ID($id: uuid!) {
|
||||
bodyshops_by_pk(id: $id) {
|
||||
@@ -3356,4 +3380,4 @@ exports.GET_DOCUMENSO_KEY_BY_JOBID = `query GET_DOCUMENSO_KEY_BY_JOBID($jobid: u
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const { createAssociation, createShop, updateShop, updateCounter } = require("../admin/adminops");
|
||||
const {
|
||||
createAssociation,
|
||||
createShop,
|
||||
updateShop,
|
||||
updateCounter,
|
||||
getFeatureFlags,
|
||||
getBodyshopFeatureFlags,
|
||||
createFeatureFlag,
|
||||
updateFeatureFlag,
|
||||
deleteFeatureFlag,
|
||||
getFeatureFlagBodyshops,
|
||||
updateFeatureFlagBodyshops
|
||||
} = require("../admin/adminops");
|
||||
const { updateUser, getUser, createUser, getWelcomeEmail, getResetLink } = require("../firebase/firebase-handler");
|
||||
const validateAdminMiddleware = require("../middleware/validateAdminMiddleware");
|
||||
|
||||
@@ -12,6 +24,13 @@ router.post("/createassociation", createAssociation);
|
||||
router.post("/createshop", createShop);
|
||||
router.post("/updateshop", updateShop);
|
||||
router.post("/updatecounter", updateCounter);
|
||||
router.get("/feature-flags", getFeatureFlags);
|
||||
router.post("/feature-flags", createFeatureFlag);
|
||||
router.get("/feature-flags/:name/bodyshops", getFeatureFlagBodyshops);
|
||||
router.put("/feature-flags/:name/bodyshops", updateFeatureFlagBodyshops);
|
||||
router.put("/feature-flags/:name", updateFeatureFlag);
|
||||
router.delete("/feature-flags/:name", deleteFeatureFlag);
|
||||
router.get("/bodyshops/:bodyshopId/feature-flags", getBodyshopFeatureFlags);
|
||||
router.post("/updateuser", updateUser);
|
||||
router.post("/getuser", getUser);
|
||||
router.post("/createuser", createUser);
|
||||
|
||||
27
server/routes/featureFlagRoutes.js
Normal file
27
server/routes/featureFlagRoutes.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const express = require("express");
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
|
||||
const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMIddleware");
|
||||
const {
|
||||
getBodyshopFeatureFlags,
|
||||
invalidateBodyshopFeatureFlags
|
||||
} = require("../feature-flags/feature-flags");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Returns runtime feature flags for a bodyshop the authenticated user can access.
|
||||
*/
|
||||
router.get(
|
||||
"/bodyshops/:bodyshopId",
|
||||
validateFirebaseIdTokenMiddleware,
|
||||
withUserGraphQLClientMiddleware,
|
||||
getBodyshopFeatureFlags
|
||||
);
|
||||
|
||||
/**
|
||||
* Receives Hasura event-trigger callbacks that invalidate feature flag runtime caches.
|
||||
*/
|
||||
router.post("/cache/invalidate", eventAuthorizationMiddleware, invalidateBodyshopFeatureFlags);
|
||||
|
||||
module.exports = router;
|
||||
103
server/utils/redisHelpers.feature-flags.test.js
Normal file
103
server/utils/redisHelpers.feature-flags.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { applyRedisHelpers } = require("./redisHelpers");
|
||||
|
||||
/**
|
||||
* Creates an in-memory Redis-like test double for feature flag cache helper tests.
|
||||
*/
|
||||
const createRedis = () => {
|
||||
const values = new Map();
|
||||
const expirations = new Map();
|
||||
|
||||
return {
|
||||
del: vi.fn(async (...keys) => {
|
||||
let deleted = 0;
|
||||
for (const key of keys) {
|
||||
if (values.delete(key)) deleted += 1;
|
||||
}
|
||||
return deleted;
|
||||
}),
|
||||
expire: vi.fn(async (key, ttl) => {
|
||||
expirations.set(key, ttl);
|
||||
return 1;
|
||||
}),
|
||||
get: vi.fn(async (key) => values.get(key) ?? null),
|
||||
incr: vi.fn(async (key) => {
|
||||
const nextValue = Number(values.get(key) || 0) + 1;
|
||||
values.set(key, String(nextValue));
|
||||
return nextValue;
|
||||
}),
|
||||
set: vi.fn(async (key, value) => {
|
||||
values.set(key, String(value));
|
||||
return "OK";
|
||||
}),
|
||||
setnx: vi.fn(async (key, value) => {
|
||||
if (values.has(key)) return 0;
|
||||
values.set(key, String(value));
|
||||
return 1;
|
||||
}),
|
||||
values,
|
||||
expirations
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies Redis helpers to the in-memory test double and returns the mounted API.
|
||||
*/
|
||||
const createHelpers = () => {
|
||||
const pubClient = createRedis();
|
||||
const app = { use: vi.fn() };
|
||||
const logger = { log: vi.fn() };
|
||||
const helpers = applyRedisHelpers({ pubClient, app, logger });
|
||||
|
||||
return { app, helpers, logger, pubClient };
|
||||
};
|
||||
|
||||
describe("feature flag Redis cache helpers", () => {
|
||||
it("stores and reads bodyshop feature flags under the current cache version", async () => {
|
||||
const { helpers, pubClient } = createHelpers();
|
||||
|
||||
await helpers.setBodyshopFeatureFlagsInRedis("shop-1", { flags: { Demo: { treatment: "on" } } });
|
||||
|
||||
expect(await helpers.getBodyshopFeatureFlagsCacheVersion()).toBe("1");
|
||||
expect(await helpers.getBodyshopFeatureFlagsFromRedis("shop-1")).toEqual({
|
||||
flags: {
|
||||
Demo: {
|
||||
treatment: "on"
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(pubClient.values.has("bodyshop-feature-flags:v1:shop-1")).toBe(true);
|
||||
expect(pubClient.expirations.get("bodyshop-feature-flags:v1:shop-1")).toBe(3600);
|
||||
});
|
||||
|
||||
it("global invalidation bumps the cache version instead of deleting old versioned keys", async () => {
|
||||
const { helpers, pubClient } = createHelpers();
|
||||
|
||||
await helpers.setBodyshopFeatureFlagsInRedis("shop-1", { flags: { Demo: { treatment: "on" } } });
|
||||
const nextVersion = await helpers.invalidateAllBodyshopFeatureFlagsInRedis();
|
||||
|
||||
expect(nextVersion).toBe(2);
|
||||
expect(pubClient.del).not.toHaveBeenCalledWith("bodyshop-feature-flags:v1:shop-1");
|
||||
expect(await helpers.getBodyshopFeatureFlagsFromRedis("shop-1")).toBeNull();
|
||||
expect(pubClient.values.has("bodyshop-feature-flags:v1:shop-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("bodyshop invalidation deletes only the current version cache key for that shop", async () => {
|
||||
const { helpers, pubClient } = createHelpers();
|
||||
|
||||
await helpers.setBodyshopFeatureFlagsInRedis("shop-1", { flags: { Demo: { treatment: "on" } } });
|
||||
await helpers.setBodyshopFeatureFlagsInRedis("shop-2", { flags: { Demo: { treatment: "on" } } });
|
||||
await helpers.invalidateAllBodyshopFeatureFlagsInRedis();
|
||||
await helpers.setBodyshopFeatureFlagsInRedis("shop-1", { flags: { Demo: { treatment: "off" } } });
|
||||
await helpers.setBodyshopFeatureFlagsInRedis("shop-2", { flags: { Demo: { treatment: "on" } } });
|
||||
|
||||
await helpers.invalidateBodyshopFeatureFlagsInRedis("shop-1");
|
||||
|
||||
expect(pubClient.values.has("bodyshop-feature-flags:v1:shop-1")).toBe(true);
|
||||
expect(pubClient.values.has("bodyshop-feature-flags:v2:shop-1")).toBe(false);
|
||||
expect(pubClient.values.has("bodyshop-feature-flags:v2:shop-2")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ const client = require("../graphql-client/graphql-client").client;
|
||||
* @type {number}
|
||||
*/
|
||||
const BODYSHOP_CACHE_TTL = 3600; // 1 hour
|
||||
const FEATURE_FLAGS_CACHE_TTL = 3600; // 1 hour
|
||||
const FEATURE_FLAGS_CACHE_VERSION_KEY = "bodyshop-feature-flags:version";
|
||||
|
||||
/**
|
||||
* Chatter API token cache TTL in seconds
|
||||
@@ -20,6 +22,7 @@ const CHATTER_TOKEN_CACHE_TTL = 3600; // 1 hour
|
||||
* @returns {`bodyshop-cache:${string}`}
|
||||
*/
|
||||
const getBodyshopCacheKey = (bodyshopId) => `bodyshop-cache:${bodyshopId}`;
|
||||
const getBodyshopFeatureFlagsCacheKey = (bodyshopId, version = "1") => `bodyshop-feature-flags:v${version}:${bodyshopId}`;
|
||||
|
||||
/**
|
||||
* Generate a cache key for a Chatter API token
|
||||
@@ -418,6 +421,92 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads or initializes the global feature flag cache version used by per-shop keys.
|
||||
*/
|
||||
const getBodyshopFeatureFlagsCacheVersion = async () => {
|
||||
try {
|
||||
const version = await pubClient.get(FEATURE_FLAGS_CACHE_VERSION_KEY);
|
||||
if (version) return version;
|
||||
|
||||
await pubClient.setnx(FEATURE_FLAGS_CACHE_VERSION_KEY, "1");
|
||||
return "1";
|
||||
} catch (error) {
|
||||
logger.log("get-bodyshop-feature-flags-cache-version", "ERROR", "redis", null, {
|
||||
error: error.message
|
||||
});
|
||||
return "1";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a bodyshop feature flag payload from the current or supplied cache version.
|
||||
*/
|
||||
const getBodyshopFeatureFlagsFromRedis = async (bodyshopId, version) => {
|
||||
const cacheVersion = version || (await getBodyshopFeatureFlagsCacheVersion());
|
||||
const key = getBodyshopFeatureFlagsCacheKey(bodyshopId, cacheVersion);
|
||||
|
||||
try {
|
||||
const cachedData = await pubClient.get(key);
|
||||
return cachedData ? JSON.parse(cachedData) : null;
|
||||
} catch (error) {
|
||||
logger.log("get-bodyshop-feature-flags-from-redis", "ERROR", "redis", null, {
|
||||
bodyshopId,
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stores a bodyshop feature flag payload under a versioned Redis key.
|
||||
*/
|
||||
const setBodyshopFeatureFlagsInRedis = async (bodyshopId, value, version) => {
|
||||
const cacheVersion = version || (await getBodyshopFeatureFlagsCacheVersion());
|
||||
const key = getBodyshopFeatureFlagsCacheKey(bodyshopId, cacheVersion);
|
||||
|
||||
try {
|
||||
await pubClient.set(key, toRedisJson(value));
|
||||
await pubClient.expire(key, FEATURE_FLAGS_CACHE_TTL);
|
||||
} catch (error) {
|
||||
logger.log("set-bodyshop-feature-flags-in-redis", "ERROR", "redis", null, {
|
||||
bodyshopId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes one bodyshop's feature flag cache entry for the current or supplied version.
|
||||
*/
|
||||
const invalidateBodyshopFeatureFlagsInRedis = async (bodyshopId, version) => {
|
||||
const cacheVersion = version || (await getBodyshopFeatureFlagsCacheVersion());
|
||||
const key = getBodyshopFeatureFlagsCacheKey(bodyshopId, cacheVersion);
|
||||
|
||||
try {
|
||||
await pubClient.del(key);
|
||||
} catch (error) {
|
||||
logger.log("invalidate-bodyshop-feature-flags-in-redis", "ERROR", "redis", null, {
|
||||
bodyshopId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Invalidates all bodyshop feature flag caches by incrementing the global version.
|
||||
*/
|
||||
const invalidateAllBodyshopFeatureFlagsInRedis = async () => {
|
||||
try {
|
||||
return await pubClient.incr(FEATURE_FLAGS_CACHE_VERSION_KEY);
|
||||
} catch (error) {
|
||||
logger.log("invalidate-all-bodyshop-feature-flags-in-redis", "ERROR", "redis", null, {
|
||||
error: error.message
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set provider cache data
|
||||
* @param ns
|
||||
@@ -482,6 +571,7 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
const api = {
|
||||
getUserSocketMappingKey,
|
||||
getBodyshopCacheKey,
|
||||
getBodyshopFeatureFlagsCacheKey,
|
||||
getChatterTokenCacheKey,
|
||||
setSessionData,
|
||||
getSessionData,
|
||||
@@ -493,6 +583,11 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
refreshUserSocketTTL,
|
||||
getBodyshopFromRedis,
|
||||
updateOrInvalidateBodyshopFromRedis,
|
||||
getBodyshopFeatureFlagsCacheVersion,
|
||||
getBodyshopFeatureFlagsFromRedis,
|
||||
setBodyshopFeatureFlagsInRedis,
|
||||
invalidateBodyshopFeatureFlagsInRedis,
|
||||
invalidateAllBodyshopFeatureFlagsInRedis,
|
||||
setSessionTransactionData,
|
||||
getSessionTransactionData,
|
||||
clearSessionTransactionData,
|
||||
|
||||
Reference in New Issue
Block a user