Files
FEDEO/backend/tests/mcpCalendarStatus.test.ts

220 lines
17 KiB
TypeScript

import assert from "node:assert/strict"
import test from "node:test"
import { randomUUID } from "node:crypto"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import { Pool } from "pg"
import { drizzle } from "drizzle-orm/node-postgres"
import { eq, getTableColumns, getTableName } from "drizzle-orm"
import * as schema from "../db/schema"
import { mcpToolMap, mcpTools } from "../src/mcp/registry"
import { assertToolPermission } from "../src/mcp/authz"
import { asToolResult } from "../src/mcp/result"
import { eventDateTimeField, prepareEventInput, calendarTools } from "../src/mcp/tools/calendar"
import { outgoingDocumentStatus, outgoingStatusTools } from "../src/mcp/tools/outgoingStatus"
const execute = promisify(execFile)
const userId = "00000000-0000-4000-8000-000000000001"
const invoice = { id: 1, state: "Gebucht", type: "invoices", rows: [{ quantity: 1, price: 100, taxPercent: 19 }], usedAdvanceInvoices: [] }
test("registers read-only document status and permission-protected calendar tools", () => {
assert.equal(new Set(mcpTools.map(tool => tool.name)).size, mcpTools.length)
for (const tool of [...calendarTools, ...outgoingStatusTools]) {
assert.equal(mcpToolMap.get(tool.name), tool)
assert.throws(() => assertToolPermission({ isAdmin: false, permissions: [] } as any, tool), { statusCode: 403 })
assert.doesNotThrow(() => assertToolPermission({ isAdmin: false, permissions: tool.requiredPermissions } as any, tool))
}
for (const tool of outgoingStatusTools) assert.deepEqual(tool.requiredPermissions, ["accounting.outgoing_documents.read"])
for (const action of ["create", "update", "archive"]) {
assert.deepEqual(mcpToolMap.get(`organisation.events.${action}`)!.requiredPermissions, ["organisation.events.write"])
}
})
test("payment status handles drafts, partial payments, credits and advance invoices", () => {
const status = (document: any, paid: number, cancellations: any[] = [], advances: any[] = []) => outgoingDocumentStatus(document, paid, cancellations, advances)
assert.equal(status(invoice, 0).paymentStatus, "Offen")
assert.equal(status(invoice, 50).paymentStatus, "Teilbezahlt")
assert.equal(status(invoice, 119).paymentStatus, "Bezahlt")
assert.equal(status(invoice, 120).paymentStatus, "Überbezahlt")
assert.equal(status({ ...invoice, state: "Entwurf" }, 119).paymentStatus, null)
assert.equal(status({ ...invoice, type: "quotes" }, 0).paymentStatus, null)
const cancelled = status(invoice, 0, [{ id: 2, documentNumber: "ST-2" }])
assert.equal(cancelled.cancelled, true)
assert.equal(cancelled.paymentStatus, null)
assert.deepEqual(cancelled.cancellationDocuments, [{ id: 2, documentNumber: "ST-2" }])
const advance = { id: 3, rows: [{ price: 20, taxPercent: 19, advanceInvoiceData: {} }] }
assert.equal(status({ ...invoice, usedAdvanceInvoices: [3] }, 95.2, [], [advance]).paymentStatus, "Bezahlt")
assert.equal(status({ ...invoice, rows: [{ quantity: 1, price: -100, taxPercent: 19 }] }, -50).paymentStatus, "Teilbezahlt")
assert.equal(status({ ...invoice, rows: [{ quantity: 1, price: -100, taxPercent: 19 }] }, -120).paymentStatus, "Überbezahlt")
assert.equal(status({ ...invoice, rows: [] }, 0).paymentStatus, "Bezahlt")
const mixedTax = { ...invoice, rows: [{ quantity: 1, price: 0.03, taxPercent: 7 }, { quantity: 1, price: 0.02, taxPercent: 19 }] }
assert.equal(status(mixedTax, 0.05).paymentStatus, "Bezahlt", "separate tax buckets must match the outgoing-document UI")
assert.throws(() => status({ ...invoice, rows: [{ quantity: 1, price: "invalid" }] }, 0), /Positionsbetrag/)
assert.throws(() => status(invoice, NaN), /Betrag kann nicht berechnet/)
})
test("calendar validates explicit timezones, dates, editable fields and typed resources", () => {
assert.equal(eventDateTimeField.parse("2026-10-15T09:00:00+02:00", "startDate").toISOString(), "2026-10-15T07:00:00.000Z")
for (const value of ["2026-10-15", "2026-10-15T09:00:00", "2026-02-30T09:00:00Z", "2026-10-15T24:00:00Z", "2026-10-15T09:60:00Z", "bad", null]) {
assert.throws(() => eventDateTimeField.parse(value, "startDate"))
}
for (const args of [
{ id: 1 }, { id: -1, name: "Test" }, { id: 1, tenant: 2 }, { id: 1, updatedBy: userId },
{ id: 1, state: "Bezahlt" }, { id: 1, repeatInterval: "irgendwann" },
{ id: 1, profiles: [1] }, { id: 1, vehicles: ["1"] }, { id: 1, name: " " }, { id: 1, resources: [] },
]) assert.throws(() => prepareEventInput("update", args))
assert.throws(() => prepareEventInput("create", { name: "Test" }), /startDate/)
assert.deepEqual(prepareEventInput("update", { id: 1, endDate: null, profiles: [], vehicles: [1, 1] }), { id: 1, endDate: null, profiles: [], vehicles: [1] })
})
// An isolated database and local fixtures only; no production configuration is loaded.
test("calendar and outgoing status integration", { skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE }, async t => {
const container = "fedeo-mcp-calendar-" + randomUUID()
const password = randomUUID()
await execute("docker", ["run", "--rm", "-d", "--name", container, "-e", `POSTGRES_PASSWORD=${password}`, "-p", "127.0.0.1::5432", process.env.FEDEO_TEST_POSTGRES_IMAGE!])
let pool: Pool | undefined
t.after(async () => { await pool?.end(); await execute("docker", ["rm", "-f", container]) })
const { stdout } = await execute("docker", ["port", container, "5432/tcp"])
pool = new Pool({ host: "127.0.0.1", port: Number(stdout.trim().split(":").at(-1)), user: "postgres", password, database: "postgres", connectionTimeoutMillis: 1000 })
for (let attempt = 0; ; attempt++) {
try { await pool.query("SELECT 1"); break } catch (error) {
if (attempt >= 80) throw error
await new Promise(resolve => setTimeout(resolve, 100))
}
}
const tables = [schema.events, schema.createddocuments, schema.statementallocations, schema.historyitems, schema.tenants,
schema.projects, schema.customers, schema.vendors, schema.authProfiles, schema.vehicles, schema.inventoryitems, schema.inventoryitemgroups]
for (const table of tables) {
const columns = Object.entries(getTableColumns(table)).map(([key, column]) => {
let suffix = ""
if (key === "id") suffix = column.getSQLType() === "uuid" ? " PRIMARY KEY DEFAULT gen_random_uuid()" : " GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY"
else if (key === "archived" || key === "quick") suffix = " DEFAULT false"
else if (key === "active") suffix = " DEFAULT true"
else if (key === "createdAt") suffix = " DEFAULT now()"
else if (["profiles", "vehicles", "inventoryitems", "inventoryitemgroups", "resources", "rows", "usedAdvanceInvoices"].includes(key)) {
suffix = column.getSQLType().endsWith("[]") ? ` DEFAULT '{}'::${column.getSQLType()}` : " DEFAULT '[]'::jsonb"
}
else if (table === schema.events && key === "state") suffix = " DEFAULT 'Final'"
else if (table === schema.events && key === "repeatInterval") suffix = " DEFAULT 'Keine Wiederholung'"
else if (table === schema.events && key === "eventtype") suffix = " DEFAULT 'Umsetzung'"
return `"${column.name}" ${column.columnType === "PgEnumColumn" ? "text" : column.getSQLType()}${suffix}`
})
await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(", ")})`)
}
const db = drizzle(pool) as any
const call = async (name: string, args: any = {}, tenantId = 1) => {
const context = { server: { db }, tenantId, userId, isAdmin: false,
permissions: ["organisation.events.read", "organisation.events.write", "accounting.outgoing_documents.read"] } as any
const tool = mcpToolMap.get(name)!
assert.ok(tool, name)
assertToolPermission(context, tool)
return await tool.handler(context, args) as any
}
const calendar = (name: string, args: any = {}, tenant = 1) => call(`organisation.events.${name}`, args, tenant)
const status = (name: string, args: any, tenant = 1) => call(`accounting.outgoing_documents.status.${name}`, args, tenant)
await db.insert(schema.tenants).values([{ id: 1, calendarConfig: { eventTypes: [{ label: "Büro", color: "blue" }] } }, { id: 2 }])
await db.insert(schema.projects).values([{ id: 1, tenant: 1, name: "Own" }, { id: 2, tenant: 2, name: "Foreign" }, { id: 3, tenant: 1, name: "Archived", archived: true }])
await db.insert(schema.vehicles).values([{ id: 1, tenant: 1 }, { id: 2, tenant: 2 }])
const ownProfile = randomUUID(), portalProfile = randomUUID(), group = randomUUID()
await db.insert(schema.authProfiles).values([{ id: ownProfile, tenant_id: 1 }, { id: portalProfile, tenant_id: 1, customer_for_portal: 5 }])
await db.insert(schema.inventoryitemgroups).values({ id: group, tenant: 1 })
await t.test("create, update and archive preserve metadata, history and tenant isolation", async () => {
const options = await call("organisation.calendar.options")
assert.equal(options.calendarConfig.eventTypes[0].label, "Büro")
const { event } = await calendar("create", { name: "Besprechung", startDate: "2026-10-15T09:00:00+02:00", endDate: "2026-10-15T10:00:00+02:00",
project: 1, profiles: [ownProfile], vehicles: [1], inventoryitemgroups: [group], repeatInterval: "Wöchentlich" })
assert.equal(event.state, "Final")
assert.equal(event.updatedBy, userId)
assert.equal(event.tenant, 1)
assert.equal(event.startDate.toISOString(), "2026-10-15T07:00:00.000Z")
await assert.rejects(calendar("get", { id: event.id }, 2), /nicht gefunden/)
await assert.rejects(calendar("update", { id: event.id, name: "wrong" }, 2), /nicht gefunden/)
await assert.rejects(calendar("archive", { id: event.id }, 2), /nicht gefunden/)
for (const patch of [{ project: 2 }, { project: 3 }, { profiles: [portalProfile] }, { vehicles: [2] }, { inventoryitemgroups: [randomUUID()] }]) {
await assert.rejects(calendar("update", { id: event.id, ...patch }), /nicht verfügbar/)
}
await assert.rejects(calendar("update", { id: event.id, startDate: "2026-10-15T11:00:00+02:00" }), /endDate/)
const changed = await calendar("update", { id: event.id, name: "Planung", endDate: null, profiles: [], project: null, state: "Entwurf" })
assert.equal(changed.event.endDate, null)
assert.deepEqual(changed.event.profiles, [])
assert.equal(changed.event.repeatInterval, "Wöchentlich")
assert.equal(changed.event.project, null)
const history = await db.select().from(schema.historyitems).where(eq(schema.historyitems.event, event.id))
assert.deepEqual(history.map(row => row.action), ["created", "updated"])
assert.ok(history.every(row => row.tenant === 1 && row.createdBy === userId))
await pool!.query("ALTER TABLE historyitems ADD CONSTRAINT reject_new_history CHECK (false) NOT VALID")
await assert.rejects(calendar("update", { id: event.id, name: "Must roll back" }))
assert.equal((await calendar("get", { id: event.id })).event.name, "Planung")
await pool!.query("ALTER TABLE historyitems DROP CONSTRAINT reject_new_history")
assert.deepEqual(asToolResult(await calendar("archive", { id: event.id })).structuredContent, { id: event.id, success: true, action: "archived" })
await assert.rejects(calendar("get", { id: event.id }), /archiviert/)
assert.equal((await calendar("list", {})).rows.length, 0)
})
await t.test("calendar range and resource filters handle overlaps and older recurring series", async () => {
const create = async (name: string, startDate: string, endDate: string | null, extra: any = {}, tenant = 1) =>
(await calendar("create", { name, startDate, endDate, ...extra }, tenant)).event
await create("Before", "2026-10-10T09:00:00Z", "2026-10-10T10:00:00Z")
await create("Ends at boundary", "2026-10-10T09:00:00Z", "2026-10-15T00:00:00Z")
const spanning = await create("Overlapping", "2026-10-14T23:00:00Z", "2026-10-15T01:00:00Z", { profiles: [ownProfile], vehicles: [1], state: "Entwurf", quick: true })
const inside = await create("Inside", "2026-10-15T10:00:00Z", null)
await create("End excluded", "2026-10-16T00:00:00Z", null)
const recurring = await create("Older series", "2020-10-01T09:00:00Z", "2020-10-01T10:00:00Z", { repeatInterval: "Wöchentlich" })
await create("Foreign", "2026-10-15T10:00:00Z", null, {}, 2)
const range = { from: "2026-10-15T00:00:00Z", to: "2026-10-16T00:00:00Z" }
const listed = await calendar("list", range)
assert.deepEqual(listed.rows.map(row => row.id), [inside.id, spanning.id, recurring.id])
assert.deepEqual((await calendar("list", { ...range, includeRecurring: false })).rows.map(row => row.id), [inside.id, spanning.id])
assert.deepEqual((await calendar("list", { ...range, profile: ownProfile, vehicle: 1, state: "Entwurf", quick: true })).rows.map(row => row.id), [spanning.id])
await assert.rejects(calendar("list", { from: range.to, to: range.from }), /from/)
})
await t.test("status ignores foreign/archived payments and draft cancellations and applies advances", async () => {
await db.insert(schema.createddocuments).values([
{ ...invoice, id: 100, tenant: 1, documentNumber: "RE-100", usedAdvanceInvoices: [103] },
{ ...invoice, id: 101, tenant: 2 }, { ...invoice, id: 102, tenant: 1, archived: true },
{ ...invoice, id: 103, tenant: 1, type: "advanceInvoices", rows: [{ price: 20, taxPercent: 19, advanceInvoiceData: {} }] },
{ ...invoice, id: 104, tenant: 1, type: "cancellationInvoices", state: "Entwurf", createddocument: 100 },
{ ...invoice, id: 105, tenant: 2, type: "cancellationInvoices", createddocument: 100 },
{ ...invoice, id: 106, tenant: 1, type: "cancellationInvoices", createddocument: 100, archived: true },
])
await db.insert(schema.statementallocations).values([
{ tenant: 1, createddocument: 100, amount: 95.2 }, { tenant: 1, createddocument: 100, amount: 500, archived: true },
{ tenant: 2, createddocument: 100, amount: 700 },
])
const result = (await status("get", { id: 100 })).status
assert.equal(result.paymentStatus, "Bezahlt")
assert.equal(result.amount, 95.2)
assert.equal(result.paidAmount, 95.2)
assert.equal(result.openAmount, 0)
assert.equal(result.cancelled, false)
await assert.rejects(status("get", { id: 101 }), /nicht gefunden/)
await assert.rejects(status("get", { id: 102 }), /archiviert/)
await db.insert(schema.createddocuments).values({ ...invoice, id: 107, tenant: 1, documentNumber: "ST-107", type: "cancellationInvoices", createddocument: 100 })
const cancelled = (await status("get", { id: 100 })).status
assert.equal(cancelled.paymentStatus, null)
assert.deepEqual(cancelled.cancellationDocuments, [{ id: 107, documentNumber: "ST-107" }])
assert.deepEqual((await status("list", { cancelled: true })).rows.map(row => row.id), [100])
})
await t.test("computed status filters precede pagination across several database batches", async () => {
const documents = Array.from({ length: 410 }, (_, index) => ({ ...invoice, id: 1000 + index, tenant: 1, project: 999,
createdAt: new Date("2026-10-01T00:00:00Z") }))
await db.insert(schema.createddocuments).values(documents)
await db.insert(schema.statementallocations).values(documents.filter((_, i) => i % 2 === 0).map(document => ({ tenant: 1, createddocument: document.id, amount: 119 })))
const ids: number[] = []
for (const offset of [0, 100, 200]) {
const result = await status("list", { state: "Gebucht", paymentStatus: "Bezahlt", cancelled: false, project: 999, limit: 100, offset })
assert.equal(result.rows.length, offset < 200 ? 100 : 5)
assert.equal(result.pagination.nextOffset, offset < 200 ? offset + 100 : null)
ids.push(...result.rows.map(row => row.id))
}
assert.deepEqual(ids, documents.filter((_, i) => i % 2 === 0).map(document => document.id))
assert.equal((await status("list", { project: 999, paymentStatus: "Teilbezahlt" })).rows.length, 0)
assert.equal((await status("list", { project: 999, offset: 400, limit: 100 })).rows.length, 10)
await assert.rejects(status("list", { paymentStatus: "unknown" }), /Zahlungsstatus/)
await assert.rejects(status("get", { id: 100, state: "Entwurf" }), /unbekanntes Feld/)
})
})