/// import { convexTest } from "convex-test"; import type { FunctionArgs, FunctionReference, FunctionReturnType, } from "convex/server"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { api as componentApi } from "../component/_generated/api.js"; import schema from "../component/schema.js"; import { type ChatStateApi, type ConvexClientLike, createConvexState, } from "./index.js"; const modules = import.meta.glob("../component/**/*.*s"); /** * The adapter calls `client.mutation(api.x, args)` where `api` is the * user's wrapper `ChatStateApi`. In tests we bypass the wrappers and * route directly to the component functions, since the wrappers are * straight forwarders. The fake `api` holds component function refs * and the fake client invokes them via convex-test. */ function buildHarness() { const t = convexTest(schema, modules); const api: ChatStateApi = { subscribe: componentApi.subscriptions .subscribe as unknown as ChatStateApi["subscribe"], unsubscribe: componentApi.subscriptions .unsubscribe as unknown as ChatStateApi["unsubscribe"], isSubscribed: componentApi.subscriptions .isSubscribed as unknown as ChatStateApi["isSubscribed"], acquireLock: componentApi.locks .acquireLock as unknown as ChatStateApi["acquireLock"], releaseLock: componentApi.locks .releaseLock as unknown as ChatStateApi["releaseLock"], forceReleaseLock: componentApi.locks .forceReleaseLock as unknown as ChatStateApi["forceReleaseLock"], extendLock: componentApi.locks .extendLock as unknown as ChatStateApi["extendLock"], kvGet: componentApi.kv.get as unknown as ChatStateApi["kvGet"], kvSet: componentApi.kv.set as unknown as ChatStateApi["kvSet"], kvSetIfNotExists: componentApi.kv .setIfNotExists as unknown as ChatStateApi["kvSetIfNotExists"], kvDelete: componentApi.kv.del as unknown as ChatStateApi["kvDelete"], appendToList: componentApi.lists .appendToList as unknown as ChatStateApi["appendToList"], getList: componentApi.lists.getList as unknown as ChatStateApi["getList"], enqueue: componentApi.queues .enqueue as unknown as ChatStateApi["enqueue"], dequeue: componentApi.queues .dequeue as unknown as ChatStateApi["dequeue"], queueDepth: componentApi.queues .queueDepth as unknown as ChatStateApi["queueDepth"], }; // Mocking the full ConvexHttpClient / ConvexClient class surface is // unreasonable in unit tests, so we expose just the two methods the adapter // needs and cast. The production type of `ConvexClientLike` remains strict // (ConvexClient | ConvexHttpClient) so users get full type-checking. const client = { mutation: >( ref: M, args: FunctionArgs ) => t.mutation( ref as FunctionReference<"mutation">, args as Record ) as Promise>, query: >( ref: Q, args: FunctionArgs ) => t.query( ref as FunctionReference<"query">, args as Record ) as Promise>, } as unknown as ConvexClientLike; return { t, api, client }; } describe("ConvexStateAdapter", () => { describe("connect/disconnect lifecycle", () => { test("throws if used before connect", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await expect(adapter.subscribe("t1")).rejects.toThrow(/not connected/); }); test("after connect, ops work", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); await adapter.subscribe("t1"); expect(await adapter.isSubscribed("t1")).toBe(true); }); test("after disconnect, ops throw", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); await adapter.disconnect(); await expect(adapter.subscribe("t1")).rejects.toThrow(/not connected/); }); }); describe("subscriptions", () => { test("subscribe + isSubscribed", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); await adapter.subscribe("slack:C:1.1"); expect(await adapter.isSubscribed("slack:C:1.1")).toBe(true); }); test("unsubscribe", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); await adapter.subscribe("t1"); await adapter.unsubscribe("t1"); expect(await adapter.isSubscribed("t1")).toBe(false); }); }); describe("locks", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); test("acquire + release + reacquire", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); const lock = await adapter.acquireLock("t1", 5000); expect(lock).not.toBeNull(); await adapter.releaseLock(lock!); const lock2 = await adapter.acquireLock("t1", 5000); expect(lock2).not.toBeNull(); expect(lock2!.token).not.toBe(lock!.token); }); test("double acquire rejected", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); const lock1 = await adapter.acquireLock("t1", 5000); const lock2 = await adapter.acquireLock("t1", 5000); expect(lock1).not.toBeNull(); expect(lock2).toBeNull(); }); test("release with wrong token is a no-op", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); const lock = await adapter.acquireLock("t1", 5000); await adapter.releaseLock({ threadId: "t1", token: "fake", expiresAt: Date.now() + 5000, }); const lock2 = await adapter.acquireLock("t1", 5000); expect(lock2).toBeNull(); // cleanup await adapter.releaseLock(lock!); }); test("extendLock keeps lock held past original TTL", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); const lock = await adapter.acquireLock("t1", 100); expect(await adapter.extendLock(lock!, 5000)).toBe(true); vi.advanceTimersByTime(200); const lock2 = await adapter.acquireLock("t1", 5000); expect(lock2).toBeNull(); }); test("forceReleaseLock bypasses token", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); await adapter.acquireLock("t1", 5000); await adapter.forceReleaseLock("t1"); const lock2 = await adapter.acquireLock("t1", 5000); expect(lock2).not.toBeNull(); }); test("expired lock releases automatically", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); await adapter.acquireLock("t1", 10); vi.advanceTimersByTime(50); const lock2 = await adapter.acquireLock("t1", 5000); expect(lock2).not.toBeNull(); }); }); describe("kv", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); test("set + get round-trips JSON", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); await adapter.set("k", { hello: "world", n: 42 }); expect(await adapter.get<{ hello: string; n: number }>("k")).toEqual({ hello: "world", n: 42, }); }); test("setIfNotExists", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); expect(await adapter.setIfNotExists("k", "first")).toBe(true); expect(await adapter.setIfNotExists("k", "second")).toBe(false); expect(await adapter.get("k")).toBe("first"); }); test("delete removes key", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); await adapter.set("k", "v"); await adapter.delete("k"); expect(await adapter.get("k")).toBeNull(); }); test("ttl expires value", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); await adapter.set("k", "v", 10); vi.advanceTimersByTime(20); expect(await adapter.get("k")).toBeNull(); }); }); describe("lists", () => { test("append and read round-trip structured values", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); await adapter.appendToList("L", { id: 1 }); await adapter.appendToList("L", { id: 2 }); expect(await adapter.getList<{ id: number }>("L")).toEqual([ { id: 1 }, { id: 2 }, ]); }); test("maxLength trims oldest", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); for (let i = 1; i <= 5; i++) { await adapter.appendToList("L", i, { maxLength: 3 }); } expect(await adapter.getList("L")).toEqual([3, 4, 5]); }); }); describe("queues", () => { test("enqueue + dequeue round-trips a QueueEntry", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); const now = Date.now(); const entry = { enqueuedAt: now, expiresAt: now + 60_000, // minimal Message shape — QueueEntry.message carries whatever the // adapter put in. We don't exercise the Message type here. message: { text: "hi" } as never, }; const depth = await adapter.enqueue("T", entry, 10); expect(depth).toBe(1); const out = await adapter.dequeue("T"); expect(out?.message).toEqual({ text: "hi" }); }); test("dequeue on empty returns null", async () => { const { api, client } = buildHarness(); const adapter = createConvexState({ client, api }); await adapter.connect(); expect(await adapter.dequeue("T")).toBeNull(); }); }); });