@convex-dev/agent
Version:
A agent component for Convex.
478 lines (403 loc) • 15.8 kB
text/typescript
/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import { describe, expect, test, vi } from "vitest";
import { api, internal } from "./_generated/api.js";
import type { Id } from "./_generated/dataModel.js";
import schema from "./schema.js";
import { modules } from "./setup.test.js";
describe("users", () => {
test("listUsersWithThreads returns users who have threads", async () => {
const t = convexTest(schema, modules);
// Create two users with threads and one without
await t.mutation(api.threads.createThread, {
userId: "user1",
title: "Test thread 1",
});
await t.mutation(api.threads.createThread, {
userId: "user2",
title: "Test thread 2",
});
await t.mutation(api.threads.createThread, {
userId: "user1", // Same user, different thread
title: "Test thread 3",
});
const result = await t.query(api.users.listUsersWithThreads, {
paginationOpts: { cursor: null, numItems: 10 },
});
expect(result.page).toHaveLength(2);
expect(result.page).toContain("user1");
expect(result.page).toContain("user2");
});
test("listUsersWithThreads pagination works", async () => {
const t = convexTest(schema, modules);
// Create multiple users with threads
for (let i = 0; i < 5; i++) {
await t.mutation(api.threads.createThread, {
userId: `user${i}`,
title: `Test thread ${i}`,
});
}
const firstPage = await t.query(api.users.listUsersWithThreads, {
paginationOpts: { cursor: null, numItems: 2 },
});
expect(firstPage.page).toHaveLength(2);
expect(firstPage.isDone).toBe(false);
const secondPage = await t.query(api.users.listUsersWithThreads, {
paginationOpts: { cursor: firstPage.continueCursor, numItems: 2 },
});
expect(secondPage.page).toHaveLength(2);
// Should not have duplicate users
expect(
firstPage.page.every((user) => !secondPage.page.includes(user)),
).toBe(true);
});
test("deleteAllForUserId sync deletes all threads and messages for a user", async () => {
const t = convexTest(schema, modules);
// Create a user with multiple threads and messages
const thread1 = await t.mutation(api.threads.createThread, {
userId: "testUser",
title: "Thread 1",
});
const thread2 = await t.mutation(api.threads.createThread, {
userId: "testUser",
title: "Thread 2",
});
// Add messages to both threads
await t.mutation(api.messages.addMessages, {
threadId: thread1._id as Id<"threads">,
messages: [
{ message: { role: "user" as const, content: "Hello thread 1" } },
{
message: { role: "assistant" as const, content: "Response thread 1" },
},
],
});
await t.mutation(api.messages.addMessages, {
threadId: thread2._id as Id<"threads">,
messages: [
{ message: { role: "user" as const, content: "Hello thread 2" } },
{
message: { role: "assistant" as const, content: "Response thread 2" },
},
],
});
// Verify data exists before deletion
const beforeThreads = await t.query(api.threads.listThreadsByUserId, {
userId: "testUser",
});
expect(beforeThreads.page).toHaveLength(2);
const beforeMessages1 = await t.query(api.messages.listMessagesByThreadId, {
threadId: thread1._id as Id<"threads">,
order: "desc",
paginationOpts: { cursor: null, numItems: 10 },
});
const beforeMessages2 = await t.query(api.messages.listMessagesByThreadId, {
threadId: thread2._id as Id<"threads">,
order: "desc",
paginationOpts: { cursor: null, numItems: 10 },
});
expect(beforeMessages1.page).toHaveLength(2);
expect(beforeMessages2.page).toHaveLength(2);
// Delete all data for the user
await t.action(api.users.deleteAllForUserId, { userId: "testUser" });
// Verify all threads are deleted
const afterThreads = await t.query(api.threads.listThreadsByUserId, {
userId: "testUser",
});
expect(afterThreads.page).toHaveLength(0);
// Verify threads are actually deleted from DB
const thread1After = await t.query(api.threads.getThread, {
threadId: thread1._id as Id<"threads">,
});
const thread2After = await t.query(api.threads.getThread, {
threadId: thread2._id as Id<"threads">,
});
expect(thread1After).toBeNull();
expect(thread2After).toBeNull();
});
test("deleteAllForUserIdAsync deletes data asynchronously", async () => {
// Enable fake timers for scheduled function testing
vi.useFakeTimers();
const t = convexTest(schema, modules);
// Create a user with a thread and messages
const thread = await t.mutation(api.threads.createThread, {
userId: "asyncUser",
title: "Async Thread",
});
await t.mutation(api.messages.addMessages, {
threadId: thread._id as Id<"threads">,
messages: [
{ message: { role: "user" as const, content: "Hello async" } },
{ message: { role: "assistant" as const, content: "Response async" } },
],
});
// Start async deletion
const result = await t.mutation(api.users.deleteAllForUserIdAsync, {
userId: "asyncUser",
});
// If there's more work to do, advance timers and wait for scheduled functions
if (!result) {
// Run all pending timers to trigger scheduled functions
vi.runAllTimers();
// Wait for all scheduled functions to complete
await t.finishInProgressScheduledFunctions();
}
// Verify deletion completed
const afterThreads = await t.query(api.threads.listThreadsByUserId, {
userId: "asyncUser",
});
expect(afterThreads.page).toHaveLength(0);
// Reset to normal timers
vi.useRealTimers();
});
test("deletePageForUserId handles messages phase correctly", async () => {
const t = convexTest(schema, modules);
// Create a thread with many messages to test pagination
const thread = await t.mutation(api.threads.createThread, {
userId: "paginationUser",
title: "Pagination Thread",
});
// Add multiple messages to force pagination
const messages = [];
for (let i = 0; i < 150; i++) {
messages.push({
message: { role: "user" as const, content: `Message ${i}` },
});
}
await t.mutation(api.messages.addMessages, {
threadId: thread._id as Id<"threads">,
messages,
});
// Test first page deletion
const result1 = await t.mutation(internal.users._deletePageForUserId, {
userId: "paginationUser",
messagesCursor: null,
threadsCursor: null,
threadInProgress: null,
streamsInProgress: false,
streamOrder: undefined,
deltaCursor: undefined,
});
expect(result1.isDone).toBe(false);
expect(result1.threadInProgress).toBe(thread._id);
expect(result1.streamsInProgress).toBe(false);
expect(result1.messagesCursor).toBeTruthy();
// Continue until messages are done
let currentResult = result1;
let iterations = 0;
while (!currentResult.streamsInProgress && iterations < 10) {
currentResult = await t.mutation(internal.users._deletePageForUserId, {
userId: "paginationUser",
messagesCursor: currentResult.messagesCursor,
threadsCursor: currentResult.threadsCursor,
threadInProgress: currentResult.threadInProgress,
streamsInProgress: currentResult.streamsInProgress,
streamOrder: currentResult.streamOrder,
deltaCursor: currentResult.deltaCursor,
});
iterations++;
}
// Should now be in streams phase
expect(currentResult.streamsInProgress).toBe(true);
expect(currentResult.messagesCursor).toBeNull();
});
test("deletePageForUserId handles streams phase correctly", async () => {
const t = convexTest(schema, modules);
// Create a thread with messages (no streams for this test)
const thread = await t.mutation(api.threads.createThread, {
userId: "streamsUser",
title: "Streams Thread",
});
await t.mutation(api.messages.addMessages, {
threadId: thread._id as Id<"threads">,
messages: [
{ message: { role: "user" as const, content: "Test message" } },
],
});
// Test starting directly in streams phase
const result = await t.mutation(internal.users._deletePageForUserId, {
userId: "streamsUser",
messagesCursor: null,
threadsCursor: null,
threadInProgress: thread._id as Id<"threads">,
streamsInProgress: true,
streamOrder: undefined,
deltaCursor: undefined,
});
expect(result.isDone).toBe(false);
expect(result.streamsInProgress).toBe(true);
expect(result.threadInProgress).toBe(thread._id);
const result2 = await t.mutation(internal.users._deletePageForUserId, {
userId: "streamsUser",
messagesCursor: result.messagesCursor,
threadsCursor: result.threadsCursor,
threadInProgress: result.threadInProgress,
streamsInProgress: result.streamsInProgress,
streamOrder: result.streamOrder,
deltaCursor: result.deltaCursor,
});
expect(result2.isDone).toBe(false);
expect(result2.threadInProgress).toBeNull();
expect(result2.streamsInProgress).toBe(false);
expect(result2.messagesCursor).toBeNull();
expect(result2.streamOrder).toBeUndefined();
expect(result2.deltaCursor).toBeUndefined();
// Thread should be deleted
const threadAfter = await t.query(api.threads.getThread, {
threadId: thread._id as Id<"threads">,
});
expect(threadAfter).toBeNull();
});
test("deletePageForUserId handles multiple threads correctly", async () => {
const t = convexTest(schema, modules);
// Create multiple threads for the same user
const thread1 = await t.mutation(api.threads.createThread, {
userId: "multiUser",
title: "Thread 1",
});
const thread2 = await t.mutation(api.threads.createThread, {
userId: "multiUser",
title: "Thread 2",
});
// Add a message to each
await t.mutation(api.messages.addMessages, {
threadId: thread1._id as Id<"threads">,
messages: [{ message: { role: "user" as const, content: "Message 1" } }],
});
await t.mutation(api.messages.addMessages, {
threadId: thread2._id as Id<"threads">,
messages: [{ message: { role: "user" as const, content: "Message 2" } }],
});
// Process first thread completely
let currentResult = await t.mutation(internal.users._deletePageForUserId, {
userId: "multiUser",
messagesCursor: null,
threadsCursor: null,
threadInProgress: null,
streamsInProgress: false,
streamOrder: undefined,
deltaCursor: undefined,
});
// Continue until first thread is done
while (currentResult.threadInProgress !== null) {
currentResult = await t.mutation(internal.users._deletePageForUserId, {
userId: "multiUser",
messagesCursor: currentResult.messagesCursor,
threadsCursor: currentResult.threadsCursor,
threadInProgress: currentResult.threadInProgress,
streamsInProgress: currentResult.streamsInProgress,
streamOrder: currentResult.streamOrder,
deltaCursor: currentResult.deltaCursor,
});
}
// Should move to second thread
currentResult = await t.mutation(internal.users._deletePageForUserId, {
userId: "multiUser",
messagesCursor: currentResult.messagesCursor,
threadsCursor: currentResult.threadsCursor,
threadInProgress: currentResult.threadInProgress,
streamsInProgress: currentResult.streamsInProgress,
streamOrder: currentResult.streamOrder,
deltaCursor: currentResult.deltaCursor,
});
expect(currentResult.threadInProgress).toBeTruthy();
expect(currentResult.isDone).toBe(false);
// Complete second thread
while (!currentResult.isDone) {
currentResult = await t.mutation(internal.users._deletePageForUserId, {
userId: "multiUser",
messagesCursor: currentResult.messagesCursor,
threadsCursor: currentResult.threadsCursor,
threadInProgress: currentResult.threadInProgress,
streamsInProgress: currentResult.streamsInProgress,
streamOrder: currentResult.streamOrder,
deltaCursor: currentResult.deltaCursor,
});
}
// All threads should be deleted
const threadsAfter = await t.query(api.threads.listThreadsByUserId, {
userId: "multiUser",
});
expect(threadsAfter.page).toHaveLength(0);
});
test("deleteAllForUserIdAsync with multiple scheduled iterations", async () => {
// Enable fake timers for scheduled function testing
vi.useFakeTimers();
const t = convexTest(schema, modules);
// Create a user with multiple threads and many messages to force multiple scheduling iterations
const thread1 = await t.mutation(api.threads.createThread, {
userId: "multiIterUser",
title: "Multi Iter Thread 1",
});
const thread2 = await t.mutation(api.threads.createThread, {
userId: "multiIterUser",
title: "Multi Iter Thread 2",
});
// Add many messages to force pagination
const messages = [];
for (let i = 0; i < 150; i++) {
messages.push({
message: { role: "user" as const, content: `Message ${i}` },
});
}
await t.mutation(api.messages.addMessages, {
threadId: thread1._id as Id<"threads">,
messages,
});
await t.mutation(api.messages.addMessages, {
threadId: thread2._id as Id<"threads">,
messages,
});
// Start async deletion
const result = await t.mutation(api.users.deleteAllForUserIdAsync, {
userId: "multiIterUser",
});
// Should return false since there's a lot of work to do
expect(result).toBe(false);
// Use finishAllScheduledFunctions to handle recursive scheduling
await t.finishAllScheduledFunctions(vi.runAllTimers);
// Verify all data is deleted
const afterThreads = await t.query(api.threads.listThreadsByUserId, {
userId: "multiIterUser",
});
expect(afterThreads.page).toHaveLength(0);
// Reset to normal timers
vi.useRealTimers();
});
test("getThreadUserId returns correct userId", async () => {
const t = convexTest(schema, modules);
const thread = await t.mutation(api.threads.createThread, {
userId: "testUserId",
title: "Test Thread",
});
const userId = await t.query(internal.users.getThreadUserId, {
threadId: thread._id as Id<"threads">,
});
expect(userId).toBe("testUserId");
});
test("getThreadUserId returns null for non-existent thread", async () => {
const t = convexTest(schema, modules);
// Create a thread, then delete it to get a valid but non-existent ID
const thread = await t.mutation(api.threads.createThread, {
userId: "tempUser",
title: "Temp Thread",
});
// Delete the thread so it no longer exists
await t.action(api.threads.deleteAllForThreadIdSync, {
threadId: thread._id as Id<"threads">,
});
// Now test with the deleted thread's ID
const userId = await t.query(internal.users.getThreadUserId, {
threadId: thread._id as Id<"threads">,
});
expect(userId).toBeNull();
});
test("deleteAllForUserId handles user with no threads", async () => {
const t = convexTest(schema, modules);
// Try to delete for a user that doesn't exist
await expect(
t.action(api.users.deleteAllForUserId, { userId: "nonexistentUser" }),
).resolves.not.toThrow();
// Should complete successfully without errors
});
});