import assert from "node:assert/strict" import test from "node:test" import { getTableColumns, getTableName } from "drizzle-orm" import { PgDialect } from "drizzle-orm/pg-core" import * as schema from "../db/schema" import { mcpToolMap } from "../src/mcp/registry" import { masterdataWriteConfigs, prepareMasterdataInput } from "../src/mcp/tools/masterdataWrites" import { asToolResult } from "../src/mcp/result" const tenantId = 7 const userId = "00000000-0000-4000-8000-000000000001" const parentId = "00000000-0000-4000-8000-000000000002" const childId = "00000000-0000-4000-8000-000000000003" const otherId = "00000000-0000-4000-8000-000000000004" const dialect = new PgDialect() /** Transactional fixture which evaluates actual Drizzle WHERE clauses, including tenant filters. */ function fixture(initial: Record = {}) { let state = structuredClone(initial) const statements: string[] = [] let failHistory = false const matches = (table: any, where: any, row: any): boolean => { if (!where) return true const query = dialect.sqlToQuery(where) statements.push(query.sql) const columns = getTableColumns(table) as Record const keyFor = (name: string) => Object.keys(columns).find((key) => columns[key].name === name)! const predicates = [...query.sql.matchAll(/"\w+"\."(\w+)" (?:= \$(\d+)|in \(([^)]+)\))/g)] assert.ok(predicates.length, `Unimplemented fixture predicate: ${query.sql}`) return predicates.every((match) => { const value = row[keyFor(match[1])] if (match[2]) return value === query.params[Number(match[2]) - 1] return [...match[3].matchAll(/\$(\d+)/g)].some((parameter) => value === query.params[Number(parameter[1]) - 1]) }) } const rowsFor = (table: any) => state[getTableName(table)] ||= [] const db: any = { execute: async () => undefined, transaction: async (callback: any) => { const before = structuredClone(state) try { return await callback(db) } catch (error) { state = before; throw error } }, select: (selection?: Record) => ({ from: (table: any) => { let condition: any let limit = Infinity const query: any = { where: (where: any) => { condition = where; return query }, limit: (value: number) => { limit = value; return query }, for: () => query, then: (resolve: any, reject: any) => Promise.resolve().then(() => { const rows = rowsFor(table).filter((row) => matches(table, condition, row)).slice(0, limit) const columns = getTableColumns(table) return rows.map((row) => selection ? Object.fromEntries(Object.entries(selection).map(([key, column]) => { const field = Object.keys(columns).find((name) => columns[name] === column)! return [key, row[field]] })) : { ...row }) }).then(resolve, reject), } return query } }), insert: (table: any) => ({ values: (data: any) => { let completed = false let created: any const run = () => { if (!completed) { if (failHistory && table === schema.historyitems) throw new Error("History unavailable") const id = table === schema.costcentres ? otherId : Math.max(0, ...rowsFor(table).map((row) => typeof row.id === "number" ? row.id : 0)) + 1 created = { id, archived: false, ...data } rowsFor(table).push(created) completed = true } return [created] } return { returning: async () => run(), then: (resolve: any, reject: any) => Promise.resolve().then(run).then(resolve, reject) } } }), update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => { let result: any[] | undefined const run = () => { if (!result) result = rowsFor(table).filter((row) => matches(table, where, row)).map((row) => { Object.assign(row, data); return { ...row } }) return result } return { returning: async () => run(), then: (resolve: any, reject: any) => Promise.resolve().then(run).then(resolve, reject) } } }) }), } const context: any = { server: { db, log: { warn: () => undefined } }, tenantId, userId, isAdmin: true, permissions: [] } return { context, rows: (table: any) => rowsFor(table), statements, failHistory: () => { failHistory = true } } } const call = async (context: any, name: string, args: Record) => { const tool = mcpToolMap.get(`masterdata.${name}`) assert.ok(tool, name) return await tool.handler(context, args) as any } for (const config of masterdataWriteConfigs) { test(`${config.namespace}: create, update and archive are scoped and attribute changes to the actor`, async () => { const f = fixture({ units: [{ id: 1 }], services: [], products: [], hourrates: [] }) const input: any = config.resource === "contacts" ? { firstName: "Ada", lastName: "Lovelace" } : { name: "Test" } if (config.numberField) input[config.numberField] = "MCP-100" if (config.resource === "products") input.unit = 1 const created = await call(f.context, `${config.namespace}.create`, input) const record = created[config.resultKey] assert.equal(record.tenant, tenantId) assert.equal(record.archived, false) assert.equal(record.updatedBy || record.updated_by, userId) const update = config.resource === "contacts" ? { firstName: "Grace", lastName: "Hopper" } : { name: "Changed" } const updated = await call(f.context, `${config.namespace}.update`, { id: record.id, ...update }) assert.equal(updated[config.resultKey][config.resource === "contacts" ? "fullName" : "name"], config.resource === "contacts" ? "Grace Hopper" : "Changed") const confirmation = await call(f.context, `${config.namespace}.archive`, { id: record.id }) assert.deepEqual(asToolResult(confirmation).structuredContent, { id: record.id, success: true, action: "archived" }) assert.equal(f.rows(config.table)[0].archived, true) await assert.rejects(call(f.context, `${config.namespace}.update`, { id: record.id, ...update }), /archiviert/) }) test(`${config.namespace}: rejects missing permissions, foreign records and injected system fields`, async () => { const id = config.resource === "costcentres" ? parentId : 1 const f = fixture({ [getTableName(config.table)]: [{ id, tenant: 8, archived: false, name: "Other tenant" }] }) const fields = config.resource === "contacts" ? { firstName: "Changed" } : { name: "Changed" } await assert.rejects(call(f.context, `${config.namespace}.update`, { id, ...fields }), /nicht gefunden/) await assert.rejects(call(f.context, `${config.namespace}.archive`, { id }), /nicht gefunden/) assert.equal(f.rows(config.table)[0].archived, false) for (const key of ["tenant", "createdAt", "updatedBy", "updated_by", "archived"]) { await assert.rejects(call(f.context, `${config.namespace}.update`, { id, [key]: "injected" }), /unbekanntes Feld/) } f.context.isAdmin = false f.context.permissions = [`masterdata.${config.namespace}.read`] await assert.rejects(call(f.context, `${config.namespace}.archive`, { id }), /Fehlende Berechtigung/) f.context.permissions.push(`masterdata.${config.namespace}.write`) await assert.rejects(call(f.context, `${config.namespace}.archive`, { id }), /nicht gefunden/) }) } test("numbered resources allocate numbers, skip occupied values and reject duplicates", async () => { for (const config of masterdataWriteConfigs.filter((item) => item.numberField)) { const f = fixture({ tenants: [{ id: tenantId, numberRanges: { [config.resource]: { prefix: "K-", nextNumber: 1 } } }], [getTableName(config.table)]: [{ id: config.resource === "costcentres" ? parentId : 10, tenant: tenantId, [config.numberField!]: "K-1", archived: true }], }) const result = await call(f.context, `${config.namespace}.create`, { name: "New" }) assert.equal(result[config.resultKey][config.numberField!], "K-2") assert.equal(f.rows(schema.tenants)[0].numberRanges[config.resource].nextNumber, 3) await assert.rejects(call(f.context, `${config.namespace}.create`, { name: "Duplicate", [config.numberField!]: "K-1" }), /bereits vergeben/) } }) test("cost centre hierarchy rejects self, descendants, foreign and archived parents", async () => { const f = fixture({ costcentres: [ { id: parentId, tenant: tenantId, name: "Parent", number: "1", parentCostcentre: null, archived: false }, { id: childId, tenant: tenantId, name: "Child", number: "2", parentCostcentre: parentId, archived: false }, { id: otherId, tenant: 8, name: "Foreign", archived: false }, ] }) await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: parentId }), /sich selbst/) await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: childId }), /Zyklus/) await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: otherId }), /nicht verfügbar/) f.rows(schema.costcentres)[1].archived = true await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: childId }), /nicht verfügbar/) const result = await call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: null }) assert.equal(result.costCentre.parentCostcentre, null) assert.equal(f.rows(schema.costcentres)[1].parentCostcentre, parentId) }) test("all declared relationships reject foreign-tenant references before insert", async () => { for (const config of masterdataWriteConfigs) { for (const reference of config.references || []) { if (reference.table === schema.units) continue const refId = (reference.table[reference.idKey || "id"].columnType === "PgUUID") ? parentId : 99 const initial = { [getTableName(reference.table)]: [{ [reference.idKey || "id"]: refId, [reference.tenantKey || "tenant"]: 8, archived: false, active: true }], units: [{ id: 1 }] } const f = fixture(initial) const input: any = config.resource === "contacts" ? { fullName: "Contact" } : { name: "Record" } if (config.resource === "products") input.unit = 1 if (config.numberField) input[config.numberField] = "N-1" const parts = reference.field.split(".") if (parts.length === 1) { input[parts[0]] = ["profiles", "productcategories", "servicecategories"].includes(parts[0]) ? [refId] : refId } else if (parts[0] === "infoData") input.infoData = { bankAccountIds: [refId] } else input[parts[0]] = [{ [parts[1]]: refId }] await assert.rejects(call(f.context, `${config.namespace}.create`, input), /nicht verfügbar/, `${config.namespace}.${reference.field}`) } } }) test("partial updates preserve unrelated fields and nullable links can be cleared", async () => { const f = fixture({ customers: [{ id: 1, tenant: tenantId, name: "Existing", archived: false, customerNumber: "C-1", infoData: { email: "old@example.org", city: "Berlin" } }] }) const result = await call(f.context, "customers.update", { id: 1, infoData: { email: "new@example.org" }, notes: null }) assert.deepEqual(result.customer.infoData, { email: "new@example.org", city: "Berlin" }) assert.equal(result.customer.name, "Existing") assert.equal(result.customer.notes, null) }) test("failed history write rolls back record creation and number range consumption", async () => { const f = fixture({ tenants: [{ id: tenantId, numberRanges: { customers: { prefix: "C-", nextNumber: 1 } } }] }) f.failHistory() await assert.rejects(call(f.context, "customers.create", { name: "Rollback" }), /History unavailable/) assert.equal(f.rows(schema.customers).length, 0) assert.equal(f.rows(schema.tenants)[0].numberRanges.customers.nextNumber, 1) }) test("product prices propagate to composed services in the same transaction", async () => { const f = fixture({ products: [{ id: 1, tenant: tenantId, name: "Product", unit: 1, selling_price: 10, purchase_price: 5, archived: false }], services: [{ id: 2, tenant: tenantId, name: "Service", archived: false, materialComposition: [{ product: 1, quantity: 2 }], personalComposition: [], priceUpdateLocked: false }], hourrates: [], }) await call(f.context, "products.update", { id: 1, selling_price: 20 }) assert.equal(f.rows(schema.services)[0].sellingPriceComposed.total, 40) assert.equal(f.rows(schema.services)[0].purchasePriceComposed.total, 10) }) test("validates scalar types, required values, dates and empty updates", () => { const config = (name: string) => masterdataWriteConfigs.find((item) => item.namespace === name)! assert.throws(() => prepareMasterdataInput(config("products"), "create", { name: "Article" }), /unit ist erforderlich/) assert.throws(() => prepareMasterdataInput(config("cost_centres"), "update", { id: "invalid", name: "Cost centre" }), /UUID/) assert.throws(() => prepareMasterdataInput(config("cost_centres"), "update", { id: parentId, name: " " }), /nicht leer/) assert.throws(() => prepareMasterdataInput(config("customers"), "update", { id: 1 }), /Mindestens ein/) for (const active of ["false", 0, null]) assert.throws(() => prepareMasterdataInput(config("customers"), "update", { id: 1, active }), /Boolean/) for (const birthday of ["2026-02-30", "not a date"]) assert.throws(() => prepareMasterdataInput(config("contacts"), "update", { id: 1, birthday }), /Datum|Kalenderdatum/) assert.throws(() => prepareMasterdataInput(config("products"), "update", { id: 1, selling_price: "20" }), /Zahl/) assert.throws(() => prepareMasterdataInput(config("cost_centres"), "update", { id: parentId, profiles: ["invalid"] }), /UUID/) }) test("manual service price updates replace the previous composed total, including zero", async () => { const f = fixture({ services: [{ id: 1, tenant: tenantId, name: "Manual", archived: false, sellingPrice: 10, sellingPriceComposed: { total: 10 }, materialComposition: [], personalComposition: [], }], products: [], hourrates: [] }) for (const price of [20, 0]) { const result = await call(f.context, "services.update", { id: 1, sellingPrice: price }) assert.equal(result.service.sellingPrice, price) assert.equal(result.service.sellingPriceComposed.total, price) } await assert.rejects(call(f.context, "services.update", { id: 1, sellingPrice: 20, sellingPriceComposed: { total: 30 } }), /widersprechen/) }) for (const config of masterdataWriteConfigs.filter((item) => item.addGet)) { test(`${config.namespace}.get: returns only an active record from the current tenant`, async () => { const f = fixture({ [getTableName(config.table)]: [ { id: 1, tenant: tenantId, archived: false, name: "Active" }, { id: 2, tenant: 8, archived: false, name: "Foreign" }, { id: 3, tenant: tenantId, archived: true, name: "Archived" }, ] }) assert.equal((await call(f.context, `${config.namespace}.get`, { id: 1 }))[config.resultKey].name, "Active") for (const id of [2, 3]) await assert.rejects(call(f.context, `${config.namespace}.get`, { id }), /nicht gefunden/) f.context.isAdmin = false await assert.rejects(call(f.context, `${config.namespace}.get`, { id: 1 }), /Fehlende Berechtigung/) }) }