168 lines
6.3 KiB
TypeScript
168 lines
6.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { getRlsExecutionContext, runWithRlsContext } from "@hivegrid/db/rls-context.server";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
featureFlagFindMany: vi.fn(),
|
|
featureFlagUpdate: vi.fn(),
|
|
featureFlagDelete: vi.fn(),
|
|
featureFlagDeleteMany: vi.fn(),
|
|
tenantFindMany: vi.fn(),
|
|
analyticsEventGroupBy: vi.fn(),
|
|
clearCacheKeyPrefix: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/db.server", () => ({
|
|
db: {
|
|
featureFlag: {
|
|
findMany: mocks.featureFlagFindMany,
|
|
update: mocks.featureFlagUpdate,
|
|
delete: mocks.featureFlagDelete,
|
|
deleteMany: mocks.featureFlagDeleteMany,
|
|
},
|
|
tenant: { findMany: mocks.tenantFindMany },
|
|
analyticsEvent: { groupBy: mocks.analyticsEventGroupBy },
|
|
},
|
|
}));
|
|
vi.mock("~/utils/cache.server", () => ({ clearCacheKeyPrefix: mocks.clearCacheKeyPrefix }));
|
|
|
|
import {
|
|
deleteAllFeatureFlags,
|
|
deleteFeatureFlag,
|
|
getAllFeatureFlags,
|
|
getFeatureFlagEvaluationNames,
|
|
getFeatureFlagsWithEventCounts,
|
|
getLegacyFeatureFlagsWithEventCounts,
|
|
updateFeatureFlag,
|
|
} from "./featureFlags.db.server";
|
|
|
|
describe("feature flag list projections", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.featureFlagFindMany.mockResolvedValue([]);
|
|
mocks.featureFlagUpdate.mockResolvedValue({ id: "flag_1" });
|
|
mocks.featureFlagDelete.mockResolvedValue({ id: "flag_1" });
|
|
mocks.featureFlagDeleteMany.mockResolvedValue({ count: 2 });
|
|
mocks.tenantFindMany.mockResolvedValue([]);
|
|
mocks.analyticsEventGroupBy.mockResolvedValue([]);
|
|
});
|
|
|
|
it("aggregates event counts across tenant scopes and isolated platform analytics", async () => {
|
|
mocks.featureFlagFindMany.mockResolvedValue([
|
|
{ id: "flag_1", filters: [] },
|
|
{ id: "flag_2", filters: [] },
|
|
]);
|
|
mocks.tenantFindMany.mockImplementation(async () => {
|
|
expect(getRlsExecutionContext()).toEqual({ userId: "operator_1" });
|
|
return [{ id: "tenant_1" }, { id: "tenant_2" }];
|
|
});
|
|
mocks.analyticsEventGroupBy.mockImplementation(async () => {
|
|
const context = getRlsExecutionContext();
|
|
if (context?.tenantId === "tenant_1") return [{ featureFlagId: "flag_1", _count: 2 }];
|
|
if (context?.tenantId === "tenant_2") {
|
|
return [
|
|
{ featureFlagId: "flag_1", _count: 3 },
|
|
{ featureFlagId: "flag_2", _count: 1 },
|
|
];
|
|
}
|
|
if (context?.platformAnalytics) {
|
|
expect(context).toEqual({ platformAnalytics: true });
|
|
return [
|
|
{ featureFlagId: "flag_1", _count: 4 },
|
|
{ featureFlagId: "flag_2", _count: 5 },
|
|
];
|
|
}
|
|
throw new Error(`Unexpected RLS context: ${JSON.stringify(context)}`);
|
|
});
|
|
|
|
const result = await runWithRlsContext({ userId: "operator_1" }, () => getAllFeatureFlags());
|
|
|
|
expect(result).toEqual([
|
|
expect.objectContaining({ id: "flag_1", _count: { events: 9 } }),
|
|
expect.objectContaining({ id: "flag_2", _count: { events: 6 } }),
|
|
]);
|
|
expect(mocks.featureFlagFindMany).toHaveBeenCalledWith({
|
|
where: { AND: [{ archivedAt: null }] },
|
|
include: { filters: true },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
expect(mocks.tenantFindMany).toHaveBeenCalledWith({ select: { id: true }, orderBy: { id: "asc" } });
|
|
expect(mocks.analyticsEventGroupBy).toHaveBeenCalledTimes(3);
|
|
expect(mocks.analyticsEventGroupBy).toHaveBeenCalledWith({
|
|
by: ["featureFlagId"],
|
|
where: { featureFlagId: { in: ["flag_1", "flag_2"] } },
|
|
_count: true,
|
|
});
|
|
expect(getRlsExecutionContext()).toBeNull();
|
|
});
|
|
|
|
it("applies the enabled filter before aggregating matching flag events", async () => {
|
|
mocks.featureFlagFindMany.mockResolvedValue([{ id: "flag_1", filters: [] }]);
|
|
|
|
await runWithRlsContext({ userId: "operator_1" }, () => getFeatureFlagsWithEventCounts({ enabled: true }));
|
|
|
|
expect(mocks.featureFlagFindMany).toHaveBeenCalledWith({
|
|
where: { AND: [{ archivedAt: null }, { enabled: true }] },
|
|
include: { filters: true },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
expect(mocks.analyticsEventGroupBy).toHaveBeenCalledOnce();
|
|
expect(getRlsExecutionContext()).toBeNull();
|
|
});
|
|
|
|
it("lists request-time evaluation names without cross-tenant analytics aggregation", async () => {
|
|
mocks.featureFlagFindMany.mockResolvedValue([
|
|
{ name: "flag-one", key: null },
|
|
{ name: "flag-two", key: "legacy-two" },
|
|
{ name: "flag-one", key: null },
|
|
]);
|
|
|
|
const names = await runWithRlsContext({ userId: "user_1", tenantId: "tenant_1" }, async () => {
|
|
expect(getRlsExecutionContext()).toEqual({ userId: "user_1", tenantId: "tenant_1" });
|
|
return getFeatureFlagEvaluationNames();
|
|
});
|
|
|
|
expect(names).toEqual(["flag-one", "flag-two"]);
|
|
|
|
expect(mocks.featureFlagFindMany).toHaveBeenCalledWith({
|
|
where: { archivedAt: null },
|
|
select: { name: true, key: true },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
expect(mocks.tenantFindMany).not.toHaveBeenCalled();
|
|
expect(mocks.analyticsEventGroupBy).not.toHaveBeenCalled();
|
|
expect(getRlsExecutionContext()).toBeNull();
|
|
});
|
|
|
|
it("deletes flags without deleting their analytics history", async () => {
|
|
await expect(deleteAllFeatureFlags()).resolves.toEqual({ count: 2 });
|
|
|
|
expect(mocks.featureFlagDeleteMany).toHaveBeenCalledOnce();
|
|
expect(mocks.featureFlagDeleteMany).toHaveBeenCalledWith();
|
|
expect(mocks.clearCacheKeyPrefix).toHaveBeenCalledWith("featureFlags:enabled");
|
|
});
|
|
|
|
it("hides archived flags from the legacy editor", async () => {
|
|
await getLegacyFeatureFlagsWithEventCounts({ enabled: undefined });
|
|
|
|
expect(mocks.featureFlagFindMany).toHaveBeenCalledWith({
|
|
where: {
|
|
AND: [{ archivedAt: null }, { enabled: undefined }],
|
|
},
|
|
include: { filters: true },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
});
|
|
|
|
it("updates active generic rollout flags", async () => {
|
|
await expect(updateFeatureFlag("flag_1", { enabled: false })).resolves.toEqual({ id: "flag_1" });
|
|
expect(mocks.featureFlagUpdate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { id: "flag_1" }, data: expect.objectContaining({ enabled: false }) })
|
|
);
|
|
});
|
|
|
|
it("deletes active generic rollout flags", async () => {
|
|
await expect(deleteFeatureFlag("flag_1")).resolves.toEqual({ id: "flag_1" });
|
|
expect(mocks.featureFlagDelete).toHaveBeenCalledWith({ where: { id: "flag_1" } });
|
|
});
|
|
});
|