feature/IO-3701-Harness-Replacement - Implement
This commit is contained in:
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" }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user