Skip to content

Commit c45c11e

Browse files
committed
test(webapp): cover the global flags write end to end
Adds the coverage the fix was missing: every catalog control type is compared against a typed Prisma write, so the hand-built SQL cannot drift on encoding, and the route action is driven directly to pin the locked-flag rejection, the schema rejection, and the default applied when the page does not say whether it unlocked the read-only flags. Also pins the disjoint-key assumption the single-statement write depends on.
1 parent 045ce28 commit c45c11e

3 files changed

Lines changed: 176 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// The page posts only the flags its UI manages, so the action's reading of an absent key is the
2+
// whole bug surface. These drive the real exported action with the auth wrapper unwrapped, and
3+
// assert on what it hands the writer.
4+
import { describe, expect, it, vi } from "vitest";
5+
import { FEATURE_FLAG } from "~/v3/featureFlags";
6+
7+
const { replaceGlobalFeatureFlags } = vi.hoisted(() => ({
8+
replaceGlobalFeatureFlags: vi.fn().mockResolvedValue(undefined),
9+
}));
10+
11+
vi.mock("~/services/routeBuilders/dashboardBuilder", () => ({
12+
dashboardAction: (_options: unknown, handler: unknown) => handler,
13+
dashboardLoader: (_options: unknown, handler: unknown) => handler,
14+
}));
15+
vi.mock("~/v3/featureFlags.server", () => ({
16+
replaceGlobalFeatureFlags,
17+
flags: vi.fn().mockResolvedValue({}),
18+
}));
19+
vi.mock("~/db.server", () => ({ prisma: {}, boundedIn: (v: unknown) => v }));
20+
21+
const { action } = await import("~/routes/admin.feature-flags");
22+
23+
async function post(host: string, body: unknown) {
24+
const request = new Request(`https://${host}/admin/feature-flags`, {
25+
method: "POST",
26+
body: JSON.stringify(body),
27+
headers: { "content-type": "application/json" },
28+
});
29+
return (await (action as any)({ request, params: {}, context: {} })) as Response;
30+
}
31+
32+
describe("admin feature flags action", () => {
33+
it("defaults unlockLockedFlags to false when the field is absent", async () => {
34+
replaceGlobalFeatureFlags.mockClear();
35+
const response = await post("localhost:3030", { flags: {} });
36+
37+
expect(response.status).toBe(200);
38+
expect(replaceGlobalFeatureFlags).toHaveBeenCalledTimes(1);
39+
expect(replaceGlobalFeatureFlags.mock.calls[0][1]).toMatchObject({
40+
unlockLockedFlags: false,
41+
isManagedCloud: false,
42+
});
43+
});
44+
45+
it("passes unlockLockedFlags through when the page says it unlocked them", async () => {
46+
replaceGlobalFeatureFlags.mockClear();
47+
await post("localhost:3030", { flags: {}, unlockLockedFlags: true });
48+
49+
expect(replaceGlobalFeatureFlags.mock.calls[0][1]).toMatchObject({ unlockLockedFlags: true });
50+
});
51+
52+
it("marks a managed cloud host as such", async () => {
53+
replaceGlobalFeatureFlags.mockClear();
54+
await post("cloud.trigger.dev", { flags: {}, unlockLockedFlags: true });
55+
56+
expect(replaceGlobalFeatureFlags.mock.calls[0][1]).toMatchObject({ isManagedCloud: true });
57+
});
58+
59+
it("rejects a locked flag submitted to managed cloud without writing", async () => {
60+
replaceGlobalFeatureFlags.mockClear();
61+
const response = await post("cloud.trigger.dev", {
62+
flags: { [FEATURE_FLAG.defaultWorkerInstanceGroupId]: "clwg0001" },
63+
});
64+
65+
expect(response.status).toBe(400);
66+
expect(replaceGlobalFeatureFlags).not.toHaveBeenCalled();
67+
});
68+
69+
it("rejects a value that fails the catalog schema without writing", async () => {
70+
replaceGlobalFeatureFlags.mockClear();
71+
const response = await post("localhost:3030", {
72+
flags: { [FEATURE_FLAG.realtimeBackend]: "not-a-backend" },
73+
});
74+
75+
expect(response.status).toBe(400);
76+
expect(replaceGlobalFeatureFlags).not.toHaveBeenCalled();
77+
});
78+
79+
it("submits every catalog key so omitted flags are swept", async () => {
80+
replaceGlobalFeatureFlags.mockClear();
81+
await post("localhost:3030", { flags: { [FEATURE_FLAG.mollifierEnabled]: true } });
82+
83+
const { catalogKeys, requestedFlags } = replaceGlobalFeatureFlags.mock.calls[0][1];
84+
expect(catalogKeys).toContain(FEATURE_FLAG.defaultWorkerInstanceGroupId);
85+
expect(requestedFlags).toEqual({ [FEATURE_FLAG.mollifierEnabled]: true });
86+
});
87+
});

apps/webapp/test/globalFeatureFlagsLockedFlags.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,44 @@ describe("replaceGlobalFeatureFlags — locked flags the UI never submitted", ()
9393
expect(await readFlag(prisma, FEATURE_FLAG.mollifierEnabled)).toBe(true);
9494
});
9595

96+
// The upsert and the sweep share one statement, which is only safe while no key is in both.
97+
postgresTest("a submitted key is never also swept", async ({ prisma }) => {
98+
await makeSetMultipleFlags(prisma)({
99+
[FEATURE_FLAG.mollifierEnabled]: true,
100+
[FEATURE_FLAG.hasAiAccess]: true,
101+
[FEATURE_FLAG.defaultWorkerInstanceGroupId]: WORKER_GROUP_ID,
102+
});
103+
104+
await replaceGlobalFeatureFlags(prisma, {
105+
requestedFlags: {
106+
[FEATURE_FLAG.mollifierEnabled]: false,
107+
[FEATURE_FLAG.hasAiAccess]: true,
108+
},
109+
catalogKeys: CATALOG_KEYS,
110+
isManagedCloud: false,
111+
unlockLockedFlags: true,
112+
});
113+
114+
// Both submitted keys survive with their new values rather than being swept by the same
115+
// statement that wrote them.
116+
expect(await readFlag(prisma, FEATURE_FLAG.mollifierEnabled)).toBe(false);
117+
expect(await readFlag(prisma, FEATURE_FLAG.hasAiAccess)).toBe(true);
118+
expect(await readFlag(prisma, FEATURE_FLAG.defaultWorkerInstanceGroupId)).toBeUndefined();
119+
});
120+
121+
postgresTest("writes nothing when there is nothing to write", async ({ prisma }) => {
122+
await makeSetMultipleFlags(prisma)({ [FEATURE_FLAG.mollifierEnabled]: true });
123+
124+
await replaceGlobalFeatureFlags(prisma, {
125+
requestedFlags: {},
126+
catalogKeys: [],
127+
isManagedCloud: false,
128+
unlockLockedFlags: false,
129+
});
130+
131+
expect(await readFlag(prisma, FEATURE_FLAG.mollifierEnabled)).toBe(true);
132+
});
133+
96134
postgresTest("submitted flags are upserted and omitted ones swept", async ({ prisma }) => {
97135
await makeSetMultipleFlags(prisma)({
98136
[FEATURE_FLAG.mollifierEnabled]: true,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// replaceGlobalFeatureFlags writes through hand-built SQL rather than Prisma's typed upsert, so
2+
// every control type in the catalog has to land in the column exactly as the typed write would.
3+
import type { PrismaClient } from "@trigger.dev/database";
4+
import { postgresTest } from "@internal/testcontainers";
5+
import { describe, expect, vi } from "vitest";
6+
import { FEATURE_FLAG, FeatureFlagCatalog, type FeatureFlagKey } from "~/v3/featureFlags";
7+
import { makeSetMultipleFlags, replaceGlobalFeatureFlags } from "~/v3/featureFlags.server";
8+
9+
vi.setConfig({ testTimeout: 60_000 });
10+
11+
const CATALOG_KEYS = Object.keys(FeatureFlagCatalog) as FeatureFlagKey[];
12+
13+
const CASES: { key: FeatureFlagKey; value: unknown; label: string }[] = [
14+
{ key: FEATURE_FLAG.defaultWorkerInstanceGroupId, value: "clwg0001", label: "string" },
15+
{ key: FEATURE_FLAG.mollifierEnabled, value: true, label: "boolean true" },
16+
{ key: FEATURE_FLAG.hasAiAccess, value: false, label: "boolean false" },
17+
{ key: FEATURE_FLAG.computeMigrationFreePercentage, value: 0, label: "number zero" },
18+
{ key: FEATURE_FLAG.computeMigrationPaidPercentage, value: 100, label: "number" },
19+
{ key: FEATURE_FLAG.realtimeBackend, value: "shadow", label: "enum" },
20+
{
21+
key: FEATURE_FLAG.promotedDashboardAgentPrompt,
22+
value: '{"prompt":"hi","nested":{"quote":"a \\"quoted\\" word"}}',
23+
label: "string holding JSON",
24+
},
25+
];
26+
27+
async function raw(prisma: PrismaClient, key: FeatureFlagKey) {
28+
const row = await prisma.featureFlag.findFirst({ where: { key }, select: { value: true } });
29+
return row?.value;
30+
}
31+
32+
describe("replaceGlobalFeatureFlags value fidelity", () => {
33+
for (const { key, value, label } of CASES) {
34+
postgresTest(`${label} matches the typed write`, async ({ prisma }) => {
35+
await replaceGlobalFeatureFlags(prisma, {
36+
requestedFlags: { [key]: value },
37+
catalogKeys: CATALOG_KEYS,
38+
isManagedCloud: false,
39+
unlockLockedFlags: true,
40+
});
41+
const viaRawSql = await raw(prisma, key);
42+
43+
await prisma.featureFlag.deleteMany({ where: { key } });
44+
await makeSetMultipleFlags(prisma)({ [key]: value } as any);
45+
const viaPrisma = await raw(prisma, key);
46+
47+
expect(viaRawSql).toStrictEqual(viaPrisma);
48+
expect(viaRawSql).toStrictEqual(value);
49+
});
50+
}
51+
});

0 commit comments

Comments
 (0)