| import { convexTest } from "convex-test"; |
| import { expect, test, describe } from "vitest"; |
| import schema from "../schema"; |
| import { api, internal } from "../_generated/api"; |
| import { getFeaturesForPlan } from "../lib/entitlements"; |
|
|
| const modules = import.meta.glob("../**/*.ts"); |
|
|
| |
| |
| |
|
|
| const NOW = Date.now(); |
| const FUTURE = NOW + 86400000 * 30; |
| const PAST = NOW - 86400000; |
|
|
| async function seedEntitlement( |
| t: ReturnType<typeof convexTest>, |
| overrides: { |
| userId?: string; |
| planKey?: string; |
| validUntil?: number; |
| updatedAt?: number; |
| } = {}, |
| ) { |
| const planKey = overrides.planKey ?? "pro_monthly"; |
| await t.run(async (ctx) => { |
| await ctx.db.insert("entitlements", { |
| userId: overrides.userId ?? "user-test", |
| planKey, |
| features: getFeaturesForPlan(planKey), |
| validUntil: overrides.validUntil ?? FUTURE, |
| updatedAt: overrides.updatedAt ?? NOW, |
| }); |
| }); |
| } |
|
|
| |
| |
| |
|
|
| describe("entitlement query", () => { |
| test("public query returns free-tier defaults when unauthenticated", async () => { |
| const t = convexTest(schema, modules); |
|
|
| const result = await t.query(api.entitlements.getEntitlementsForUser, {}); |
|
|
| expect(result.planKey).toBe("free"); |
| expect(result.features.tier).toBe(0); |
| expect(result.features.apiAccess).toBe(false); |
| expect(result.validUntil).toBe(0); |
| }); |
|
|
| test("returns free-tier defaults for unknown userId", async () => { |
| const t = convexTest(schema, modules); |
|
|
| const result = await t.query(internal.entitlements.getEntitlementsByUserId, { |
| userId: "user-nonexistent", |
| }); |
|
|
| expect(result.planKey).toBe("free"); |
| expect(result.features.tier).toBe(0); |
| expect(result.features.apiAccess).toBe(false); |
| expect(result.validUntil).toBe(0); |
| }); |
|
|
| test("returns active entitlements for subscribed user", async () => { |
| const t = convexTest(schema, modules); |
|
|
| await seedEntitlement(t, { |
| userId: "user-pro", |
| planKey: "pro_monthly", |
| validUntil: FUTURE, |
| }); |
|
|
| const result = await t.query(internal.entitlements.getEntitlementsByUserId, { |
| userId: "user-pro", |
| }); |
|
|
| expect(result.planKey).toBe("pro_monthly"); |
| expect(result.features.tier).toBe(1); |
| expect(result.features.apiAccess).toBe(false); |
| }); |
|
|
| test("returns free-tier for expired entitlements", async () => { |
| const t = convexTest(schema, modules); |
|
|
| await seedEntitlement(t, { |
| userId: "user-expired", |
| planKey: "pro_monthly", |
| validUntil: PAST, |
| }); |
|
|
| const result = await t.query(internal.entitlements.getEntitlementsByUserId, { |
| userId: "user-expired", |
| }); |
|
|
| expect(result.planKey).toBe("free"); |
| expect(result.features.tier).toBe(0); |
| expect(result.features.apiAccess).toBe(false); |
| expect(result.validUntil).toBe(0); |
| }); |
|
|
| test("returns correct tier for api_starter plan", async () => { |
| const t = convexTest(schema, modules); |
|
|
| await seedEntitlement(t, { |
| userId: "user-api", |
| planKey: "api_starter", |
| validUntil: FUTURE, |
| }); |
|
|
| const result = await t.query(internal.entitlements.getEntitlementsByUserId, { |
| userId: "user-api", |
| }); |
|
|
| expect(result.features.tier).toBe(2); |
| expect(result.features.apiAccess).toBe(true); |
| }); |
|
|
| test("returns correct tier for enterprise plan", async () => { |
| const t = convexTest(schema, modules); |
|
|
| await seedEntitlement(t, { |
| userId: "user-enterprise", |
| planKey: "enterprise", |
| validUntil: FUTURE, |
| }); |
|
|
| const result = await t.query(internal.entitlements.getEntitlementsByUserId, { |
| userId: "user-enterprise", |
| }); |
|
|
| expect(result.features.tier).toBe(3); |
| expect(result.features.apiAccess).toBe(true); |
| expect(result.features.prioritySupport).toBe(true); |
| }); |
|
|
| test("getFeaturesForPlan throws on unknown plan key", () => { |
| expect(() => getFeaturesForPlan("nonexistent_plan")).toThrow( |
| /Unknown planKey "nonexistent_plan"/, |
| ); |
| }); |
|
|
| test("mcpAccess: free is false, paid tiers are true (plan 2026-05-10-001)", () => { |
| |
| expect(getFeaturesForPlan("free").mcpAccess).toBe(false); |
|
|
| |
| expect(getFeaturesForPlan("pro_monthly").mcpAccess).toBe(true); |
| expect(getFeaturesForPlan("pro_annual").mcpAccess).toBe(true); |
| expect(getFeaturesForPlan("api_starter").mcpAccess).toBe(true); |
| expect(getFeaturesForPlan("api_starter_annual").mcpAccess).toBe(true); |
| expect(getFeaturesForPlan("api_business").mcpAccess).toBe(true); |
| expect(getFeaturesForPlan("enterprise").mcpAccess).toBe(true); |
| }); |
|
|
| test("read-time catalog merge surfaces mcpAccess on legacy rows lacking the field (reviewer round-2 P1.3)", async () => { |
| const t = convexTest(schema, modules); |
|
|
| |
| |
| |
| await t.run(async (ctx) => { |
| await ctx.db.insert("entitlements", { |
| userId: "user-legacy", |
| planKey: "pro_monthly", |
| features: { |
| tier: 1, |
| apiAccess: false, |
| apiRateLimit: 60, |
| maxDashboards: 10, |
| prioritySupport: false, |
| exportFormats: ["csv"], |
| |
| }, |
| validUntil: FUTURE, |
| updatedAt: NOW, |
| }); |
| }); |
|
|
| const result = await t.query(internal.entitlements.getEntitlementsByUserId, { |
| userId: "user-legacy", |
| }); |
|
|
| expect(result.planKey).toBe("pro_monthly"); |
| expect(result.features.tier).toBe(1); |
| expect(result.features.mcpAccess).toBe(true); |
| }); |
|
|
| test("read-time catalog merge: stored features win on conflict (per-user overrides preserved)", async () => { |
| const t = convexTest(schema, modules); |
|
|
| |
| |
| await t.run(async (ctx) => { |
| await ctx.db.insert("entitlements", { |
| userId: "user-override", |
| planKey: "pro_monthly", |
| features: { |
| tier: 1, |
| apiAccess: false, |
| apiRateLimit: 60, |
| maxDashboards: 10, |
| prioritySupport: false, |
| exportFormats: ["csv"], |
| mcpAccess: false, |
| }, |
| validUntil: FUTURE, |
| updatedAt: NOW, |
| }); |
| }); |
|
|
| const result = await t.query(internal.entitlements.getEntitlementsByUserId, { |
| userId: "user-override", |
| }); |
|
|
| expect(result.features.mcpAccess).toBe(false); |
| }); |
|
|
| test("does not throw when duplicate entitlement rows exist for same userId", async () => { |
| const t = convexTest(schema, modules); |
|
|
| |
| await t.run(async (ctx) => { |
| await ctx.db.insert("entitlements", { |
| userId: "user-dup", |
| planKey: "pro_monthly", |
| features: getFeaturesForPlan("pro_monthly"), |
| validUntil: FUTURE, |
| updatedAt: NOW, |
| }); |
| await ctx.db.insert("entitlements", { |
| userId: "user-dup", |
| planKey: "pro_monthly", |
| features: getFeaturesForPlan("pro_monthly"), |
| validUntil: FUTURE, |
| updatedAt: NOW + 1, |
| }); |
| }); |
|
|
| |
| await expect( |
| t.query(internal.entitlements.getEntitlementsByUserId, { userId: "user-dup" }), |
| ).resolves.toMatchObject({ planKey: "pro_monthly" }); |
| }); |
| }); |
|
|