import { existsSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { addAppConversationId, backfillAppConversationIds, createApp, getApp, } from "../apps/app-store.js"; import { getDb } from "../persistence/db-connection.js"; import { initializeDb } from "../persistence/db-init.js"; import { migrateBackfillAppConversationLineage } from "../persistence/migrations/354-backfill-app-conversation-lineage.js"; import { rawRun } from "../persistence/raw-query.js"; // Initialize db once for all tests await initializeDb(); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- let testDataDir: string; function freshTempDir(): string { return join( tmpdir(), `vellum-app-backfill-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); } function makeAppParams(name: string) { return { name, schemaJson: "{}", htmlDefinition: "

Hello

", }; } /** Insert a message row with the given conversation_id and content JSON. */ function insertMessage( conversationId: string, content: unknown[], role = "assistant", ): void { const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2)}`; const contentStr = JSON.stringify(content); rawRun( "test:insertMessage", `INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`, id, conversationId, role, contentStr, Date.now(), ); } /** Insert a conversation row so FK constraints are satisfied. */ function insertConversation(id: string, parentConversationId?: string): void { const now = Date.now(); rawRun( "test:insertConversation", `INSERT OR IGNORE INTO conversations ( id, title, created_at, updated_at, parent_conversation_id ) VALUES (?, ?, ?, ?, ?)`, id, "test", now, now, parentConversationId ?? null, ); } // --------------------------------------------------------------------------- // Setup / teardown // --------------------------------------------------------------------------- beforeEach(() => { // Clean database tables between tests const db = getDb(); db.run(`DELETE FROM messages`); db.run(`DELETE FROM conversations`); // Fresh temp dir for app-store filesystem operations testDataDir = freshTempDir(); process.env.VELLUM_WORKSPACE_DIR = testDataDir; }); afterEach(() => { if (existsSync(testDataDir)) { rmSync(testDataDir, { recursive: true, force: true }); } }); // --------------------------------------------------------------------------- // backfillAppConversationIds // --------------------------------------------------------------------------- describe("backfillAppConversationIds", () => { test("populates conversationIds from ui_surface blocks in messages", () => { const app = createApp(makeAppParams("My App")); const convId = "conv-backfill-1"; insertConversation(convId); // Insert a message with a ui_surface block referencing the app insertMessage(convId, [ { type: "text", text: "Here is your app" }, { type: "ui_surface", surfaceType: "dynamic_page", data: { appId: app.id, html: "

App

" }, }, ]); backfillAppConversationIds(); const loaded = getApp(app.id); expect(loaded?.conversationIds).toEqual([convId]); }); test("handles multiple apps and conversations", () => { const app1 = createApp(makeAppParams("App One")); const app2 = createApp(makeAppParams("App Two")); const conv1 = "conv-multi-1"; const conv2 = "conv-multi-2"; insertConversation(conv1); insertConversation(conv2); // app1 referenced in conv1 and conv2 insertMessage(conv1, [ { type: "ui_surface", surfaceType: "dynamic_page", data: { appId: app1.id, html: "

A1

" }, }, ]); insertMessage(conv2, [ { type: "ui_surface", surfaceType: "dynamic_page", data: { appId: app1.id, html: "

A1

" }, }, ]); // app2 referenced only in conv2 insertMessage(conv2, [ { type: "ui_surface", surfaceType: "dynamic_page", data: { appId: app2.id, html: "

A2

" }, }, ]); backfillAppConversationIds(); const loaded1 = getApp(app1.id); expect(loaded1?.conversationIds?.sort()).toEqual([conv1, conv2].sort()); const loaded2 = getApp(app2.id); expect(loaded2?.conversationIds).toEqual([conv2]); }); test("is idempotent — running twice does not duplicate associations", () => { const app = createApp(makeAppParams("Idempotent App")); const convId = "conv-idemp-1"; insertConversation(convId); insertMessage(convId, [ { type: "ui_surface", surfaceType: "dynamic_page", data: { appId: app.id, html: "

App

" }, }, ]); backfillAppConversationIds(); backfillAppConversationIds(); const loaded = getApp(app.id); expect(loaded?.conversationIds).toEqual([convId]); }); test("apps with no message references remain unchanged", () => { const app = createApp(makeAppParams("Untouched App")); const convId = "conv-unrelated"; insertConversation(convId); // Insert a message with no ui_surface blocks insertMessage(convId, [{ type: "text", text: "Hello world" }]); backfillAppConversationIds(); const loaded = getApp(app.id); expect(loaded?.conversationIds).toBeUndefined(); }); test("skips malformed message rows without error", () => { const app = createApp(makeAppParams("Robust App")); const convId = "conv-malformed"; insertConversation(convId); // Insert a message with invalid JSON that happens to match the LIKE filter const msgId = `msg-malformed-${Date.now()}`; rawRun( "test:insertMalformedMessage", `INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`, msgId, convId, "assistant", 'not valid json but has "type":"ui_surface" in it', Date.now(), ); // Also insert a valid message referencing the app const convId2 = "conv-valid"; insertConversation(convId2); insertMessage(convId2, [ { type: "ui_surface", surfaceType: "dynamic_page", data: { appId: app.id, html: "

App

" }, }, ]); // Should not throw, and should still process the valid message backfillAppConversationIds(); const loaded = getApp(app.id); expect(loaded?.conversationIds).toEqual([convId2]); }); test("skips ui_surface blocks without data.appId", () => { const app = createApp(makeAppParams("No AppId App")); const convId = "conv-no-appid"; insertConversation(convId); // ui_surface block without appId in data insertMessage(convId, [ { type: "ui_surface", surfaceType: "card", data: { title: "Hello", body: "World" }, }, ]); backfillAppConversationIds(); const loaded = getApp(app.id); expect(loaded?.conversationIds).toBeUndefined(); }); test("preserves existing conversationIds added before backfill", () => { const app = createApp(makeAppParams("Pre-existing App")); const existingConvId = "conv-existing"; const backfillConvId = "conv-from-backfill"; insertConversation(existingConvId); insertConversation(backfillConvId); // Manually add a conversationId before backfill addAppConversationId(app.id, existingConvId); // Insert a message referencing the app from a different conversation insertMessage(backfillConvId, [ { type: "ui_surface", surfaceType: "dynamic_page", data: { appId: app.id, html: "

App

" }, }, ]); backfillAppConversationIds(); const loaded = getApp(app.id); expect(loaded?.conversationIds).toEqual([existingConvId, backfillConvId]); }); }); describe("migrateBackfillAppConversationLineage", () => { test("adds inherited ancestors to existing direct app associations", () => { const app = createApp(makeAppParams("Lineage App")); const rootConversationId = "conv-lineage-root"; const parentConversationId = "conv-lineage-parent"; const childConversationId = "conv-lineage-child"; insertConversation(rootConversationId); insertConversation(parentConversationId, rootConversationId); insertConversation(childConversationId, parentConversationId); addAppConversationId(app.id, childConversationId); migrateBackfillAppConversationLineage(); migrateBackfillAppConversationLineage(); const loaded = getApp(app.id); expect(loaded?.conversationIds).toEqual([ childConversationId, parentConversationId, rootConversationId, ]); expect(loaded?.inheritedConversationIds).toEqual([ parentConversationId, rootConversationId, ]); }); });