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/) }) })