| import { convexTest } from "convex-test"; |
| import { describe, expect, test } from "vitest"; |
| import schema from "../schema"; |
| import { api, internal } from "../_generated/api"; |
|
|
| const modules = import.meta.glob("../**/*.ts"); |
|
|
| const USER = { subject: "user-tests-alertrules", tokenIdentifier: "clerk|user-tests-alertrules" }; |
| const VARIANT = "full"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function seedProEntitlement( |
| t: ReturnType<typeof convexTest>, |
| userId = USER.subject, |
| validUntil = Date.now() + 30 * 24 * 60 * 60 * 1000, |
| ) { |
| await t.run(async (ctx) => { |
| await ctx.db.insert("entitlements", { |
| userId, |
| planKey: "pro_monthly", |
| features: { |
| tier: 1, |
| maxDashboards: 10, |
| apiAccess: true, |
| apiRateLimit: 1000, |
| prioritySupport: true, |
| exportFormats: ["json", "csv"], |
| }, |
| validUntil, |
| updatedAt: Date.now(), |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
|
|
| describe("alertRules β realtime+non-critical cross-field invariant", () => { |
| test("setAlertRules({sensitivity:'all'}) against existing realtime row β throws", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| |
| |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| }); |
| |
| await expect( |
| asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "all", |
| channels: [], |
| }), |
| ).rejects.toThrow(/INCOMPATIBLE_DELIVERY|Real-time delivery is for Critical/i); |
| }); |
|
|
| test("setAlertRules({sensitivity:'high'}) against existing realtime row β throws (tightened rule)", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| }); |
| |
| |
| await expect( |
| asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "high", |
| channels: [], |
| }), |
| ).rejects.toThrow(/INCOMPATIBLE_DELIVERY|Real-time delivery is for Critical/i); |
| }); |
|
|
| test("setAlertRules({sensitivity:'all'}) against existing daily-digest row β succeeds", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| |
| await asUser.mutation(api.alertRules.setDigestSettings, { |
| variant: VARIANT, |
| digestMode: "daily", |
| digestHour: 8, |
| digestTimezone: "UTC", |
| }); |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "all", |
| channels: [], |
| }); |
| const rows = await asUser.query(api.alertRules.getAlertRules, {}); |
| expect(rows.find((r) => r.variant === VARIANT)?.sensitivity).toBe("all"); |
| expect(rows.find((r) => r.variant === VARIANT)?.digestMode).toBe("daily"); |
| }); |
|
|
| test("setDigestSettings({digestMode:'realtime'}) against existing sensitivity:'all' digest β throws", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| await asUser.mutation(api.alertRules.setDigestSettings, { |
| variant: VARIANT, |
| digestMode: "daily", |
| }); |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "all", |
| channels: [], |
| }); |
| await expect( |
| asUser.mutation(api.alertRules.setDigestSettings, { |
| variant: VARIANT, |
| digestMode: "realtime", |
| }), |
| ).rejects.toThrow(/INCOMPATIBLE_DELIVERY|Real-time delivery is for Critical/i); |
| }); |
|
|
| test("setDigestSettings({digestMode:'daily'}) against existing sensitivity:'all' realtime β succeeds, sensitivity preserved", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| |
| await t.run(async (ctx) => { |
| await ctx.db.insert("alertRules", { |
| userId: USER.subject, |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "all", |
| channels: [], |
| updatedAt: Date.now(), |
| |
| }); |
| }); |
| await asUser.mutation(api.alertRules.setDigestSettings, { |
| variant: VARIANT, |
| digestMode: "daily", |
| digestHour: 8, |
| digestTimezone: "UTC", |
| }); |
| const rows = await asUser.query(api.alertRules.getAlertRules, {}); |
| const row = rows.find((r) => r.variant === VARIANT); |
| expect(row?.digestMode).toBe("daily"); |
| expect(row?.sensitivity).toBe("all"); |
| }); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
|
|
| describe("alertRules β insert-only default for sensitivity", () => { |
| test("setAlertRulesForUser with no existing row, sensitivity omitted β defaults to 'critical'", async () => { |
| const t = convexTest(schema, modules); |
| await t.mutation(internal.alertRules.setAlertRulesForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| |
| channels: [], |
| }); |
| const rows = await t.run(async (ctx) => |
| ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => q.eq("userId", USER.subject).eq("variant", VARIANT)) |
| .collect(), |
| ); |
| |
| |
| expect(rows[0]?.sensitivity).toBe("critical"); |
| }); |
|
|
| test("setAlertRulesForUser with existing daily+all row, sensitivity omitted β preserves 'all'", async () => { |
| |
| |
| const t = convexTest(schema, modules); |
| await t.run(async (ctx) => { |
| await ctx.db.insert("alertRules", { |
| userId: USER.subject, |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "all", |
| channels: [], |
| digestMode: "daily", |
| digestHour: 8, |
| digestTimezone: "UTC", |
| updatedAt: Date.now(), |
| }); |
| }); |
| await t.mutation(internal.alertRules.setAlertRulesForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: ["something"], |
| |
| channels: ["email"], |
| }); |
| const rows = await t.run(async (ctx) => |
| ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => q.eq("userId", USER.subject).eq("variant", VARIANT)) |
| .collect(), |
| ); |
| expect(rows[0]?.sensitivity).toBe("all"); |
| expect(rows[0]?.digestMode).toBe("daily"); |
| expect(rows[0]?.eventTypes).toEqual(["something"]); |
| }); |
|
|
| test("setQuietHoursForUser with no existing row β inserts with sensitivity:'critical', not 'all'/'high'", async () => { |
| const t = convexTest(schema, modules); |
| await t.mutation(internal.alertRules.setQuietHoursForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| quietHoursEnabled: true, |
| quietHoursStart: 22, |
| quietHoursEnd: 7, |
| quietHoursTimezone: "UTC", |
| }); |
| const rows = await t.run(async (ctx) => |
| ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => q.eq("userId", USER.subject).eq("variant", VARIANT)) |
| .collect(), |
| ); |
| expect(rows[0]?.sensitivity).toBe("critical"); |
| }); |
|
|
| test("setQuietHoursForUser does NOT throw on pre-migration forbidden row (Greptile P1)", async () => { |
| |
| |
| |
| |
| const t = convexTest(schema, modules); |
| await t.run(async (ctx) => { |
| await ctx.db.insert("alertRules", { |
| userId: USER.subject, |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "all", |
| channels: [], |
| |
| updatedAt: Date.now(), |
| }); |
| }); |
| await t.mutation(internal.alertRules.setQuietHoursForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| quietHoursEnabled: true, |
| quietHoursStart: 22, |
| quietHoursEnd: 7, |
| quietHoursTimezone: "UTC", |
| }); |
| const rows = await t.run(async (ctx) => |
| ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => q.eq("userId", USER.subject).eq("variant", VARIANT)) |
| .collect(), |
| ); |
| expect(rows[0]?.quietHoursEnabled).toBe(true); |
| expect(rows[0]?.quietHoursStart).toBe(22); |
| |
| expect(rows[0]?.sensitivity).toBe("all"); |
| }); |
| }); |
|
|
| |
| |
| |
| |
|
|
| describe("alertRules β setNotificationConfigForUser atomic pair update", () => { |
| test("rejects (realtime, all) atomically", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| await expect( |
| t.mutation(internal.alertRules.setNotificationConfigForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| digestMode: "realtime", |
| sensitivity: "all", |
| }), |
| ).rejects.toThrow(/INCOMPATIBLE_DELIVERY|Real-time delivery is for Critical/i); |
| }); |
|
|
| test("daily+all β realtime+critical lands atomically (no race) β tightened rule requires critical", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| |
| await t.run(async (ctx) => { |
| await ctx.db.insert("alertRules", { |
| userId: USER.subject, |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "all", |
| channels: [], |
| digestMode: "daily", |
| digestHour: 8, |
| digestTimezone: "UTC", |
| updatedAt: Date.now(), |
| }); |
| }); |
| await t.mutation(internal.alertRules.setNotificationConfigForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| digestMode: "realtime", |
| sensitivity: "critical", |
| }); |
| const rows = await t.run(async (ctx) => |
| ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => q.eq("userId", USER.subject).eq("variant", VARIANT)) |
| .collect(), |
| ); |
| expect(rows[0]?.digestMode).toBe("realtime"); |
| expect(rows[0]?.sensitivity).toBe("critical"); |
| }); |
|
|
| test("setNotificationConfigForUser({digestMode:'realtime', sensitivity:'high'}) β throws (tightened rule)", async () => { |
| |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| await expect( |
| t.mutation(internal.alertRules.setNotificationConfigForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| digestMode: "realtime", |
| sensitivity: "high", |
| }), |
| ).rejects.toThrow(/INCOMPATIBLE_DELIVERY|Real-time delivery is for Critical/i); |
| }); |
|
|
| test("partial update {enabled:true} against existing forbidden row β throws (re-validation)", async () => { |
| |
| |
| |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| await t.run(async (ctx) => { |
| await ctx.db.insert("alertRules", { |
| userId: USER.subject, |
| variant: VARIANT, |
| enabled: false, |
| eventTypes: [], |
| sensitivity: "all", |
| channels: [], |
| |
| updatedAt: Date.now(), |
| }); |
| }); |
| await expect( |
| t.mutation(internal.alertRules.setNotificationConfigForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| enabled: true, |
| |
| }), |
| ).rejects.toThrow(/INCOMPATIBLE_DELIVERY|Real-time delivery is for Critical/i); |
| }); |
|
|
| test("free user (no entitlement) calling setNotificationConfigForUser β throws PRO_REQUIRED", async () => { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const t = convexTest(schema, modules); |
| |
| await expect( |
| t.mutation(internal.alertRules.setNotificationConfigForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| digestMode: "daily", |
| sensitivity: "high", |
| }), |
| ).rejects.toThrow(/PRO_REQUIRED|Notifications are a PRO feature/i); |
| }); |
|
|
| test("expired entitlement (validUntil < now) β throws PRO_REQUIRED", async () => { |
| |
| |
| |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t, USER.subject, Date.now() - 1000); |
| await expect( |
| t.mutation(internal.alertRules.setNotificationConfigForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| digestMode: "daily", |
| sensitivity: "high", |
| }), |
| ).rejects.toThrow(/PRO_REQUIRED|Notifications are a PRO feature/i); |
| }); |
|
|
| test("omitted sensitivity on patch preserves existing value", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| await t.run(async (ctx) => { |
| await ctx.db.insert("alertRules", { |
| userId: USER.subject, |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| digestMode: "daily", |
| digestHour: 8, |
| digestTimezone: "UTC", |
| updatedAt: Date.now(), |
| }); |
| }); |
| await t.mutation(internal.alertRules.setNotificationConfigForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| digestHour: 14, |
| }); |
| const rows = await t.run(async (ctx) => |
| ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => q.eq("userId", USER.subject).eq("variant", VARIANT)) |
| .collect(), |
| ); |
| expect(rows[0]?.sensitivity).toBe("critical"); |
| expect(rows[0]?.digestHour).toBe(14); |
| }); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| describe("alertRules β layer-2 entitlement gate (PRO_REQUIRED)", () => { |
| test("setAlertRules from a free-tier user β throws PRO_REQUIRED", async () => { |
| const t = convexTest(schema, modules); |
| |
| const asFreeUser = t.withIdentity(USER); |
| await expect( |
| asFreeUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| }), |
| ).rejects.toThrow(/PRO_REQUIRED|Notifications are a PRO feature/i); |
| }); |
|
|
| test("setDigestSettings from a free-tier user β throws PRO_REQUIRED", async () => { |
| const t = convexTest(schema, modules); |
| const asFreeUser = t.withIdentity(USER); |
| await expect( |
| asFreeUser.mutation(api.alertRules.setDigestSettings, { |
| variant: VARIANT, |
| digestMode: "daily", |
| digestHour: 8, |
| digestTimezone: "UTC", |
| }), |
| ).rejects.toThrow(/PRO_REQUIRED|Notifications are a PRO feature/i); |
| }); |
|
|
| test("setQuietHours from a free-tier user β throws PRO_REQUIRED", async () => { |
| const t = convexTest(schema, modules); |
| const asFreeUser = t.withIdentity(USER); |
| await expect( |
| asFreeUser.mutation(api.alertRules.setQuietHours, { |
| variant: VARIANT, |
| quietHoursEnabled: true, |
| quietHoursStart: 22, |
| quietHoursEnd: 7, |
| quietHoursTimezone: "UTC", |
| }), |
| ).rejects.toThrow(/PRO_REQUIRED|Notifications are a PRO feature/i); |
| }); |
|
|
| test("setAlertRules with expired entitlement β throws PRO_REQUIRED", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t, USER.subject, Date.now() - 1000); |
| const asUser = t.withIdentity(USER); |
| await expect( |
| asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| }), |
| ).rejects.toThrow(/PRO_REQUIRED|Notifications are a PRO feature/i); |
| }); |
|
|
| test("setAlertRules with PRO entitlement β succeeds (control)", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| }); |
| const rows = await asUser.query(api.alertRules.getAlertRules, {}); |
| expect(rows).toHaveLength(1); |
| }); |
|
|
| test("INTENTIONAL: setAlertRulesForUser internal mutation stays UNGATED for operator/migration paths", async () => { |
| |
| |
| |
| |
| |
| |
| const t = convexTest(schema, modules); |
| |
| await t.mutation(internal.alertRules.setAlertRulesForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| enabled: false, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| }); |
| |
| const rows = await t.run(async (ctx) => |
| ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => q.eq("userId", USER.subject).eq("variant", VARIANT)) |
| .collect(), |
| ); |
| expect(rows).toHaveLength(1); |
| expect(rows[0]?.enabled).toBe(false); |
| }); |
| }); |
|
|