adc198b37b
* docs: add feature opt-in banner system plan Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * docs: iterate on feature opt-in banner plan based on feedback - Use config.ts for feature metadata instead of TRPC response - Consolidate localStorage dismissal into single key to avoid bloat - Replace separate success dialog with in-dialog success state for smoother UX Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: implement feature opt-in banner system - Add checkFeatureOptInEligibility TRPC procedure to check if user can opt-in - Create useFeatureOptInBanner hook with localStorage dismissal state - Create FeatureOptInBanner floating component for bottom-right display - Create FeatureOptInConfirmDialog with role-based options and success state - Integrate banner into bookings-view.tsx as example usage Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: add i18n translation keys for feature opt-in banner * refactor: create FeatureOptInBannerWrapper for simpler consumer integration Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: use @calcom/lib/webstorage and add Zod schema for localStorage Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: address PR review comments - use PBAC, move logic to service, improve typing Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> # Conflicts: # packages/features/feature-opt-in/services/FeatureOptInService.ts * test: add comprehensive tests for checkFeatureOptInEligibility method Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: correct import and Checkbox type errors Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * update message * invalidate after dialog dismiss * feat: simulate user opt-in to determine banner eligibility When a feature has a strict policy and org/team hasn't explicitly enabled it, user opt-in alone won't enable the feature. This change simulates what would happen if the user opts in and only shows the banner if opting in would actually enable the feature. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: add early return for missing featureConfig and clarify simulation comment Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix repository usage * refactor: simplify FeatureOptInConfirmDialog UI and remove unused translation Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: replace radio buttons with multi-select dropdown in FeatureOptInConfirmDialog Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * resize banner * refactor: use AnimatedPopover pattern for team selection and rename titleI18nKey to nameI18nKey Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: change 'Just for me' to 'For me', fix width clipping, remove divider Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * update style * feat: make 'Just for me' mutually exclusive with teams/org and store opt-ins in localStorage Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: remove barrel imports and update to direct imports Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: add modal prop to AnimatedPopover to fix scroll lock conflict in dialogs Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: wrap Popover.Content with Portal to prevent layout interference in dialogs Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: use flexbox gap instead of space-y to prevent popover wrapper from affecting layout Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * Revert "fix: use flexbox gap instead of space-y to prevent popover wrapper from affecting layout" This reverts commit 8385adafa890c2f2674aa3ae3b10ca6e0c55ba5c. * Revert "fix: wrap Popover.Content with Portal to prevent layout interference in dialogs" This reverts commit e84301a35a99b3559d47c2d37316af290834ca2d. * Revert "fix: add modal prop to AnimatedPopover to fix scroll lock conflict in dialogs" This reverts commit b015af2f1cf31ef7087c02cb8535d96cc4a34125. * feat: replace Radix Dialog and AnimatedPopover with coss-ui components This migration uses @coss/ui Dialog and Popover components which are built on Base UI instead of Radix. Base UI handles scroll locking and positioning differently, which should fix the gap issue when opening the popover inside the dialog. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: replace remaining @calcom/ui components with coss-ui - Replace Divider with Separator from @coss/ui - Replace Label with Label from @coss/ui - Replace CheckboxField with Checkbox + Label from @coss/ui - Replace Icon with direct lucide-react icons (UserIcon, BuildingIcon, UsersIcon, CheckIcon) - Replace showToast with toastManager.add() from @coss/ui Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: replace Popover with Menu component from coss-ui - Replace Popover + FilterCheckboxField with Menu + MenuCheckboxItem - MenuCheckboxItem provides built-in checkbox indicator and styling - MenuSeparator provides proper menu separators - Cleaner API with onCheckedChange callback - Better keyboard navigation support Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: align icon and text on same line in MenuCheckboxItem, widen dialog - Wrap icon and text in flex container with items-center gap-2 - Change dialog width from sm:max-w-md to sm:max-w-lg to prevent text wrapping Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * use dimmed bg for coss dialog title * refactor: change nameI18nKey/descriptionI18nKey to nested i18n object - Change OptInFeatureConfig interface to use i18n: { name, description } - Update all component usages to use featureConfig.i18n.name and featureConfig.i18n.description - Update test mocks to use new structure Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: add i18n.title to OptInFeatureConfig type definition Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * update texts * use title instead of name * update banner style * refactor: split FeatureOptInConfirmDialog into separate success and confirmation dialogs Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: don't record dismissed info in local storage after successful opt-in Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: add checkFeatureOptInEligibility to IFeatureOptInService interface Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * update common.json * refactor: move tRPC usage from features package to apps/web/modules Addresses Cubic AI review feedback (confidence 9/10): - Remove @calcom/trpc import from FeatureOptInConfirmDialog.tsx - Add FeatureOptInMutations type for passing mutation functions via props - Move tRPC hooks to useFeatureOptInBanner hook in apps/web/modules - Pass mutations through FeatureOptInBannerWrapper to dialog component This maintains the architectural constraint that packages/features should not import from @calcom/trpc to prevent circular dependencies. Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
920 lines
29 KiB
TypeScript
920 lines
29 KiB
TypeScript
import { getFeatureOptInService } from "@calcom/features/di/containers/FeatureOptInService";
|
|
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
|
|
import type { FeatureId } from "@calcom/features/flags/config";
|
|
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
|
import { prisma } from "@calcom/prisma";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { IFeatureOptInService } from "./IFeatureOptInService";
|
|
|
|
// Mock isFeatureAllowedForScope to always return true for integration tests.
|
|
// The scope validation logic is tested in unit tests; integration tests focus on database behavior.
|
|
vi.mock("../config", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../config")>();
|
|
return {
|
|
...actual,
|
|
isFeatureAllowedForScope: () => true,
|
|
};
|
|
});
|
|
|
|
// Helper to generate unique identifiers per test
|
|
const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
|
// Store cleanup functions to ensure they run even if tests fail
|
|
const cleanupFunctions: Array<() => Promise<void>> = [];
|
|
|
|
afterEach(async () => {
|
|
for (const cleanup of cleanupFunctions) {
|
|
await cleanup();
|
|
}
|
|
cleanupFunctions.length = 0;
|
|
});
|
|
|
|
// Access private clearCache method through type assertion
|
|
const clearFeaturesCache = (repo: FeaturesRepository) => {
|
|
(repo as unknown as { clearCache: () => void }).clearCache();
|
|
};
|
|
|
|
interface TestEntities {
|
|
user: { id: number };
|
|
org: { id: number };
|
|
team: { id: number };
|
|
team2: { id: number };
|
|
featuresRepository: FeaturesRepository;
|
|
service: IFeatureOptInService;
|
|
createdFeatures: string[];
|
|
setupFeature: (enabled?: boolean) => Promise<FeatureId>;
|
|
}
|
|
|
|
/**
|
|
* Creates isolated test entities (user, org, teams) and returns them along with a cleanup function.
|
|
* Each test gets its own entities, ensuring complete isolation even when tests run in parallel.
|
|
*/
|
|
async function setup(): Promise<TestEntities> {
|
|
const id = uniqueId();
|
|
const createdFeatures: string[] = [];
|
|
|
|
// Create test user
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email: `test-opt-in-${id}@example.com`,
|
|
username: `test-opt-in-${id}`,
|
|
},
|
|
});
|
|
|
|
// Create test org
|
|
const org = await prisma.team.create({
|
|
data: {
|
|
name: `Test OptIn Org ${id}`,
|
|
slug: `test-opt-in-org-${id}`,
|
|
isOrganization: true,
|
|
},
|
|
});
|
|
|
|
// Create test teams
|
|
const team = await prisma.team.create({
|
|
data: {
|
|
name: `Test OptIn Team ${id}`,
|
|
slug: `test-opt-in-team-${id}`,
|
|
parentId: org.id,
|
|
},
|
|
});
|
|
|
|
const team2 = await prisma.team.create({
|
|
data: {
|
|
name: `Test OptIn Team 2 ${id}`,
|
|
slug: `test-opt-in-team-2-${id}`,
|
|
parentId: org.id,
|
|
},
|
|
});
|
|
|
|
const featuresRepository = getFeaturesRepository();
|
|
const service = getFeatureOptInService();
|
|
|
|
// Helper to create a feature for a test and track it for cleanup
|
|
const setupFeature = async (enabled = true): Promise<FeatureId> => {
|
|
const featureSlug = `test-opt-in-feature-${uniqueId()}` as FeatureId;
|
|
createdFeatures.push(featureSlug);
|
|
await prisma.feature.create({
|
|
data: {
|
|
slug: featureSlug,
|
|
enabled,
|
|
type: "EXPERIMENT",
|
|
},
|
|
});
|
|
// Clear cache after creating feature so getAllFeatures() returns fresh data
|
|
clearFeaturesCache(featuresRepository);
|
|
return featureSlug;
|
|
};
|
|
|
|
// Cleanup function - automatically registered with afterEach
|
|
const cleanup = async () => {
|
|
// Clean up in correct order to respect foreign key constraints
|
|
await prisma.userFeatures.deleteMany({
|
|
where: { userId: user.id },
|
|
});
|
|
await prisma.teamFeatures.deleteMany({
|
|
where: { teamId: { in: [team.id, team2.id, org.id] } },
|
|
});
|
|
await prisma.feature.deleteMany({
|
|
where: { slug: { in: createdFeatures } },
|
|
});
|
|
await prisma.membership.deleteMany({
|
|
where: { userId: user.id },
|
|
});
|
|
await prisma.team.deleteMany({
|
|
where: { id: { in: [team.id, team2.id] } },
|
|
});
|
|
await prisma.team.deleteMany({
|
|
where: { id: org.id },
|
|
});
|
|
await prisma.user.delete({
|
|
where: { id: user.id },
|
|
});
|
|
};
|
|
|
|
// Register cleanup to run automatically after each test
|
|
cleanupFunctions.push(cleanup);
|
|
|
|
return {
|
|
user,
|
|
org,
|
|
team,
|
|
team2,
|
|
featuresRepository,
|
|
service,
|
|
createdFeatures,
|
|
setupFeature,
|
|
};
|
|
}
|
|
|
|
describe("FeatureOptInService Integration Tests", () => {
|
|
describe("resolveFeatureStatesAcrossTeams", () => {
|
|
describe("global feature disabled", () => {
|
|
it("should return effectiveEnabled=false regardless of other states", async () => {
|
|
const { user, org, team, service, setupFeature } = await setup();
|
|
const testFeature = await setupFeature(false);
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.effectiveEnabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("org disabled", () => {
|
|
it("should return effectiveEnabled=false regardless of team and user states", async () => {
|
|
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Org explicitly disables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: org.id,
|
|
featureId: testFeature,
|
|
state: "disabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// Team enables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// User enables
|
|
await featuresRepository.setUserFeatureState({
|
|
userId: user.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.orgState).toBe("disabled");
|
|
expect(status.effectiveEnabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("org enabled", () => {
|
|
it("should return effectiveEnabled=false when all teams are disabled", async () => {
|
|
const { user, org, team, team2, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Org enables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: org.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// Both teams disable
|
|
await Promise.all([
|
|
featuresRepository.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "disabled",
|
|
assignedBy: "test",
|
|
}),
|
|
featuresRepository.setTeamFeatureState({
|
|
teamId: team2.id,
|
|
featureId: testFeature,
|
|
state: "disabled",
|
|
assignedBy: "test",
|
|
}),
|
|
]);
|
|
|
|
// User enables
|
|
await featuresRepository.setUserFeatureState({
|
|
userId: user.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id, team2.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.orgState).toBe("enabled");
|
|
expect(status.teamStates).toEqual(["disabled", "disabled"]);
|
|
expect(status.effectiveEnabled).toBe(false);
|
|
});
|
|
|
|
it("should return effectiveEnabled=true when at least one team is enabled and user inherits", async () => {
|
|
const { user, org, team, team2, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Org enables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: org.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// One team enables, one disables
|
|
await Promise.all([
|
|
featuresRepository.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
}),
|
|
featuresRepository.setTeamFeatureState({
|
|
teamId: team2.id,
|
|
featureId: testFeature,
|
|
state: "disabled",
|
|
assignedBy: "test",
|
|
}),
|
|
]);
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id, team2.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.orgState).toBe("enabled");
|
|
expect(status.userState).toBe("inherit");
|
|
expect(status.effectiveEnabled).toBe(true);
|
|
});
|
|
|
|
it("should return effectiveEnabled=false when user explicitly disables", async () => {
|
|
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Org enables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: org.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// Team enables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// User disables
|
|
await featuresRepository.setUserFeatureState({
|
|
userId: user.id,
|
|
featureId: testFeature,
|
|
state: "disabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.userState).toBe("disabled");
|
|
expect(status.effectiveEnabled).toBe(false);
|
|
});
|
|
|
|
it("should return effectiveEnabled=true when teams inherit from enabled org", async () => {
|
|
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Org enables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: org.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// Teams inherit (no rows)
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.orgState).toBe("enabled");
|
|
expect(status.teamStates).toEqual(["inherit"]);
|
|
expect(status.effectiveEnabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("org inherits (or no org)", () => {
|
|
it("should return effectiveEnabled=false when all teams are disabled", async () => {
|
|
const { user, team, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Team disables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "disabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: null,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.orgState).toBe("inherit");
|
|
expect(status.teamStates).toEqual(["disabled"]);
|
|
expect(status.effectiveEnabled).toBe(false);
|
|
});
|
|
|
|
it("should return effectiveEnabled=true when at least one team is enabled", async () => {
|
|
const { user, team, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// One team enables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: null,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.teamStates).toEqual(["enabled"]);
|
|
expect(status.effectiveEnabled).toBe(true);
|
|
});
|
|
|
|
it("should return effectiveEnabled=false when teams only inherit (no explicit enablement)", async () => {
|
|
const { user, team, service, setupFeature } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// No org, team inherits (no row)
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: null,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.orgState).toBe("inherit");
|
|
expect(status.teamStates).toEqual(["inherit"]);
|
|
expect(status.effectiveEnabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("no teams", () => {
|
|
it("should return effectiveEnabled=true when org is enabled and user inherits", async () => {
|
|
const { user, org, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Org enables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: org.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.orgState).toBe("enabled");
|
|
expect(status.teamStates).toEqual([]);
|
|
expect(status.effectiveEnabled).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("setUserFeatureState", () => {
|
|
it("should delete the row when state is 'inherit'", async () => {
|
|
const { user, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// First create a row
|
|
await featuresRepository.setUserFeatureState({
|
|
userId: user.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// Set to inherit (should delete)
|
|
await service.setUserFeatureState({
|
|
userId: user.id,
|
|
featureId: testFeature,
|
|
state: "inherit",
|
|
});
|
|
|
|
const row = await prisma.userFeatures.findFirst({
|
|
where: {
|
|
userId: user.id,
|
|
featureId: testFeature,
|
|
},
|
|
});
|
|
|
|
expect(row).toBeNull();
|
|
});
|
|
|
|
it("should upsert with enabled=true when state is 'enabled'", async () => {
|
|
const { user, service, setupFeature } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
await service.setUserFeatureState({
|
|
userId: user.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: user.id,
|
|
});
|
|
|
|
const row = await prisma.userFeatures.findFirst({
|
|
where: {
|
|
userId: user.id,
|
|
featureId: testFeature,
|
|
},
|
|
});
|
|
|
|
expect(row).not.toBeNull();
|
|
expect(row?.enabled).toBe(true);
|
|
});
|
|
|
|
it("should upsert with enabled=false when state is 'disabled'", async () => {
|
|
const { user, service, setupFeature } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
await service.setUserFeatureState({
|
|
userId: user.id,
|
|
featureId: testFeature,
|
|
state: "disabled",
|
|
assignedBy: user.id,
|
|
});
|
|
|
|
const row = await prisma.userFeatures.findFirst({
|
|
where: {
|
|
userId: user.id,
|
|
featureId: testFeature,
|
|
},
|
|
});
|
|
|
|
expect(row).not.toBeNull();
|
|
expect(row?.enabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("setTeamFeatureState", () => {
|
|
it("should delete the row when state is 'inherit'", async () => {
|
|
const { team, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// First create a row
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// Set to inherit (should delete)
|
|
await service.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "inherit",
|
|
});
|
|
|
|
const row = await prisma.teamFeatures.findUnique({
|
|
where: {
|
|
teamId_featureId: {
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(row).toBeNull();
|
|
});
|
|
|
|
it("should upsert with enabled=true when state is 'enabled'", async () => {
|
|
const { user, team, service, setupFeature } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
await service.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: user.id,
|
|
});
|
|
|
|
const row = await prisma.teamFeatures.findUnique({
|
|
where: {
|
|
teamId_featureId: {
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(row).not.toBeNull();
|
|
expect(row?.enabled).toBe(true);
|
|
});
|
|
|
|
it("should upsert with enabled=false when state is 'disabled'", async () => {
|
|
const { user, team, service, setupFeature } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
await service.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "disabled",
|
|
assignedBy: user.id,
|
|
});
|
|
|
|
const row = await prisma.teamFeatures.findUnique({
|
|
where: {
|
|
teamId_featureId: {
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(row).not.toBeNull();
|
|
expect(row?.enabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("auto-opt-in scenarios", () => {
|
|
describe("user autoOptInFeatures", () => {
|
|
it("should transform user state from 'inherit' to 'enabled' when user has autoOptInFeatures=true", async () => {
|
|
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Enable auto-opt-in for user
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { autoOptInFeatures: true },
|
|
});
|
|
|
|
// Org enables (to allow team level)
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: org.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// Team enables (to allow user level)
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// User has no explicit state (inherit), but autoOptInFeatures=true should enable
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.userState).toBe("inherit"); // Raw state is still inherit
|
|
expect(status.userAutoOptIn).toBe(true);
|
|
expect(status.effectiveEnabled).toBe(true); // But effective is true due to auto-opt-in
|
|
});
|
|
|
|
it("should NOT transform explicit 'disabled' state when user has autoOptInFeatures=true", async () => {
|
|
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Enable auto-opt-in for user
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { autoOptInFeatures: true },
|
|
});
|
|
|
|
// Org enables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: org.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// Team enables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// User explicitly disables
|
|
await featuresRepository.setUserFeatureState({
|
|
userId: user.id,
|
|
featureId: testFeature,
|
|
state: "disabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.userState).toBe("disabled");
|
|
expect(status.userAutoOptIn).toBe(true);
|
|
expect(status.effectiveEnabled).toBe(false); // Explicit disabled takes precedence
|
|
});
|
|
});
|
|
|
|
describe("team autoOptInFeatures", () => {
|
|
it("should transform team state from 'inherit' to 'enabled' when team has autoOptInFeatures=true", async () => {
|
|
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Enable auto-opt-in for team
|
|
await prisma.team.update({
|
|
where: { id: team.id },
|
|
data: { autoOptInFeatures: true },
|
|
});
|
|
|
|
// Org enables (to allow team level)
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: org.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// Team has no explicit state (inherit), but autoOptInFeatures=true should enable
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.teamStates).toEqual(["inherit"]); // Raw state is still inherit
|
|
expect(status.teamAutoOptIns).toEqual([true]);
|
|
expect(status.effectiveEnabled).toBe(true); // Effective is true due to team auto-opt-in
|
|
});
|
|
|
|
it("should NOT transform explicit 'disabled' state when team has autoOptInFeatures=true", async () => {
|
|
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Enable auto-opt-in for team
|
|
await prisma.team.update({
|
|
where: { id: team.id },
|
|
data: { autoOptInFeatures: true },
|
|
});
|
|
|
|
// Org enables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: org.id,
|
|
featureId: testFeature,
|
|
state: "enabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
// Team explicitly disables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: team.id,
|
|
featureId: testFeature,
|
|
state: "disabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.teamStates).toEqual(["disabled"]);
|
|
expect(status.teamAutoOptIns).toEqual([true]);
|
|
expect(status.effectiveEnabled).toBe(false); // Explicit disabled takes precedence
|
|
});
|
|
});
|
|
|
|
describe("org autoOptInFeatures", () => {
|
|
it("should transform org state from 'inherit' to 'enabled' when org has autoOptInFeatures=true", async () => {
|
|
const { user, org, team, service, setupFeature } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Enable auto-opt-in for org
|
|
await prisma.team.update({
|
|
where: { id: org.id },
|
|
data: { autoOptInFeatures: true },
|
|
});
|
|
|
|
// Org has no explicit state (inherit), but autoOptInFeatures=true should enable
|
|
// Team inherits from org
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.orgState).toBe("inherit"); // Raw state is still inherit
|
|
expect(status.orgAutoOptIn).toBe(true);
|
|
expect(status.effectiveEnabled).toBe(true); // Effective is true due to org auto-opt-in
|
|
});
|
|
|
|
it("should NOT transform explicit 'disabled' state when org has autoOptInFeatures=true", async () => {
|
|
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Enable auto-opt-in for org
|
|
await prisma.team.update({
|
|
where: { id: org.id },
|
|
data: { autoOptInFeatures: true },
|
|
});
|
|
|
|
// Org explicitly disables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: org.id,
|
|
featureId: testFeature,
|
|
state: "disabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.orgState).toBe("disabled");
|
|
expect(status.orgAutoOptIn).toBe(true);
|
|
expect(status.effectiveEnabled).toBe(false); // Explicit disabled takes precedence
|
|
});
|
|
});
|
|
|
|
describe("auto-opt-in with org disabled blocking", () => {
|
|
it("should return effectiveEnabled=false when user auto-opts-in but org is disabled", async () => {
|
|
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Enable auto-opt-in for user
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { autoOptInFeatures: true },
|
|
});
|
|
|
|
// Org explicitly disables
|
|
await featuresRepository.setTeamFeatureState({
|
|
teamId: org.id,
|
|
featureId: testFeature,
|
|
state: "disabled",
|
|
assignedBy: "test",
|
|
});
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.orgState).toBe("disabled");
|
|
expect(status.userState).toBe("inherit");
|
|
expect(status.userAutoOptIn).toBe(true);
|
|
expect(status.effectiveEnabled).toBe(false); // Org disabled blocks everything
|
|
});
|
|
});
|
|
|
|
describe("auto-opt-in flags in response", () => {
|
|
it("should return correct auto-opt-in flags for all levels", async () => {
|
|
const { user, org, team, team2, service, setupFeature } = await setup();
|
|
const testFeature = await setupFeature(true);
|
|
|
|
// Set up different auto-opt-in states
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { autoOptInFeatures: true },
|
|
});
|
|
|
|
await prisma.team.update({
|
|
where: { id: org.id },
|
|
data: { autoOptInFeatures: false },
|
|
});
|
|
|
|
await prisma.team.update({
|
|
where: { id: team.id },
|
|
data: { autoOptInFeatures: true },
|
|
});
|
|
|
|
await prisma.team.update({
|
|
where: { id: team2.id },
|
|
data: { autoOptInFeatures: false },
|
|
});
|
|
|
|
const statusMap = await service.resolveFeatureStatesAcrossTeams({
|
|
userId: user.id,
|
|
orgId: org.id,
|
|
teamIds: [team.id, team2.id],
|
|
featureIds: [testFeature],
|
|
});
|
|
const status = statusMap[testFeature];
|
|
|
|
expect(status.userAutoOptIn).toBe(true);
|
|
expect(status.orgAutoOptIn).toBe(false);
|
|
expect(status.teamAutoOptIns).toEqual([true, false]);
|
|
});
|
|
});
|
|
});
|
|
});
|