220 lines
17 KiB
TypeScript
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/)
|
|
})
|
|
})
|