feat: sync savings chat history with convex
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { convexTest } from "convex-test";
|
||||
import { generateText } from "ai";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
@@ -57,6 +58,18 @@ vi.mock("ai", async (importOriginal) => {
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
delete modules["./savingsChat.test.ts"];
|
||||
|
||||
const historyApi = {
|
||||
listSessions: makeFunctionReference<"query">("savingsChatHistory:listSessions"),
|
||||
listMessages: makeFunctionReference<"query">("savingsChatHistory:listMessages"),
|
||||
createSession: makeFunctionReference<"mutation">("savingsChatHistory:createSession"),
|
||||
deleteSession: makeFunctionReference<"mutation">("savingsChatHistory:deleteSession"),
|
||||
importLocalSession: makeFunctionReference<"mutation">("savingsChatHistory:importLocalSession"),
|
||||
};
|
||||
|
||||
const sendMessageAction = makeFunctionReference<"action">("savingsChat:sendMessage");
|
||||
|
||||
const paginationOpts = { cursor: null, numItems: 20 };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -205,6 +218,336 @@ async function seedSavingsInsightFixture() {
|
||||
};
|
||||
}
|
||||
|
||||
describe("savingsChatHistory", () => {
|
||||
test("keeps sessions and messages scoped to the authenticated user and hides deleted sessions", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", { name: "Laptop User", email: "laptop@example.com" });
|
||||
const otherUserId = await ctx.db.insert("users", { name: "PC User", email: "pc@example.com" });
|
||||
return { userId, otherUserId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|laptop`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
const asOtherUser = t.withIdentity({
|
||||
subject: `${seeded.otherUserId}|pc`,
|
||||
tokenIdentifier: `test:${seeded.otherUserId}`,
|
||||
});
|
||||
|
||||
const imported = await asUser.mutation(historyApi.importLocalSession, {
|
||||
legacyLocalId: "local-laptop-chat",
|
||||
title: "Laptop Chat",
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_100_000,
|
||||
messages: [
|
||||
{ role: "assistant", content: "Frag mich zu deinen Umsätzen." },
|
||||
{ role: "user", content: "Wie viel habe ich gespart?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Du hast 100 EUR gespart.",
|
||||
toolTrace: [
|
||||
{
|
||||
name: "summarize_spending",
|
||||
inputSummary: "Standard-Kontext",
|
||||
resultSummary: "2 Umsätze, Saldo 100.00€, 1 Kategorien",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const userSessions = await asUser.query(historyApi.listSessions, { paginationOpts });
|
||||
expect(userSessions.page).toMatchObject([
|
||||
{ _id: imported.sessionId, title: "Laptop Chat", messageCount: 3 },
|
||||
]);
|
||||
|
||||
const otherSessions = await asOtherUser.query(historyApi.listSessions, { paginationOpts });
|
||||
expect(otherSessions.page).toEqual([]);
|
||||
|
||||
const messages = await asUser.query(historyApi.listMessages, {
|
||||
sessionId: imported.sessionId,
|
||||
paginationOpts,
|
||||
});
|
||||
expect([...messages.page].reverse()).toMatchObject([
|
||||
{ role: "assistant", content: "Frag mich zu deinen Umsätzen." },
|
||||
{ role: "user", content: "Wie viel habe ich gespart?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Du hast 100 EUR gespart.",
|
||||
toolTrace: [
|
||||
{
|
||||
name: "summarize_spending",
|
||||
inputSummary: "Standard-Kontext",
|
||||
resultSummary: "2 Umsätze, Saldo 100.00€, 1 Kategorien",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
asOtherUser.query(historyApi.listMessages, {
|
||||
sessionId: imported.sessionId,
|
||||
paginationOpts,
|
||||
}),
|
||||
).rejects.toThrow("Nicht autorisiert");
|
||||
|
||||
await expect(
|
||||
asOtherUser.action(sendMessageAction, {
|
||||
sessionId: imported.sessionId,
|
||||
content: "Kann ich diesen Chat nutzen?",
|
||||
from: "2026-02-01",
|
||||
to: "2026-02-28",
|
||||
basis: "effective",
|
||||
}),
|
||||
).rejects.toThrow("Nicht autorisiert");
|
||||
|
||||
await expect(
|
||||
asOtherUser.mutation(historyApi.deleteSession, { sessionId: imported.sessionId }),
|
||||
).rejects.toThrow("Nicht autorisiert");
|
||||
|
||||
await asUser.mutation(historyApi.deleteSession, { sessionId: imported.sessionId });
|
||||
const sessionsAfterDelete = await asUser.query(historyApi.listSessions, { paginationOpts });
|
||||
expect(sessionsAfterDelete.page).toEqual([]);
|
||||
});
|
||||
|
||||
test("imports a legacy local session once per legacy id without duplicating messages", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", { name: "Legacy User", email: "legacy@example.com" });
|
||||
return { userId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|legacy`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const input = {
|
||||
legacyLocalId: "legacy-session-1",
|
||||
title: "Alter Laptop-Chat",
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_500_000,
|
||||
messages: [
|
||||
{ role: "assistant", content: "Hallo" },
|
||||
{ role: "user", content: "Was waren meine Fixkosten?" },
|
||||
],
|
||||
};
|
||||
|
||||
const first = await asUser.mutation(historyApi.importLocalSession, input);
|
||||
const second = await asUser.mutation(historyApi.importLocalSession, input);
|
||||
|
||||
expect(second.sessionId).toBe(first.sessionId);
|
||||
|
||||
const sessions = await asUser.query(historyApi.listSessions, { paginationOpts });
|
||||
expect(sessions.page).toHaveLength(1);
|
||||
expect(sessions.page[0]).toMatchObject({
|
||||
_id: first.sessionId,
|
||||
title: "Alter Laptop-Chat",
|
||||
messageCount: 2,
|
||||
});
|
||||
|
||||
const messages = await asUser.query(historyApi.listMessages, {
|
||||
sessionId: first.sessionId,
|
||||
paginationOpts,
|
||||
});
|
||||
expect(messages.page).toHaveLength(2);
|
||||
expect([...messages.page].reverse().map((message: { content: string }) => message.content)).toEqual([
|
||||
"Hallo",
|
||||
"Was waren meine Fixkosten?",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("savingsChat.sendMessage", () => {
|
||||
test("stores user and assistant messages with tool traces and uses recent Convex history", async () => {
|
||||
const previousKey = process.env.OPENAI_API_KEY;
|
||||
const previousModel = process.env.SAVINGS_CHAT_MODEL;
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", { name: "Chat User", email: "chat@example.com" });
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-02-01",
|
||||
valueDate: "2026-02-01",
|
||||
description: "Gehalt",
|
||||
amount: 3000,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-02",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-02-10",
|
||||
valueDate: "2026-02-10",
|
||||
description: "Supermarkt",
|
||||
amount: -120,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-02",
|
||||
});
|
||||
return { userId, accountId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|chat`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
delete process.env.SAVINGS_CHAT_MODEL;
|
||||
|
||||
const created = await asUser.mutation(historyApi.createSession, { title: "Neuer Chat" });
|
||||
for (let index = 0; index < 24; index++) {
|
||||
await asUser.mutation(historyApi.importLocalSession, {
|
||||
legacyLocalId: `history-${index}`,
|
||||
title: `Historie ${index}`,
|
||||
createdAt: index,
|
||||
updatedAt: index,
|
||||
messages: [{ role: "user", content: `Separate legacy message ${index}` }],
|
||||
});
|
||||
}
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
for (let index = 0; index < 24; index++) {
|
||||
await ctx.db.insert("chatMessages", {
|
||||
userId: seeded.userId,
|
||||
sessionId: created.sessionId,
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `Vorige Nachricht ${index}`,
|
||||
createdAt: index,
|
||||
});
|
||||
}
|
||||
await ctx.db.patch(created.sessionId, {
|
||||
messageCount: 25,
|
||||
updatedAt: 24,
|
||||
});
|
||||
});
|
||||
|
||||
const result = await asUser.action(sendMessageAction, {
|
||||
sessionId: created.sessionId,
|
||||
content: "Wie sieht Februar aus?",
|
||||
from: "2026-02-01",
|
||||
to: "2026-02-28",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
});
|
||||
|
||||
expect(result.answer).toBe("Agenten-Antwort");
|
||||
expect(result.toolTrace).toHaveLength(2);
|
||||
|
||||
const generateCall = vi.mocked(generateText).mock.calls[0][0] as {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
};
|
||||
expect(generateCall.messages).toHaveLength(20);
|
||||
expect(generateCall.messages[0].content).toBe("Vorige Nachricht 6");
|
||||
expect(generateCall.messages[generateCall.messages.length - 1]).toEqual({
|
||||
role: "user",
|
||||
content: "Wie sieht Februar aus?",
|
||||
});
|
||||
|
||||
const messages = await asUser.query(historyApi.listMessages, {
|
||||
sessionId: created.sessionId,
|
||||
paginationOpts: { cursor: null, numItems: 40 },
|
||||
});
|
||||
expect([...messages.page].reverse().slice(-2)).toMatchObject([
|
||||
{ role: "user", content: "Wie sieht Februar aus?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Agenten-Antwort",
|
||||
toolTrace: [
|
||||
{
|
||||
name: "get_transactions",
|
||||
inputSummary: "2026-02-01 bis 2026-02-28, Limit 2",
|
||||
resultSummary: "2 Umsätze, Saldo 2880.00€, vollständig",
|
||||
},
|
||||
{
|
||||
name: "summarize_spending",
|
||||
inputSummary: "2026-02-01 bis 2026-02-28",
|
||||
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
if (previousKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousKey;
|
||||
}
|
||||
if (previousModel === undefined) {
|
||||
delete process.env.SAVINGS_CHAT_MODEL;
|
||||
} else {
|
||||
process.env.SAVINGS_CHAT_MODEL = previousModel;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("stores an assistant failure message when AI generation fails after the user message is saved", async () => {
|
||||
const previousKey = process.env.OPENAI_API_KEY;
|
||||
const previousModel = process.env.SAVINGS_CHAT_MODEL;
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", { name: "Failure User", email: "failure@example.com" });
|
||||
return { userId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|failure`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
delete process.env.SAVINGS_CHAT_MODEL;
|
||||
vi.mocked(generateText)
|
||||
.mockRejectedValueOnce(new Error("model failed 1"))
|
||||
.mockRejectedValueOnce(new Error("model failed 2"))
|
||||
.mockRejectedValueOnce(new Error("model failed 3"));
|
||||
|
||||
const created = await asUser.mutation(historyApi.createSession, { title: "Neuer Chat" });
|
||||
await expect(
|
||||
asUser.action(sendMessageAction, {
|
||||
sessionId: created.sessionId,
|
||||
content: "Warum geht das nicht?",
|
||||
from: "2026-02-01",
|
||||
to: "2026-02-28",
|
||||
basis: "effective",
|
||||
}),
|
||||
).rejects.toThrow("KI-Anfrage fehlgeschlagen");
|
||||
|
||||
const messages = await asUser.query(historyApi.listMessages, {
|
||||
sessionId: created.sessionId,
|
||||
paginationOpts: { cursor: null, numItems: 10 },
|
||||
});
|
||||
expect([...messages.page].reverse().slice(-2)).toMatchObject([
|
||||
{ role: "user", content: "Warum geht das nicht?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
if (previousKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousKey;
|
||||
}
|
||||
if (previousModel === undefined) {
|
||||
delete process.env.SAVINGS_CHAT_MODEL;
|
||||
} else {
|
||||
process.env.SAVINGS_CHAT_MODEL = previousModel;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("savingsChat.getContext", () => {
|
||||
test("counts and sums every matching transaction before applying prompt limits", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
Reference in New Issue
Block a user