| import { convexTest } from "convex-test"; |
| import { afterEach, describe, expect, test, vi } from "vitest"; |
| import { internal } from "../_generated/api"; |
| import { getFeaturesForPlan } from "../lib/entitlements"; |
| import schema from "../schema"; |
|
|
| const modules = import.meta.glob("../**/*.ts"); |
| const usageFns = (internal as any).apiPlanLimitUsage; |
|
|
| const NOW = 1_800_000_000_000; |
| const FUTURE = NOW + 30 * 86_400_000; |
|
|
| async function seedEntitlement(t: ReturnType<typeof convexTest>, userId: string, planKey: string) { |
| await t.run(async (ctx) => { |
| await ctx.db.insert("entitlements", { |
| userId, |
| planKey, |
| features: getFeaturesForPlan(planKey), |
| validUntil: FUTURE, |
| updatedAt: NOW, |
| }); |
| }); |
| } |
|
|
| describe("api plan-limit usage scanner", () => { |
| afterEach(() => { |
| vi.unstubAllGlobals(); |
| vi.unstubAllEnvs(); |
| }); |
|
|
| test("a failing Axiom fetch degrades to a blocked source, not an aborted scan", async () => { |
| const t = convexTest(schema, modules); |
| |
| vi.stubEnv("AXIOM_QUERY_TOKEN", "test-token"); |
| vi.stubGlobal("fetch", vi.fn(async () => { |
| throw new Error("simulated network failure / AbortSignal timeout"); |
| })); |
|
|
| |
| |
| |
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW }); |
|
|
| expect(summary.blocked.some((b: { reason?: string }) => b.reason === "axiom_query_error")).toBe(true); |
| expect(summary.notified).toBe(0); |
| }); |
|
|
| test("api daily detection reads the Redis rl:apikey:day meter, keyed by userId", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-api", "api_starter"); |
| vi.stubEnv("AXIOM_QUERY_TOKEN", "test-token"); |
| vi.stubEnv("UPSTASH_REDIS_REST_URL", "https://upstash.test"); |
| vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", "upstash-token"); |
|
|
| |
| |
| |
| |
| vi.stubGlobal("fetch", vi.fn(async (url: string | URL) => { |
| const u = String(url); |
| if (u.includes("api.axiom.co")) { |
| return new Response(JSON.stringify({ matches: [] }), { status: 200 }); |
| } |
| if (u.includes("rl%3Aapikey%3Aday")) { |
| return new Response(JSON.stringify({ result: "1200" }), { status: 200 }); |
| } |
| return new Response(JSON.stringify({ result: "0" }), { status: 200 }); |
| })); |
|
|
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW }); |
|
|
| const notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| const daily = notices.filter((n) => n.dimension === "api_daily_requests"); |
| expect(daily).toHaveLength(1); |
| expect(daily[0]).toMatchObject({ state: "over_limit", usage: 1200 }); |
|
|
| const rollups = await t.run((ctx) => ctx.db.query("apiUsageRollups").collect()); |
| const dailyRollup = rollups.find((r) => r.dimension === "api_daily_requests"); |
| expect(dailyRollup?.usage).toBe(1200); |
| expect(dailyRollup?.source).toContain("apikey_day"); |
| }); |
|
|
| test("a malformed meter body blocks the read instead of false-clearing a live notice", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-api", "api_starter"); |
| vi.stubEnv("AXIOM_QUERY_TOKEN", "test-token"); |
| vi.stubEnv("UPSTASH_REDIS_REST_URL", "https://upstash.test"); |
| vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", "upstash-token"); |
|
|
| |
| vi.stubGlobal("fetch", vi.fn(async (url: string | URL) => { |
| const u = String(url); |
| if (u.includes("api.axiom.co")) return new Response(JSON.stringify({ matches: [] }), { status: 200 }); |
| if (u.includes("rl%3Aapikey%3Aday")) return new Response(JSON.stringify({ result: "1200" }), { status: 200 }); |
| return new Response(JSON.stringify({ result: "0" }), { status: 200 }); |
| })); |
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW }); |
| let notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices.filter((n) => n.current)).toHaveLength(1); |
|
|
| |
| |
| |
| vi.stubGlobal("fetch", vi.fn(async (url: string | URL) => { |
| const u = String(url); |
| if (u.includes("api.axiom.co")) return new Response(JSON.stringify({ matches: [] }), { status: 200 }); |
| if (u.includes("rl%3Aapikey%3Aday")) return new Response("<html>bad gateway</html>", { status: 200 }); |
| return new Response(JSON.stringify({ result: "0" }), { status: 200 }); |
| })); |
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW + 3_600_000 }); |
|
|
| notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices.filter((n) => n.current)).toHaveLength(1); |
| expect(summary.blocked.some((b: { reason?: string }) => b.reason === "redis_read_failed")).toBe(true); |
| }); |
|
|
| test("dry run reports would-notify without mutating notice state", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-api", "api_starter"); |
|
|
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| dryRun: true, |
| now: NOW, |
| rows: [{ |
| userId: "user-api", |
| dimension: "api_daily_requests", |
| usage: 850, |
| source: "test", |
| }], |
| }); |
|
|
| expect(summary).toMatchObject({ |
| dryRun: true, |
| evaluated: 1, |
| wouldNotify: 1, |
| notified: 0, |
| }); |
| const notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices).toHaveLength(0); |
| }); |
|
|
| test("records over-limit API Starter notice with a self-serve billing_portal CTA", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-api", "api_starter"); |
|
|
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ |
| userId: "user-api", |
| dimension: "api_daily_requests", |
| usage: 1_200, |
| source: "test", |
| }], |
| }); |
|
|
| expect(summary.notified).toBe(1); |
| |
| |
| |
| expect(summary.blocked).not.toContainEqual({ |
| userId: "user-api", |
| dimension: "api_daily_requests", |
| reason: "api_business_not_self_serve", |
| }); |
|
|
| const notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices).toHaveLength(1); |
| expect(notices[0]).toMatchObject({ |
| state: "over_limit", |
| ctaKind: "billing_portal", |
| upgradeTargetPlanKey: "api_business", |
| }); |
| expect(notices[0].blockedReason).toBeUndefined(); |
| }); |
|
|
| test("an over-limit annual API Starter notice routes to contact_support, not a portal dead-end", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-annual", "api_starter_annual"); |
|
|
| |
| |
| |
| |
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ |
| userId: "user-annual", |
| dimension: "api_daily_requests", |
| usage: 1_200, |
| source: "test", |
| }], |
| }); |
|
|
| expect(summary.notified).toBe(1); |
| const notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices).toHaveLength(1); |
| expect(notices[0]).toMatchObject({ |
| planKey: "api_starter_annual", |
| state: "over_limit", |
| ctaKind: "contact_support", |
| blockedReason: "api_business_not_self_serve", |
| upgradeTargetPlanKey: "api_business", |
| }); |
| }); |
|
|
| test("does not emit MCP minute notices without durable limiter-hit buckets", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro", "pro_monthly"); |
|
|
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ |
| userId: "user-pro", |
| dimension: "mcp_minute_burst", |
| usage: 75, |
| source: "test", |
| }], |
| }); |
|
|
| expect(summary).toMatchObject({ |
| evaluated: 1, |
| wouldNotify: 0, |
| notified: 0, |
| }); |
| const notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices).toHaveLength(0); |
| }); |
|
|
| test("emits MCP minute notices from durable limiter-hit buckets", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro", "pro_monthly"); |
|
|
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ |
| userId: "user-pro", |
| dimension: "mcp_minute_burst", |
| usage: 90, |
| minuteBuckets: [61, 62, 10, 65, 20], |
| source: "axiom:mcp_rate_limit_hit", |
| }], |
| }); |
|
|
| expect(summary).toMatchObject({ |
| evaluated: 1, |
| wouldNotify: 1, |
| notified: 1, |
| }); |
| const notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices).toHaveLength(1); |
| expect(notices[0]).toMatchObject({ |
| dimension: "mcp_minute_burst", |
| state: "sustained_burst", |
| ctaKind: "checkout", |
| upgradeTargetPlanKey: "api_starter", |
| }); |
| }); |
|
|
| test("recovers a lingering burst notice once the burst stops", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro", "pro_monthly"); |
|
|
| |
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ |
| userId: "user-pro", |
| dimension: "mcp_minute_burst", |
| usage: 90, |
| minuteBuckets: [61, 62, 63, 65, 66], |
| source: "axiom:mcp_rate_limit_hit", |
| }], |
| }); |
| let notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices.filter((notice) => notice.current)).toHaveLength(1); |
|
|
| |
| |
| |
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW + 3_600_000, |
| rows: [], |
| }); |
| expect(summary.recovered).toBe(1); |
| notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices.filter((notice) => notice.current)).toHaveLength(0); |
| }); |
|
|
| test("a continuing burst reuses one notice across hourly scans and holds the 6h email cadence", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro", "pro_monthly"); |
| const noticeFns = (internal as any).apiPlanLimitNotices; |
|
|
| const burstRow = { |
| userId: "user-pro", |
| dimension: "mcp_minute_burst", |
| usage: 90, |
| minuteBuckets: [61, 62, 63, 65, 66], |
| source: "axiom:mcp_rate_limit_hit", |
| }; |
|
|
| |
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW, rows: [burstRow] }); |
| let notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices).toHaveLength(1); |
| const noticeId = notices[0]._id; |
|
|
| |
| await t.mutation(noticeFns.markEmailStatus, { noticeId, emailStatus: "sent", emailedAt: NOW }); |
|
|
| |
| |
| |
| |
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW + 3_600_000, rows: [burstRow] }); |
|
|
| notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices).toHaveLength(1); |
| expect(String(notices[0]._id)).toBe(String(noticeId)); |
| expect(notices[0].current).toBe(true); |
| expect(notices[0].emailStatus).toBe("sent"); |
| expect(notices[0].lastEmailedAt).toBe(NOW); |
|
|
| |
| const due = await t.query(noticeFns.listEmailDue, { now: NOW + 3_600_000 }); |
| expect(due.map((n: { _id: unknown }) => String(n._id))).not.toContain(String(noticeId)); |
|
|
| |
| |
| const rollups = await t.run((ctx) => ctx.db.query("apiUsageRollups").collect()); |
| expect(rollups.some((r) => r.windowKey.includes("T"))).toBe(true); |
| expect(notices[0].windowKey).not.toContain("T"); |
| }); |
|
|
| test("a dismissed burst notice stays dismissed across an hourly rescan", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro", "pro_monthly"); |
|
|
| const burstRow = { |
| userId: "user-pro", |
| dimension: "mcp_minute_burst", |
| usage: 90, |
| minuteBuckets: [61, 62, 63, 65, 66], |
| source: "axiom:mcp_rate_limit_hit", |
| }; |
|
|
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW, rows: [burstRow] }); |
| const before = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(before).toHaveLength(1); |
| |
| await t.run((ctx) => ctx.db.patch(before[0]._id, { acknowledgedAt: NOW })); |
|
|
| |
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW + 3_600_000, rows: [burstRow] }); |
| const after = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(after).toHaveLength(1); |
| expect(after[0].acknowledgedAt).toBe(NOW); |
| }); |
|
|
| test("per-row recovery clears a daily notice when usage drops below the recovery floor", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-api", "api_starter"); |
|
|
| |
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ userId: "user-api", dimension: "api_daily_requests", usage: 1_200, source: "test" }], |
| }); |
| let notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices.filter((n) => n.current)).toHaveLength(1); |
|
|
| |
| |
| |
| |
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW + 3_600_000, |
| rows: [{ userId: "user-api", dimension: "api_daily_requests", usage: 200, source: "test" }], |
| }); |
| expect(summary.recovered).toBe(1); |
| notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices.filter((n) => n.current)).toHaveLength(0); |
| }); |
|
|
| test("bounded-concurrency reads attribute each user's meter to their own notice", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-a", "api_starter"); |
| await seedEntitlement(t, "user-b", "api_starter"); |
| vi.stubEnv("AXIOM_QUERY_TOKEN", "test-token"); |
| vi.stubEnv("UPSTASH_REDIS_REST_URL", "https://upstash.test"); |
| vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", "upstash-token"); |
| |
| |
| |
| |
| vi.stubGlobal("fetch", vi.fn(async (url: string | URL) => { |
| const u = String(url); |
| if (u.includes("api.axiom.co")) return new Response(JSON.stringify({ matches: [] }), { status: 200 }); |
| if (u.includes("user-a")) return new Response(JSON.stringify({ result: "400" }), { status: 200 }); |
| if (u.includes("user-b")) return new Response(JSON.stringify({ result: "1500" }), { status: 200 }); |
| return new Response(JSON.stringify({ result: "0" }), { status: 200 }); |
| })); |
|
|
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW }); |
|
|
| |
| |
| |
| const dailyCurrent = (await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect())) |
| .filter((n) => n.current && n.dimension === "api_daily_requests"); |
| expect(dailyCurrent.map((n) => n.userId)).toEqual(["user-b"]); |
| expect(dailyCurrent[0]).toMatchObject({ state: "over_limit", usage: 1_500 }); |
| }); |
|
|
| test("dead-zone refresh then recovery: over -> dead zone -> recovered clears the notice", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-api", "api_starter"); |
| const scan = (usage: number, at: number) => |
| t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: at, |
| rows: [{ userId: "user-api", dimension: "api_daily_requests", usage, source: "test" }], |
| }); |
|
|
| await scan(1_200, NOW); |
| await scan(700, NOW + 3_600_000); |
| const live = (await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect())).filter((n) => n.current); |
| expect(live).toHaveLength(1); |
| expect(live[0].lastSeenAt).toBe(NOW + 3_600_000); |
| expect(live[0].usage).toBe(700); |
|
|
| const summary = await scan(300, NOW + 7_200_000); |
| expect(summary.recovered).toBe(1); |
| const stillCurrent = (await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect())).filter((n) => n.current); |
| expect(stillCurrent).toHaveLength(0); |
| }); |
|
|
| test("does not notify a Pro (no apiAccess) account on an api_* dimension", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro", "pro_monthly"); |
|
|
| |
| |
| |
| |
| |
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ |
| userId: "user-pro", |
| dimension: "api_minute_burst", |
| usage: 500, |
| minuteBuckets: [500, 500, 500, 500, 500], |
| source: "test", |
| }], |
| }); |
|
|
| expect(summary.evaluated).toBe(0); |
| expect(summary.wouldNotify).toBe(0); |
| expect(summary.notified).toBe(0); |
| expect(summary.skipped).toContainEqual({ |
| userId: "user-pro", |
| dimension: "api_minute_burst", |
| reason: "no_api_access", |
| }); |
| const notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices).toHaveLength(0); |
| }); |
|
|
| test("an HTTP-200 Axiom body with no result envelope blocks the dimension, not false-clears", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro", "pro_monthly"); |
|
|
| |
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ |
| userId: "user-pro", |
| dimension: "mcp_minute_burst", |
| usage: 90, |
| minuteBuckets: [61, 62, 63, 65, 66], |
| source: "axiom:mcp_rate_limit_hit", |
| }], |
| }); |
| expect((await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect())).filter((n) => n.current)).toHaveLength(1); |
|
|
| |
| |
| |
| |
| |
| vi.stubEnv("AXIOM_QUERY_TOKEN", "test-token"); |
| vi.stubGlobal("fetch", vi.fn(async () => |
| new Response(JSON.stringify({ error: "query failed", code: "bad_apl" }), { status: 200 }), |
| )); |
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW + 3_600_000 }); |
|
|
| expect(summary.blocked.some((b: { reason?: string }) => b.reason === "axiom_unexpected_body")).toBe(true); |
| expect((await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect())).filter((n) => n.current)).toHaveLength(1); |
| }); |
|
|
| test("an empty {matches: []} Axiom result does not block the dimension", async () => { |
| const t = convexTest(schema, modules); |
| vi.stubEnv("AXIOM_QUERY_TOKEN", "test-token"); |
| |
| |
| |
| vi.stubGlobal("fetch", vi.fn(async (url: string | URL) => { |
| if (String(url).includes("api.axiom.co")) return new Response(JSON.stringify({ matches: [] }), { status: 200 }); |
| return new Response(JSON.stringify({ result: "0" }), { status: 200 }); |
| })); |
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW }); |
| expect(summary.blocked.some((b: { reason?: string }) => b.reason === "axiom_unexpected_body")).toBe(false); |
| }); |
|
|
| test("a non-array error-free Axiom body reads as empty and still recovers the notice", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro", "pro_monthly"); |
| |
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ userId: "user-pro", dimension: "mcp_minute_burst", usage: 90, minuteBuckets: [61, 62, 63, 65, 66], source: "axiom:mcp_rate_limit_hit" }], |
| }); |
| expect((await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect())).filter((n) => n.current)).toHaveLength(1); |
|
|
| |
| |
| |
| vi.stubEnv("AXIOM_QUERY_TOKEN", "test-token"); |
| vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ status: { rowsMatched: 0 } }), { status: 200 }))); |
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW + 3_600_000 }); |
|
|
| expect(summary.blocked.some((b: { reason?: string }) => b.reason === "axiom_unexpected_body")).toBe(false); |
| expect((await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect())).filter((n) => n.current)).toHaveLength(0); |
| }); |
|
|
| test("burst detection counts rate-limit rejections so it survives enforce mode", async () => { |
| const t = convexTest(schema, modules); |
| vi.stubEnv("AXIOM_QUERY_TOKEN", "test-token"); |
| let burstApl = ""; |
| vi.stubGlobal("fetch", vi.fn(async (url: string | URL, init?: RequestInit) => { |
| const u = String(url); |
| if (u.includes("api.axiom.co")) { |
| const apl = JSON.parse(String(init?.body)).apl as string; |
| |
| if (apl.includes("customer_id") && apl.includes("bin(_time, 1m)")) burstApl = apl; |
| return new Response(JSON.stringify({ matches: [] }), { status: 200 }); |
| } |
| return new Response(JSON.stringify({ result: "0" }), { status: 200 }); |
| })); |
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW }); |
| |
| |
| expect(burstApl).toContain("rl_min_429"); |
| expect(burstApl).toContain("rl_min_shadow"); |
| }); |
|
|
| test("mcp_daily_calls for a Pro account uses the Redis counter, dropping the dual Axiom row", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro", "pro_monthly"); |
| vi.stubEnv("AXIOM_QUERY_TOKEN", "test-token"); |
| vi.stubEnv("UPSTASH_REDIS_REST_URL", "https://upstash.test"); |
| vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", "upstash-token"); |
|
|
| |
| |
| |
| |
| vi.stubGlobal("fetch", vi.fn(async (url: string | URL, init?: RequestInit) => { |
| const u = String(url); |
| if (u.includes("api.axiom.co")) { |
| const apl = JSON.parse(String(init?.body)).apl as string; |
| if (apl.includes("mcp.toolcall")) { |
| return new Response(JSON.stringify({ matches: [{ data: { user_id: "user-pro", usage: 80 } }] }), { status: 200 }); |
| } |
| return new Response(JSON.stringify({ matches: [] }), { status: 200 }); |
| } |
| if (u.includes("mcp%3Apro-usage")) return new Response(JSON.stringify({ result: "60" }), { status: 200 }); |
| return new Response(JSON.stringify({ result: "0" }), { status: 200 }); |
| })); |
|
|
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW }); |
|
|
| |
| expect(summary.evaluated).toBe(1); |
| const mcp = (await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect())) |
| .filter((n) => n.dimension === "mcp_daily_calls" && n.current); |
| expect(mcp).toHaveLength(1); |
| expect(mcp[0].state).toBe("over_limit"); |
| expect(mcp[0].usage).toBe(60); |
| }); |
|
|
| test("mcp_daily_calls for a Pro Business account also uses the Redis counter, not the Axiom row", async () => { |
| |
| |
| |
| |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro-business", "pro_business_monthly"); |
| vi.stubEnv("AXIOM_QUERY_TOKEN", "test-token"); |
| vi.stubEnv("UPSTASH_REDIS_REST_URL", "https://upstash.test"); |
| vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", "upstash-token"); |
|
|
| |
| |
| |
| vi.stubGlobal("fetch", vi.fn(async (url: string | URL, init?: RequestInit) => { |
| const u = String(url); |
| if (u.includes("api.axiom.co")) { |
| const apl = JSON.parse(String(init?.body)).apl as string; |
| if (apl.includes("mcp.toolcall")) { |
| return new Response(JSON.stringify({ matches: [{ data: { user_id: "user-pro-business", usage: 400 } }] }), { status: 200 }); |
| } |
| return new Response(JSON.stringify({ matches: [] }), { status: 200 }); |
| } |
| if (u.includes("mcp%3Apro-usage")) return new Response(JSON.stringify({ result: "300" }), { status: 200 }); |
| return new Response(JSON.stringify({ result: "0" }), { status: 200 }); |
| })); |
|
|
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { now: NOW }); |
|
|
| expect(summary.evaluated).toBe(1); |
| const mcp = (await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect())) |
| .filter((n) => n.dimension === "mcp_daily_calls" && n.current); |
| expect(mcp).toHaveLength(1); |
| expect(mcp[0].state).toBe("over_limit"); |
| expect(mcp[0].usage).toBe(300); |
| expect(mcp[0].limit).toBe(250); |
| }); |
|
|
| test("a capped Pro Business account is offered API Starter, not a dead-end notice", async () => { |
| |
| |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro-business", "pro_business_monthly"); |
|
|
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ |
| userId: "user-pro-business", |
| dimension: "mcp_daily_calls", |
| usage: 300, |
| source: "test", |
| }], |
| }); |
|
|
| expect(summary.notified).toBe(1); |
| const notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices).toHaveLength(1); |
| expect(notices[0]).toMatchObject({ |
| planKey: "pro_business_monthly", |
| dimension: "mcp_daily_calls", |
| state: "over_limit", |
| ctaKind: "checkout", |
| upgradeTargetPlanKey: "api_starter", |
| }); |
| }); |
|
|
| test("the annual Pro Business plan gets the same API Starter CTA as monthly", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro-business-annual", "pro_business_annual"); |
|
|
| await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ |
| userId: "user-pro-business-annual", |
| dimension: "mcp_daily_calls", |
| usage: 300, |
| source: "test", |
| }], |
| }); |
|
|
| const notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect()); |
| expect(notices).toHaveLength(1); |
| expect(notices[0]).toMatchObject({ |
| planKey: "pro_business_annual", |
| ctaKind: "checkout", |
| upgradeTargetPlanKey: "api_starter", |
| }); |
| }); |
|
|
| test("clears a stale api_* notice for a non-apiAccess account instead of wedging it", async () => { |
| const t = convexTest(schema, modules); |
| await seedEntitlement(t, "user-pro", "pro_monthly"); |
| |
| |
| |
| await t.run(async (ctx) => { |
| await ctx.db.insert("apiPlanLimitNotices", { |
| userId: "user-pro", |
| planKey: "api_starter", |
| dimension: "api_minute_burst", |
| state: "sustained_burst", |
| windowKey: "2020-01-01", |
| usage: 100, |
| limit: 60, |
| usageRatio: null, |
| current: true, |
| firstSeenAt: NOW - 86_400_000, |
| lastSeenAt: NOW - 86_400_000, |
| emailStatus: "sent", |
| ctaKind: "checkout", |
| }); |
| }); |
|
|
| |
| |
| |
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ |
| userId: "user-pro", |
| dimension: "api_minute_burst", |
| usage: 500, |
| minuteBuckets: [500, 500, 500, 500, 500], |
| source: "test", |
| }], |
| }); |
|
|
| expect(summary.skipped).toContainEqual({ |
| userId: "user-pro", |
| dimension: "api_minute_burst", |
| reason: "no_api_access", |
| }); |
| expect(summary.recovered).toBe(1); |
| const current = (await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect())) |
| .filter((n) => n.current); |
| expect(current).toHaveLength(0); |
| }); |
|
|
| test("skips rows that cannot be joined to an active entitlement", async () => { |
| const t = convexTest(schema, modules); |
|
|
| const summary = await t.action(usageFns.scanApiPlanLimitUsageInternal, { |
| now: NOW, |
| rows: [{ |
| userId: "unknown-user", |
| dimension: "api_daily_requests", |
| usage: 2_000, |
| source: "test", |
| }], |
| }); |
|
|
| expect(summary.skipped).toContainEqual({ |
| userId: "unknown-user", |
| dimension: "api_daily_requests", |
| reason: "unknown_or_inactive_entitlement", |
| }); |
| }); |
| }); |
|
|