feat: add guarded savings agent tools
This commit is contained in:
@@ -25,12 +25,19 @@ vi.mock("ai", async (importOriginal) => {
|
||||
compare_periods: { execute: (input: unknown) => Promise<unknown> };
|
||||
forecast_fixed_costs: { execute: (input: unknown) => Promise<unknown> };
|
||||
explain_savings_rate: { execute: (input: unknown) => Promise<unknown> };
|
||||
inspect_category_health: { execute: (input: unknown) => Promise<unknown> };
|
||||
suggest_category_for_transactions: { execute: (input: unknown) => Promise<unknown> };
|
||||
preview_month_end_spending: { execute: (input: unknown) => Promise<unknown> };
|
||||
propose_bulk_recategory: { execute: (input: unknown) => Promise<unknown> };
|
||||
propose_category_changes: { execute: (input: unknown) => Promise<unknown> };
|
||||
};
|
||||
}) => {
|
||||
const transactionInput = { from: "2026-02-01", to: "2026-02-28", limit: 2 };
|
||||
const summaryInput = { from: "2026-02-01", to: "2026-02-28" };
|
||||
const previewInput = { today: "2026-02-20" };
|
||||
const transactionOutput = await options.tools.get_transactions.execute(transactionInput);
|
||||
const summaryOutput = await options.tools.summarize_spending.execute(summaryInput);
|
||||
const previewOutput = await options.tools.preview_month_end_spending.execute(previewInput);
|
||||
|
||||
return {
|
||||
text: "Agenten-Antwort",
|
||||
@@ -47,6 +54,11 @@ vi.mock("ai", async (importOriginal) => {
|
||||
input: summaryInput,
|
||||
output: summaryOutput,
|
||||
},
|
||||
{
|
||||
toolName: "preview_month_end_spending",
|
||||
input: previewInput,
|
||||
output: previewOutput,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -67,6 +79,16 @@ const historyApi = {
|
||||
};
|
||||
|
||||
const sendMessageAction = makeFunctionReference<"action">("savingsChat:sendMessage");
|
||||
const previewMonthEndSpendingTool = makeFunctionReference<"query">("savingsChat:previewMonthEndSpendingTool");
|
||||
const inspectCategoryHealthTool = makeFunctionReference<"query">("savingsChat:inspectCategoryHealthTool");
|
||||
const suggestCategoryForTransactionsTool = makeFunctionReference<"query">("savingsChat:suggestCategoryForTransactionsTool");
|
||||
const proposeBulkRecategoryTool = makeFunctionReference<"mutation">("savingsChat:proposeBulkRecategoryTool");
|
||||
const proposeCategoryChangesTool = makeFunctionReference<"mutation">("savingsChat:proposeCategoryChangesTool");
|
||||
const actionPlanApi = {
|
||||
listPendingActionPlans: makeFunctionReference<"query">("savingsChatActionPlans:listPendingActionPlans"),
|
||||
applyActionPlan: makeFunctionReference<"mutation">("savingsChatActionPlans:applyActionPlan"),
|
||||
dismissActionPlan: makeFunctionReference<"mutation">("savingsChatActionPlans:dismissActionPlan"),
|
||||
};
|
||||
|
||||
const paginationOpts = { cursor: null, numItems: 20 };
|
||||
|
||||
@@ -454,10 +476,11 @@ describe("savingsChat.sendMessage", () => {
|
||||
to: "2026-02-28",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
today: "2026-02-20",
|
||||
});
|
||||
|
||||
expect(result.answer).toBe("Agenten-Antwort [1] [2]");
|
||||
expect(result.toolTrace).toHaveLength(2);
|
||||
expect(result.answer).toBe("Agenten-Antwort [1] [2] [3]");
|
||||
expect(result.toolTrace).toHaveLength(3);
|
||||
expect(result.sources).toEqual([
|
||||
{
|
||||
id: "tool-1",
|
||||
@@ -469,10 +492,16 @@ describe("savingsChat.sendMessage", () => {
|
||||
title: "summarize_spending",
|
||||
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
|
||||
},
|
||||
{
|
||||
id: "tool-3",
|
||||
title: "preview_month_end_spending",
|
||||
description: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
|
||||
},
|
||||
]);
|
||||
expect(result.citations).toEqual([
|
||||
{ marker: "1", sourceId: "tool-1" },
|
||||
{ marker: "2", sourceId: "tool-2" },
|
||||
{ marker: "3", sourceId: "tool-3" },
|
||||
]);
|
||||
|
||||
const generateCall = vi.mocked(generateText).mock.calls[0][0] as {
|
||||
@@ -493,7 +522,7 @@ describe("savingsChat.sendMessage", () => {
|
||||
{ role: "user", content: "Wie sieht Februar aus?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Agenten-Antwort [1] [2]",
|
||||
content: "Agenten-Antwort [1] [2] [3]",
|
||||
toolTrace: [
|
||||
{
|
||||
name: "get_transactions",
|
||||
@@ -505,6 +534,11 @@ describe("savingsChat.sendMessage", () => {
|
||||
inputSummary: "2026-02-01 bis 2026-02-28",
|
||||
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
|
||||
},
|
||||
{
|
||||
name: "preview_month_end_spending",
|
||||
inputSummary: "Monatsvorschau für 2026-02-20",
|
||||
resultSummary: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
@@ -517,10 +551,16 @@ describe("savingsChat.sendMessage", () => {
|
||||
title: "summarize_spending",
|
||||
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
|
||||
},
|
||||
{
|
||||
id: "tool-3",
|
||||
title: "preview_month_end_spending",
|
||||
description: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
|
||||
},
|
||||
],
|
||||
citations: [
|
||||
{ marker: "1", sourceId: "tool-1" },
|
||||
{ marker: "2", sourceId: "tool-2" },
|
||||
{ marker: "3", sourceId: "tool-3" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -567,6 +607,7 @@ describe("savingsChat.sendMessage", () => {
|
||||
from: "2026-02-01",
|
||||
to: "2026-02-28",
|
||||
basis: "effective",
|
||||
today: "2026-02-20",
|
||||
}),
|
||||
).rejects.toThrow("KI-Anfrage fehlgeschlagen");
|
||||
|
||||
@@ -1428,9 +1469,10 @@ describe("savingsChat read-only agent tools", () => {
|
||||
to: "2026-02-28",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
today: "2026-02-20",
|
||||
});
|
||||
|
||||
expect(result.answer).toBe("Agenten-Antwort [1] [2]");
|
||||
expect(result.answer).toBe("Agenten-Antwort [1] [2] [3]");
|
||||
expect(result.model).toBe("gpt-5.4-mini");
|
||||
expect(result.usedTransactions).toBe(2);
|
||||
expect(result.usedBalance).toEqual({ income: 3000, expenses: -120, balance: 2880 });
|
||||
@@ -1445,6 +1487,11 @@ describe("savingsChat read-only agent tools", () => {
|
||||
inputSummary: "2026-02-01 bis 2026-02-28",
|
||||
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
|
||||
},
|
||||
{
|
||||
name: "preview_month_end_spending",
|
||||
inputSummary: "Monatsvorschau für 2026-02-20",
|
||||
resultSummary: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
|
||||
},
|
||||
]);
|
||||
expect(result.sources).toEqual([
|
||||
{
|
||||
@@ -1457,10 +1504,16 @@ describe("savingsChat read-only agent tools", () => {
|
||||
title: "summarize_spending",
|
||||
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
|
||||
},
|
||||
{
|
||||
id: "tool-3",
|
||||
title: "preview_month_end_spending",
|
||||
description: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
|
||||
},
|
||||
]);
|
||||
expect(result.citations).toEqual([
|
||||
{ marker: "1", sourceId: "tool-1" },
|
||||
{ marker: "2", sourceId: "tool-2" },
|
||||
{ marker: "3", sourceId: "tool-3" },
|
||||
]);
|
||||
expect(JSON.stringify(result.toolTrace)).not.toContain("RAW PAYLOAD");
|
||||
expect(JSON.stringify(result.toolTrace)).not.toContain("private note");
|
||||
@@ -1479,6 +1532,11 @@ describe("savingsChat read-only agent tools", () => {
|
||||
compare_periods: expect.any(Object),
|
||||
forecast_fixed_costs: expect.any(Object),
|
||||
explain_savings_rate: expect.any(Object),
|
||||
inspect_category_health: expect.any(Object),
|
||||
suggest_category_for_transactions: expect.any(Object),
|
||||
preview_month_end_spending: expect.any(Object),
|
||||
propose_bulk_recategory: expect.any(Object),
|
||||
propose_category_changes: expect.any(Object),
|
||||
}),
|
||||
stopWhen: expect.any(Function),
|
||||
}),
|
||||
@@ -1644,6 +1702,665 @@ describe("savingsChat read-only agent tools", () => {
|
||||
expect(JSON.stringify(result)).not.toContain("PRIVATE NOTE SHOULD NOT LEAK");
|
||||
});
|
||||
|
||||
test("previewMonthEndSpendingTool forecasts remaining current-month expenses for the selected account", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Preview User",
|
||||
email: "preview@example.com",
|
||||
});
|
||||
const giroAccountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const otherAccountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Tagesgeld",
|
||||
type: "savings",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const rentId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Miete",
|
||||
kind: "ausgabe",
|
||||
block: "wiederkehrend",
|
||||
color: "#64748b",
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
});
|
||||
const groceriesId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Lebensmittel",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#22c55e",
|
||||
sortOrder: 2,
|
||||
isSystem: false,
|
||||
});
|
||||
const salaryId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Gehalt",
|
||||
kind: "einnahme",
|
||||
color: "#0ea5e9",
|
||||
sortOrder: 3,
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
for (const tx of [
|
||||
{ date: "2026-04-28", description: "Miete April", amount: -1000, categoryId: rentId },
|
||||
{ date: "2026-05-28", description: "Miete Mai", amount: -1000, categoryId: rentId },
|
||||
{ date: "2026-04-05", description: "Supermarkt April", amount: -120, categoryId: groceriesId },
|
||||
{ date: "2026-05-05", description: "Supermarkt Mai", amount: -150, categoryId: groceriesId },
|
||||
{ date: "2026-06-01", description: "Gehalt Juni", amount: 3000, categoryId: salaryId },
|
||||
{ date: "2026-06-05", description: "Supermarkt Juni", amount: -100, categoryId: groceriesId },
|
||||
{ date: "2026-06-10", description: "Drogerie Juni", amount: -50, categoryId: groceriesId },
|
||||
]) {
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
categoryId: tx.categoryId,
|
||||
bookingDate: tx.date,
|
||||
valueDate: tx.date,
|
||||
description: tx.description,
|
||||
amount: tx.amount,
|
||||
isPending: false,
|
||||
effectiveMonth: tx.date.slice(0, 7),
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: otherAccountId,
|
||||
bookingDate: "2026-06-07",
|
||||
valueDate: "2026-06-07",
|
||||
description: "Other account should not affect preview",
|
||||
amount: -999,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
|
||||
return { userId, giroAccountId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|preview`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const result = await asUser.query(previewMonthEndSpendingTool, {
|
||||
scope: {
|
||||
from: "2026-01-01",
|
||||
to: "2026-12-31",
|
||||
accountId: seeded.giroAccountId as Id<"accounts">,
|
||||
basis: "booking",
|
||||
},
|
||||
today: "2026-06-10",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
month: "2026-06",
|
||||
today: "2026-06-10",
|
||||
accountName: "Girokonto",
|
||||
actualIncome: 3000,
|
||||
actualExpenses: -150,
|
||||
predictedRemainingFixed: -1000,
|
||||
predictedRemainingVariable: -300,
|
||||
predictedRemainingExpenses: -1300,
|
||||
expectedMonthExpenses: -1450,
|
||||
projectedMonthEndBalance: 1550,
|
||||
confidence: "medium",
|
||||
});
|
||||
expect(result.drivers.map((driver: { label: string }) => driver.label)).toEqual([
|
||||
"Noch erwartete Fixkosten: Miete",
|
||||
"Variable Ausgaben auf Basis des laufenden Monats",
|
||||
]);
|
||||
});
|
||||
|
||||
test("inspectCategoryHealthTool reports uncategorized usage, unused categories, and similar names", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Category Health User",
|
||||
email: "category-health@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const groceriesId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Lebensmittel",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#22c55e",
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
});
|
||||
await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Lebensmittel & Supermarkt",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#16a34a",
|
||||
sortOrder: 2,
|
||||
isSystem: false,
|
||||
});
|
||||
await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Ungenutzt",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#f97316",
|
||||
sortOrder: 3,
|
||||
isSystem: false,
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
categoryId: groceriesId,
|
||||
bookingDate: "2026-06-01",
|
||||
description: "Supermarkt",
|
||||
amount: -40,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-06-02",
|
||||
description: "Mystery Shop",
|
||||
counterparty: "Mystery GmbH",
|
||||
amount: -30,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
return { userId, accountId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|health`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const result = await asUser.query(inspectCategoryHealthTool, {
|
||||
scope: {
|
||||
from: "2026-06-01",
|
||||
to: "2026-06-30",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "booking",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.totalCategories).toBe(3);
|
||||
expect(result.uncategorizedCount).toBe(1);
|
||||
expect(result.uncategorizedAmount).toBe(-30);
|
||||
expect(result.topUncategorizedCounterparties).toEqual([
|
||||
{ name: "Mystery GmbH", count: 1, amount: -30 },
|
||||
]);
|
||||
expect(result.unusedCategories.map((category: { name: string }) => category.name)).toContain("Ungenutzt");
|
||||
expect(result.similarCategories).toEqual([
|
||||
{ names: ["Lebensmittel", "Lebensmittel & Supermarkt"], reason: "Ähnliche Kategoriebegriffe" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("suggestCategoryForTransactionsTool proposes categories without leaking raw private fields", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Suggestion User",
|
||||
email: "suggestion@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const subscriptionsId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Abos",
|
||||
kind: "ausgabe",
|
||||
block: "wiederkehrend",
|
||||
color: "#a855f7",
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
categoryId: subscriptionsId,
|
||||
bookingDate: "2026-05-03",
|
||||
description: "Netflix",
|
||||
counterparty: "Netflix",
|
||||
amount: -15,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-05",
|
||||
rawText: "RAW SHOULD NOT LEAK",
|
||||
notes: "PRIVATE NOTE SHOULD NOT LEAK",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-06-03",
|
||||
description: "Netflix",
|
||||
counterparty: "Netflix",
|
||||
amount: -16,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
rawText: "RAW SHOULD NOT LEAK",
|
||||
notes: "PRIVATE NOTE SHOULD NOT LEAK",
|
||||
});
|
||||
return { userId, accountId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|suggestion`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const result = await asUser.query(suggestCategoryForTransactionsTool, {
|
||||
scope: {
|
||||
from: "2026-06-01",
|
||||
to: "2026-06-30",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "booking",
|
||||
},
|
||||
search: "Netflix",
|
||||
onlyUncategorized: true,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.suggestions).toEqual([
|
||||
{
|
||||
categoryName: "Abos",
|
||||
confidence: "high",
|
||||
matchCount: 1,
|
||||
amount: -16,
|
||||
examples: [
|
||||
{
|
||||
date: "2026-06-03",
|
||||
description: "Netflix",
|
||||
counterparty: "Netflix",
|
||||
amount: -16,
|
||||
accountName: "Girokonto",
|
||||
},
|
||||
],
|
||||
reason: "Ähnliche frühere Umsätze waren bereits dieser Kategorie zugeordnet.",
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain("RAW SHOULD NOT LEAK");
|
||||
expect(JSON.stringify(result)).not.toContain("PRIVATE NOTE SHOULD NOT LEAK");
|
||||
});
|
||||
|
||||
test("proposeBulkRecategoryTool creates a pending plan and applyActionPlan revalidates before changing transactions", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Plan User",
|
||||
email: "plan@example.com",
|
||||
});
|
||||
const otherUserId = await ctx.db.insert("users", {
|
||||
name: "Other Plan User",
|
||||
email: "other-plan@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const categoryId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Abos",
|
||||
kind: "ausgabe",
|
||||
block: "wiederkehrend",
|
||||
color: "#a855f7",
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
});
|
||||
const txId = await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-06-03",
|
||||
description: "Netflix",
|
||||
counterparty: "Netflix",
|
||||
amount: -16,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
return { userId, otherUserId, accountId, categoryId, txId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|plan`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
const asOtherUser = t.withIdentity({
|
||||
subject: `${seeded.otherUserId}|plan`,
|
||||
tokenIdentifier: `test:${seeded.otherUserId}`,
|
||||
});
|
||||
const session = await asUser.mutation(historyApi.createSession, { title: "Plan Chat" });
|
||||
|
||||
const proposed = await asUser.mutation(proposeBulkRecategoryTool, {
|
||||
scope: {
|
||||
from: "2026-06-01",
|
||||
to: "2026-06-30",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "booking",
|
||||
},
|
||||
sessionId: session.sessionId,
|
||||
targetCategoryName: "Abos",
|
||||
search: "Netflix",
|
||||
onlyUncategorized: true,
|
||||
limit: 10,
|
||||
now: 1_800_000_000_000,
|
||||
});
|
||||
|
||||
expect(proposed).toMatchObject({
|
||||
kind: "bulk_recategory",
|
||||
status: "pending",
|
||||
affectedCount: 1,
|
||||
summary: "1 Umsatz zur Kategorie Abos zuordnen",
|
||||
});
|
||||
let tx = await t.run(async (ctx) => await ctx.db.get(seeded.txId));
|
||||
expect(tx?.categoryId).toBeUndefined();
|
||||
|
||||
const otherPlans = await asOtherUser.query(actionPlanApi.listPendingActionPlans, {
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
expect(otherPlans).toEqual([]);
|
||||
await expect(
|
||||
asOtherUser.mutation(actionPlanApi.applyActionPlan, {
|
||||
planId: proposed.planId,
|
||||
now: 1_800_000_000_001,
|
||||
}),
|
||||
).rejects.toThrow("Nicht autorisiert");
|
||||
|
||||
const pending = await asUser.query(actionPlanApi.listPendingActionPlans, {
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]).toMatchObject({
|
||||
_id: proposed.planId,
|
||||
kind: "bulk_recategory",
|
||||
affectedCount: 1,
|
||||
previewRows: [
|
||||
{
|
||||
description: "Netflix",
|
||||
amount: -16,
|
||||
currentCategoryName: "Ohne Kategorie",
|
||||
targetCategoryName: "Abos",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const applied = await asUser.mutation(actionPlanApi.applyActionPlan, {
|
||||
planId: proposed.planId,
|
||||
now: 1_800_000_000_002,
|
||||
});
|
||||
expect(applied).toEqual({
|
||||
appliedCount: 1,
|
||||
skippedCount: 0,
|
||||
status: "applied",
|
||||
summary: "1 Änderung angewendet, 0 übersprungen",
|
||||
});
|
||||
tx = await t.run(async (ctx) => await ctx.db.get(seeded.txId));
|
||||
expect(tx?.categoryId).toBe(seeded.categoryId);
|
||||
});
|
||||
|
||||
test("proposeCategoryChangesTool applies category creates and updates but does not support deletes", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Category Plan User",
|
||||
email: "category-plan@example.com",
|
||||
});
|
||||
const subscriptionId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Abos",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#a855f7",
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
});
|
||||
return { userId, subscriptionId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|category-plan`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
const session = await asUser.mutation(historyApi.createSession, { title: "Category Plan Chat" });
|
||||
|
||||
const proposed = await asUser.mutation(proposeCategoryChangesTool, {
|
||||
sessionId: session.sessionId,
|
||||
creates: [
|
||||
{
|
||||
name: "Versicherungen",
|
||||
kind: "ausgabe",
|
||||
block: "wiederkehrend",
|
||||
color: "#0f766e",
|
||||
sortOrder: 10,
|
||||
},
|
||||
],
|
||||
updates: [
|
||||
{
|
||||
name: "Abos",
|
||||
block: "wiederkehrend",
|
||||
},
|
||||
],
|
||||
now: 1_800_000_000_000,
|
||||
});
|
||||
expect(proposed).toMatchObject({
|
||||
kind: "category_changes",
|
||||
status: "pending",
|
||||
affectedCount: 2,
|
||||
summary: "2 Kategorieänderungen vorbereiten",
|
||||
});
|
||||
|
||||
const applied = await asUser.mutation(actionPlanApi.applyActionPlan, {
|
||||
planId: proposed.planId,
|
||||
now: 1_800_000_000_001,
|
||||
});
|
||||
expect(applied.appliedCount).toBe(2);
|
||||
|
||||
const categories = await asUser.query(api.categories.list, {});
|
||||
expect(categories.find((category) => category.name === "Versicherungen")).toMatchObject({
|
||||
kind: "ausgabe",
|
||||
block: "wiederkehrend",
|
||||
isSystem: false,
|
||||
});
|
||||
expect(categories.find((category) => category.name === "Abos")).toMatchObject({
|
||||
block: "wiederkehrend",
|
||||
});
|
||||
});
|
||||
|
||||
test("category action plans skip empty names and duplicate renames", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Guarded Category User",
|
||||
email: "guarded-category@example.com",
|
||||
});
|
||||
await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Abos",
|
||||
kind: "ausgabe",
|
||||
block: "wiederkehrend",
|
||||
color: "#a855f7",
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
});
|
||||
await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Versicherungen",
|
||||
kind: "ausgabe",
|
||||
block: "wiederkehrend",
|
||||
color: "#0f766e",
|
||||
sortOrder: 2,
|
||||
isSystem: false,
|
||||
});
|
||||
return { userId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|guarded-category`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
const session = await asUser.mutation(historyApi.createSession, { title: "Guarded Category Chat" });
|
||||
|
||||
await expect(
|
||||
asUser.mutation(proposeCategoryChangesTool, {
|
||||
sessionId: session.sessionId,
|
||||
creates: [
|
||||
{
|
||||
name: " ",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#f97316",
|
||||
sortOrder: 3,
|
||||
},
|
||||
],
|
||||
updates: [{ name: "Abos", newName: " " }],
|
||||
now: 1_800_000_000_000,
|
||||
}),
|
||||
).rejects.toThrow("Keine Kategorieänderungen vorbereitet");
|
||||
|
||||
const proposed = await asUser.mutation(proposeCategoryChangesTool, {
|
||||
sessionId: session.sessionId,
|
||||
updates: [{ name: "Abos", newName: "Versicherungen" }],
|
||||
now: 1_800_000_000_000,
|
||||
});
|
||||
const applied = await asUser.mutation(actionPlanApi.applyActionPlan, {
|
||||
planId: proposed.planId,
|
||||
now: 1_800_000_000_001,
|
||||
});
|
||||
expect(applied).toEqual({
|
||||
appliedCount: 0,
|
||||
skippedCount: 1,
|
||||
status: "applied",
|
||||
summary: "0 Änderungen angewendet, 1 übersprungen",
|
||||
});
|
||||
|
||||
const categories = await asUser.query(api.categories.list, {});
|
||||
expect(categories.map((category) => category.name).sort()).toEqual(["Abos", "Versicherungen"]);
|
||||
});
|
||||
|
||||
test("applyActionPlan skips stale category assignments and expires old plans", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Stale Plan User",
|
||||
email: "stale-plan@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const subscriptionsId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Abos",
|
||||
kind: "ausgabe",
|
||||
block: "wiederkehrend",
|
||||
color: "#a855f7",
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
});
|
||||
const groceriesId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Lebensmittel",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#22c55e",
|
||||
sortOrder: 2,
|
||||
isSystem: false,
|
||||
});
|
||||
const txId = await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-06-03",
|
||||
description: "Netflix",
|
||||
counterparty: "Netflix",
|
||||
amount: -16,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
return { userId, accountId, subscriptionsId, groceriesId, txId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|stale-plan`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
const session = await asUser.mutation(historyApi.createSession, { title: "Stale Plan Chat" });
|
||||
|
||||
const proposed = await asUser.mutation(proposeBulkRecategoryTool, {
|
||||
scope: {
|
||||
from: "2026-06-01",
|
||||
to: "2026-06-30",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "booking",
|
||||
},
|
||||
sessionId: session.sessionId,
|
||||
targetCategoryName: "Abos",
|
||||
search: "Netflix",
|
||||
onlyUncategorized: true,
|
||||
limit: 10,
|
||||
now: 1_800_000_000_000,
|
||||
});
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.patch(seeded.txId, { categoryId: seeded.groceriesId });
|
||||
});
|
||||
|
||||
const staleApplied = await asUser.mutation(actionPlanApi.applyActionPlan, {
|
||||
planId: proposed.planId,
|
||||
now: 1_800_000_000_001,
|
||||
});
|
||||
expect(staleApplied).toEqual({
|
||||
appliedCount: 0,
|
||||
skippedCount: 1,
|
||||
status: "applied",
|
||||
summary: "0 Änderungen angewendet, 1 übersprungen",
|
||||
});
|
||||
const tx = await t.run(async (ctx) => await ctx.db.get(seeded.txId));
|
||||
expect(tx?.categoryId).toBe(seeded.groceriesId);
|
||||
|
||||
const expired = await asUser.mutation(proposeBulkRecategoryTool, {
|
||||
scope: {
|
||||
from: "2026-06-01",
|
||||
to: "2026-06-30",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "booking",
|
||||
},
|
||||
sessionId: session.sessionId,
|
||||
targetCategoryName: "Abos",
|
||||
search: "Netflix",
|
||||
limit: 10,
|
||||
now: 1_800_000_000_000,
|
||||
});
|
||||
const expiredApplied = await asUser.mutation(actionPlanApi.applyActionPlan, {
|
||||
planId: expired.planId,
|
||||
now: 1_800_086_400_001,
|
||||
});
|
||||
expect(expiredApplied).toEqual({
|
||||
appliedCount: 0,
|
||||
skippedCount: 1,
|
||||
status: "expired",
|
||||
summary: "Vorschlag ist abgelaufen",
|
||||
});
|
||||
});
|
||||
|
||||
test("comparePeriodsTool computes totals and category deltas", async () => {
|
||||
const { asUser, seeded } = await seedSavingsInsightFixture();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user