feat(mcp): manage master data and cost centre hierarchies
This commit is contained in:
245
backend/tests/mcpMasterdata.test.ts
Normal file
245
backend/tests/mcpMasterdata.test.ts
Normal 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/)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user