feat(mcp): manage master data and cost centre hierarchies

This commit is contained in:
2026-09-14 13:29:25 +02:00
parent 342ad9764e
commit cfef07b96d
8 changed files with 725 additions and 53 deletions

View File

@@ -0,0 +1,245 @@
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<string, any[]> = {}) {
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<string, any>
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<string, any>) => ({ 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<string, any>) => {
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/)
})
}