119 lines
4.9 KiB
TypeScript
119 lines
4.9 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { ProductBlueprintFeatureDefinition } from "@hivegrid/entitlements";
|
|
import {
|
|
AdminConnectorError,
|
|
buildConnectorInputRows,
|
|
getTenantConnectorRows,
|
|
setTenantFeatureConnector,
|
|
type ConnectorServiceDeps,
|
|
} from "./subscription-assembly.server";
|
|
import { AdminActionInputError } from "./admin-action-audit.server";
|
|
import { getRlsExecutionContext } from "@hivegrid/db/rls-context.server";
|
|
|
|
const FEATURES: readonly ProductBlueprintFeatureDefinition[] = [
|
|
{ featureKey: "crm-basic", defaultEnabled: true, tenantToggleable: false },
|
|
{ featureKey: "files", defaultEnabled: true, tenantToggleable: true },
|
|
{ featureKey: "api", defaultEnabled: false, tenantToggleable: true },
|
|
];
|
|
|
|
function makeDeps(overrides: Array<{ featureKey: string; override: string }> = []): {
|
|
deps: ConnectorServiceDeps;
|
|
upsert: ReturnType<typeof vi.fn>;
|
|
deleteMany: ReturnType<typeof vi.fn>;
|
|
invalidateCache: ReturnType<typeof vi.fn>;
|
|
recordAction: ReturnType<typeof vi.fn>;
|
|
} {
|
|
const upsert = vi.fn(async () => ({}));
|
|
const deleteMany = vi.fn(async () => ({ count: 1 }));
|
|
const invalidateCache = vi.fn();
|
|
const recordAction = vi.fn(async () => {});
|
|
const deps = {
|
|
db: {
|
|
tenantProductConfiguration: { findUnique: vi.fn(async () => ({ blueprintId: "crm-saas" })) },
|
|
tenantFeatureOverride: { findMany: vi.fn(async () => overrides), upsert, deleteMany },
|
|
},
|
|
invalidateCache,
|
|
readBlueprint: () => FEATURES,
|
|
recordAction,
|
|
} as unknown as ConnectorServiceDeps;
|
|
return { deps, upsert, deleteMany, invalidateCache, recordAction };
|
|
}
|
|
|
|
describe("buildConnectorInputRows", () => {
|
|
it("joins blueprint features with stored overrides", () => {
|
|
const rows = buildConnectorInputRows(FEATURES, new Map([["files", "disabled"]]));
|
|
expect(rows).toEqual([
|
|
{ featureKey: "crm-basic", toggleable: false, baseEnabled: true, overrideState: null },
|
|
{ featureKey: "files", toggleable: true, baseEnabled: true, overrideState: "disabled" },
|
|
{ featureKey: "api", toggleable: true, baseEnabled: false, overrideState: null },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("getTenantConnectorRows", () => {
|
|
it("reads overrides and maps them onto the blueprint", async () => {
|
|
const { deps } = makeDeps([{ featureKey: "api", override: "enabled" }]);
|
|
vi.mocked(deps.db.tenantProductConfiguration.findUnique).mockImplementationOnce(async () => {
|
|
expect(getRlsExecutionContext()?.tenantId).toBe("t1");
|
|
return { blueprintId: "crm-saas" };
|
|
});
|
|
const rows = await getTenantConnectorRows("t1", deps);
|
|
expect(rows.find((r) => r.featureKey === "api")?.overrideState).toBe("enabled");
|
|
});
|
|
});
|
|
|
|
describe("setTenantFeatureConnector", () => {
|
|
it("rejects a missing reason before touching the DB", async () => {
|
|
const { deps, upsert } = makeDeps();
|
|
await expect(
|
|
setTenantFeatureConnector({ tenantId: "t1", featureKey: "files", state: "disabled", reason: "", actorUserId: "a1" }, deps)
|
|
).rejects.toBeInstanceOf(AdminActionInputError);
|
|
expect(upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an unknown feature key", async () => {
|
|
const { deps } = makeDeps();
|
|
await expect(
|
|
setTenantFeatureConnector({ tenantId: "t1", featureKey: "nope", state: "enabled", reason: "valid reason", actorUserId: "a1" }, deps)
|
|
).rejects.toMatchObject({ code: "FEATURE_NOT_FOUND" });
|
|
});
|
|
|
|
it("refuses to toggle a non-toggleable core feature", async () => {
|
|
const { deps } = makeDeps();
|
|
await expect(
|
|
setTenantFeatureConnector({ tenantId: "t1", featureKey: "crm-basic", state: "disabled", reason: "valid reason", actorUserId: "a1" }, deps)
|
|
).rejects.toBeInstanceOf(AdminConnectorError);
|
|
});
|
|
|
|
it("upserts the override, invalidates the cache and audits", async () => {
|
|
const { deps, upsert, invalidateCache, recordAction } = makeDeps();
|
|
upsert.mockImplementationOnce(async () => {
|
|
expect(getRlsExecutionContext()?.tenantId).toBe("t1");
|
|
return {};
|
|
});
|
|
await setTenantFeatureConnector(
|
|
{ tenantId: "t1", featureKey: "api", state: "enabled", reason: "Pilot freischalten", actorUserId: "a1" },
|
|
deps
|
|
);
|
|
expect(upsert).toHaveBeenCalledOnce();
|
|
expect(invalidateCache).toHaveBeenCalledWith("t1");
|
|
expect(recordAction).toHaveBeenCalledOnce();
|
|
expect(recordAction.mock.calls[0][0]).toMatchObject({
|
|
action: "subscription.featureToggled",
|
|
targetId: "t1",
|
|
reason: "Pilot freischalten",
|
|
after: { featureKey: "api", state: "enabled" },
|
|
});
|
|
});
|
|
|
|
it("clears the override (deleteMany) when set back to inherit", async () => {
|
|
const { deps, deleteMany, upsert } = makeDeps();
|
|
await setTenantFeatureConnector(
|
|
{ tenantId: "t1", featureKey: "files", state: "inherit", reason: "Zurueck auf Plan-Default", actorUserId: "a1" },
|
|
deps
|
|
);
|
|
expect(deleteMany).toHaveBeenCalledOnce();
|
|
expect(upsert).not.toHaveBeenCalled();
|
|
});
|
|
});
|