import type { JobPublisher } from "@rakazo/adapter-kit"; import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; import type * as CoachRhythmModule from "./coach-rhythm.js"; import { syncCoachTimeZone } from "./coach-rhythm.js"; import { selectBuiltinToolsForRun } from "./executor-tools.js"; import { updateUserProfileFromTool } from "./user-profile-tools.js"; vi.mock("./coach-rhythm.js", async (importOriginal) => ({ ...(await importOriginal()), syncCoachTimeZone: vi.fn(async () => undefined), })); function toolNames(options: { mainBot?: boolean; messagingChannelRun?: boolean; foreignAgentRun?: boolean; }) { return selectBuiltinToolsForRun({ graphicalToolsAllowed: false, groupId: null, trigger: "user", semanticMemoryEnabled: false, messagingChannelRun: options.messagingChannelRun ?? false, foreignAgentRun: options.foreignAgentRun, bot: { kind: options.mainBot ? "main" : "specialist", restrictBuiltinTools: false, allowedBuiltinTools: [], }, }).map((tool) => tool.name); } /** Any database or job access fails the test: refusals and invalid input must not reach it. */ const untouched = new Proxy( {}, { get(_target, property) { throw new Error(`unexpected access: ${String(property)}`); }, }, ); const untouchedDeps = { prisma: untouched as PrismaClient, jobs: untouched as JobPublisher }; const context = (kind: string) => ({ userId: "user-1", bot: { id: "bot-1", kind }, threadId: "thread-1", sourceMessageId: "message-1", }); describe("update_user_profile", () => { it("is offered to the main bot only, and never in shared messaging channels", () => { expect(toolNames({ mainBot: true })).toContain("update_user_profile"); expect(toolNames({ mainBot: false })).not.toContain("update_user_profile"); expect(toolNames({})).not.toContain("update_user_profile"); expect(toolNames({ mainBot: true, messagingChannelRun: true })).not.toContain( "update_user_profile", ); }); it("is withheld from runs that answer another owner's agent", () => { const names = toolNames({ mainBot: true, foreignAgentRun: true }); expect(names).not.toContain("update_user_profile"); expect(names).toContain("delegate_task"); }); it("refuses specialists even when they call the tool anyway", async () => { await expect( updateUserProfileFromTool(untouchedDeps, context("specialist"), { facts: [{ category: "work", value: "Nurse" }], }), ).resolves.toEqual({ error: "Only the user's main bot can update the user profile." }); }); it("rejects invalid or empty updates before writing", async () => { const invalidCategory = await updateUserProfileFromTool(untouchedDeps, context("main"), { facts: [{ category: "secrets", value: "x" }], }); expect(invalidCategory).toEqual({ error: expect.stringContaining("facts.0.category") }); const unknownZone = await updateUserProfileFromTool(untouchedDeps, context("main"), { time_zone: "Vienna", }); expect(unknownZone).toEqual({ error: expect.stringContaining("Unknown time zone") }); await expect(updateUserProfileFromTool(untouchedDeps, context("main"), {})).resolves.toEqual({ error: "Nothing to save. Pass facts or a profile field.", }); }); it("records conversation facts with where they were learned", async () => { const created: Array> = []; const tx = { $queryRaw: vi.fn().mockResolvedValue([]), userProfile: { upsert: vi.fn().mockResolvedValue({}) }, userFact: { findMany: vi.fn().mockResolvedValue([]), create: vi.fn(async ({ data }: { data: Record }) => { created.push(data); return { id: `fact-${created.length}`, value: data.value, status: data.status }; }), }, }; const prisma = { $transaction: (run: (client: typeof tx) => Promise) => run(tx), } as unknown as PrismaClient; const jobs = { enqueue: vi.fn(), cancel: vi.fn() }; const result = await updateUserProfileFromTool({ prisma, jobs }, context("main"), { facts: [ { category: "work", value: "Nurse" }, { category: "health", value: "Asthma" }, ], time_zone: "europe/vienna", }); expect(result).toMatchObject({ ok: true, saved: 1, pendingConfirmation: 1 }); expect(tx.userProfile.upsert).toHaveBeenCalledWith( expect.objectContaining({ update: { timeZone: "Europe/Vienna" } }), ); expect(created).toEqual([ expect.objectContaining({ category: "work", value: "Nurse", source: "conversation", status: "active", sourceBotId: "bot-1", sourceThreadId: "thread-1", sourceMessageId: "message-1", }), expect.objectContaining({ category: "health", status: "pending", sensitive: true }), ]); // A new time zone moves the check-ins; facts alone leave them be. expect(syncCoachTimeZone).toHaveBeenCalledExactlyOnceWith({ prisma, jobs }, "user-1"); vi.mocked(syncCoachTimeZone).mockClear(); await updateUserProfileFromTool({ prisma, jobs }, context("main"), { facts: [{ category: "work", value: "Nurse" }], }); expect(syncCoachTimeZone).not.toHaveBeenCalled(); }); });