import { convexTest } from "convex-test"; import { afterEach, describe, expect, test, vi } from "vitest"; import schema from "../schema"; import { api } from "../_generated/api"; import { MAX_PREFS_BLOB_SIZE, USER_PREFS_WRITE_RATE_LIMIT, USER_PREFS_WRITE_RATE_WINDOW_MS, } from "../constants"; const modules = import.meta.glob("../**/*.ts"); const TEST_NOW = 1_700_000_000_000; const TEST_WINDOW_START = Math.floor(TEST_NOW / USER_PREFS_WRITE_RATE_WINDOW_MS) * USER_PREFS_WRITE_RATE_WINDOW_MS; const TEST_RESET = TEST_WINDOW_START + USER_PREFS_WRITE_RATE_WINDOW_MS; const USER_A = { subject: "user-prefs-rate-a", tokenIdentifier: "clerk|user-prefs-rate-a", }; const USER_B = { subject: "user-prefs-rate-b", tokenIdentifier: "clerk|user-prefs-rate-b", }; function makeT() { return convexTest(schema, modules); } async function writePref( t: ReturnType, user: typeof USER_A, expectedSyncVersion: number, ) { return await t.withIdentity(user).mutation(api.userPreferences.setPreferences, { variant: "full", data: { theme: `theme-${expectedSyncVersion}` }, expectedSyncVersion, schemaVersion: 1, }); } async function expectRateLimited(promise: Promise, reset = TEST_RESET) { await expect(promise).resolves.toEqual({ ok: false, reason: "RATE_LIMITED", limit: USER_PREFS_WRITE_RATE_LIMIT, reset, }); } describe("userPreferences.setPreferences write rate limit", () => { afterEach(() => { vi.restoreAllMocks(); }); test("caps direct Convex writes per authenticated user and fixed window", async () => { vi.spyOn(Date, "now").mockReturnValue(TEST_NOW); const t = makeT(); for (let i = 0; i < USER_PREFS_WRITE_RATE_LIMIT; i++) { const result = await writePref(t, USER_A, i); expect(result).toEqual({ ok: true, syncVersion: i + 1 }); } await expectRateLimited(writePref(t, USER_A, USER_PREFS_WRITE_RATE_LIMIT)); const row = await t.run(async (ctx) => { return await ctx.db .query("userPreferences") .withIndex("by_user_variant", (q) => q.eq("userId", USER_A.subject).eq("variant", "full"), ) .unique(); }); expect(row?.syncVersion).toBe(USER_PREFS_WRITE_RATE_LIMIT); }); test("uses separate buckets per authenticated user", async () => { vi.spyOn(Date, "now").mockReturnValue(TEST_NOW); const t = makeT(); for (let i = 0; i < USER_PREFS_WRITE_RATE_LIMIT; i++) { await writePref(t, USER_A, i); } await expectRateLimited(writePref(t, USER_A, USER_PREFS_WRITE_RATE_LIMIT)); await expect(writePref(t, USER_B, 0)).resolves.toEqual({ ok: true, syncVersion: 1 }); }); test("resets the write budget when the fixed window advances", async () => { const now = vi.spyOn(Date, "now").mockReturnValue(TEST_NOW); const t = makeT(); for (let i = 0; i < USER_PREFS_WRITE_RATE_LIMIT; i++) { await writePref(t, USER_A, i); } await expectRateLimited(writePref(t, USER_A, USER_PREFS_WRITE_RATE_LIMIT)); now.mockReturnValue(TEST_RESET); await expect(writePref(t, USER_A, USER_PREFS_WRITE_RATE_LIMIT)).resolves.toEqual({ ok: true, syncVersion: USER_PREFS_WRITE_RATE_LIMIT + 1, }); }); test("consolidates duplicate counter rows left by concurrent first writes", async () => { vi.spyOn(Date, "now").mockReturnValue(TEST_NOW); const t = makeT(); await t.run(async (ctx) => { await ctx.db.insert("userPreferenceWriteRateLimits", { userId: USER_A.subject, windowStart: TEST_WINDOW_START, count: 1, updatedAt: TEST_NOW - 20, }); await ctx.db.insert("userPreferenceWriteRateLimits", { userId: USER_A.subject, windowStart: TEST_WINDOW_START, count: 2, updatedAt: TEST_NOW - 10, }); await ctx.db.insert("userPreferenceWriteRateLimits", { userId: USER_A.subject, windowStart: TEST_WINDOW_START - USER_PREFS_WRITE_RATE_WINDOW_MS, count: 99, updatedAt: TEST_NOW - USER_PREFS_WRITE_RATE_WINDOW_MS, }); }); await expect(writePref(t, USER_A, 0)).resolves.toEqual({ ok: true, syncVersion: 1 }); const rows = await t.run(async (ctx) => { return await ctx.db .query("userPreferenceWriteRateLimits") .withIndex("by_user_window", (q) => q.eq("userId", USER_A.subject)) .collect(); }); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ userId: USER_A.subject, windowStart: TEST_WINDOW_START, count: 4, updatedAt: TEST_NOW, }); }); test("consolidates duplicate counter rows even when the request is rate limited", async () => { vi.spyOn(Date, "now").mockReturnValue(TEST_NOW); const t = makeT(); await t.run(async (ctx) => { await ctx.db.insert("userPreferenceWriteRateLimits", { userId: USER_A.subject, windowStart: TEST_WINDOW_START, count: 10, updatedAt: TEST_NOW - 20, }); await ctx.db.insert("userPreferenceWriteRateLimits", { userId: USER_A.subject, windowStart: TEST_WINDOW_START, count: USER_PREFS_WRITE_RATE_LIMIT - 10, updatedAt: TEST_NOW - 10, }); }); await expectRateLimited(writePref(t, USER_A, 0)); const rows = await t.run(async (ctx) => { return await ctx.db .query("userPreferenceWriteRateLimits") .withIndex("by_user_window", (q) => q.eq("userId", USER_A.subject).eq("windowStart", TEST_WINDOW_START), ) .collect(); }); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ userId: USER_A.subject, windowStart: TEST_WINDOW_START, count: USER_PREFS_WRITE_RATE_LIMIT, updatedAt: TEST_NOW, }); }); test("oversized write attempts consume the direct Convex write budget", async () => { vi.spyOn(Date, "now").mockReturnValue(TEST_NOW); const t = makeT(); const data = { payload: "x".repeat(MAX_PREFS_BLOB_SIZE) }; for (let i = 0; i < USER_PREFS_WRITE_RATE_LIMIT; i++) { await expect( t.withIdentity(USER_A).mutation(api.userPreferences.setPreferences, { variant: "full", data, expectedSyncVersion: 0, schemaVersion: 1, }), ).resolves.toMatchObject({ ok: false, reason: "BLOB_TOO_LARGE", max: MAX_PREFS_BLOB_SIZE, }); } await expectRateLimited(writePref(t, USER_A, 0)); }); test("rate limit wins before stale-version CONFLICT checks", async () => { vi.spyOn(Date, "now").mockReturnValue(TEST_NOW); const t = makeT(); for (let i = 0; i < USER_PREFS_WRITE_RATE_LIMIT; i++) { await writePref(t, USER_A, i); } await expectRateLimited(writePref(t, USER_A, 0)); }); });