feat(mcp): Archivierte Einträge wiederherstellen und Kontostände abfragen
- archive.entities.list / archive.entities.restore für alle per MCP archivierbaren Entitäten mit Berechtigungsprüfung je Entität und Historieneintrag - accounting.bank_accounts.balances liefert Stände von Bankkonten und Kassenbüchern, optional zu einem Stichtag Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
24
backend/tests/bankBalances.test.ts
Normal file
24
backend/tests/bankBalances.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { calculateAccountBalance, CASHBOOK_BANK_ID } from "../src/modules/bank-balances.service"
|
||||
|
||||
const syncedAt = new Date("2026-10-20T08:00:00Z")
|
||||
const bank = { id: 1, name: "Giro", iban: "DE00", ownerName: null, bankId: "bank", datevNumber: "1200", balance: 1000.1, expired: false, syncedAt }
|
||||
const cashbook = { ...bank, id: 2, name: "Kasse", iban: "CASH-1", bankId: CASHBOOK_BANK_ID, datevNumber: "1000", balance: 50 }
|
||||
|
||||
test("bank accounts report the synced balance and revert later statements for a past date", () => {
|
||||
assert.equal(calculateAccountBalance(bank, 0, null).balance, 1000.1)
|
||||
assert.equal(calculateAccountBalance(bank, 0, null).source, "bank_sync")
|
||||
const past = calculateAccountBalance(bank, -200.05, "2026-10-01")
|
||||
assert.equal(past.balance, 1200.15)
|
||||
assert.equal(past.syncedBalance, 1000.1)
|
||||
assert.equal(past.source, "bank_sync_reconstructed")
|
||||
})
|
||||
|
||||
test("cashbooks add their bookings to the opening balance", () => {
|
||||
const result = calculateAccountBalance(cashbook, 12.345, null)
|
||||
assert.equal(result.type, "cashbook")
|
||||
assert.equal(result.balance, 62.35)
|
||||
assert.equal(result.openingBalance, 50)
|
||||
assert.equal(result.iban, null)
|
||||
})
|
||||
99
backend/tests/mcpArchiveRestore.test.ts
Normal file
99
backend/tests/mcpArchiveRestore.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
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 { archiveEntities, prepareArchiveRestoreInput } from "../src/mcp/tools/archive"
|
||||
import { asToolResult } from "../src/mcp/result"
|
||||
|
||||
const tenantId = 7
|
||||
const userId = "00000000-0000-4000-8000-000000000001"
|
||||
const uuid = "00000000-0000-4000-8000-000000000002"
|
||||
const dialect = new PgDialect()
|
||||
|
||||
/** Minimal Drizzle stand-in which evaluates equality predicates, including tenant filters. */
|
||||
function fixture(initial: Record<string, any[]> = {}, listRows: any[] = []) {
|
||||
const state = structuredClone(initial)
|
||||
const rowsFor = (table: any) => state[getTableName(table)] ||= []
|
||||
const matches = (table: any, where: any, row: any) => {
|
||||
const query = dialect.sqlToQuery(where)
|
||||
const columns = getTableColumns(table) as Record<string, any>
|
||||
const keyFor = (name: string) => Object.keys(columns).find((key) => columns[key].name === name)!
|
||||
return [...query.sql.matchAll(/"\w+"\."(\w+)" = \$(\d+)/g)]
|
||||
.every((match) => row[keyFor(match[1])] === query.params[Number(match[2]) - 1])
|
||||
}
|
||||
const listed: string[] = []
|
||||
const db: any = {
|
||||
transaction: async (callback: any) => callback(db),
|
||||
select: () => ({ from: (table: any) => {
|
||||
let condition: any
|
||||
const query: any = {
|
||||
where: (where: any) => { condition = where; return query },
|
||||
orderBy: () => query,
|
||||
offset: () => query,
|
||||
limit: () => query,
|
||||
for: () => query,
|
||||
then: (resolve: any, reject: any) => Promise.resolve().then(() => {
|
||||
if (listRows.length) { listed.push(dialect.sqlToQuery(condition).sql); return listRows }
|
||||
return rowsFor(table).filter((row) => matches(table, condition, row)).map((row) => ({ ...row }))
|
||||
}).then(resolve, reject),
|
||||
}
|
||||
return query
|
||||
} }),
|
||||
insert: (table: any) => ({ values: async (data: any) => { rowsFor(table).push(data) } }),
|
||||
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({
|
||||
returning: async () => rowsFor(table).filter((row) => matches(table, where, row)).map((row) => ({ ...Object.assign(row, data) })),
|
||||
}) }) }),
|
||||
}
|
||||
const context: any = { server: { db, log: { warn: () => undefined } }, tenantId, userId, isAdmin: true, permissions: [] }
|
||||
return { context, rows: rowsFor, listed }
|
||||
}
|
||||
const call = async (context: any, name: string, args: Record<string, any>) => await mcpToolMap.get(`archive.entities.${name}`)!.handler(context, args) as any
|
||||
|
||||
for (const [key, entity] of Object.entries(archiveEntities)) {
|
||||
test(`${key}: restore unarchives within the tenant and records history`, async () => {
|
||||
const id = entity.uuid ? uuid : 1
|
||||
const f = fixture({
|
||||
[getTableName(entity.table)]: [{ id, tenant: tenantId, archived: true }],
|
||||
// Restoring documents requires an internal (non-portal) profile.
|
||||
auth_profiles: [{ tenant_id: tenantId, user_id: userId, customer_for_portal: null }],
|
||||
})
|
||||
const result = await call(f.context, "restore", { entity: key, id })
|
||||
assert.deepEqual(asToolResult(result).structuredContent?.success, true)
|
||||
assert.equal(result.record.archived, undefined)
|
||||
const [row] = f.rows(entity.table)
|
||||
assert.equal(row.archived, false)
|
||||
assert.equal(row.updatedBy ?? row.updated_by, userId)
|
||||
await assert.rejects(call(f.context, "restore", { entity: key, id }), /nicht archiviert/)
|
||||
})
|
||||
}
|
||||
|
||||
test("restore rejects foreign tenants, unknown entities, invalid IDs and missing write permission", async () => {
|
||||
const f = fixture({ customers: [{ id: 1, tenant: 8, archived: true }], tasks: [{ id: 2, tenant: tenantId, archived: true }] })
|
||||
await assert.rejects(call(f.context, "restore", { entity: "customers", id: 1 }), /nicht gefunden/)
|
||||
assert.equal(f.rows(schema.customers)[0].archived, true)
|
||||
await assert.rejects(call(f.context, "restore", { entity: "accounts", id: 1 }), /unbekannte Entität/)
|
||||
assert.throws(() => prepareArchiveRestoreInput({ entity: "cost_centres", id: 1 }), /UUID/)
|
||||
assert.throws(() => prepareArchiveRestoreInput({ entity: "tasks", id: "abc" }), /ganze Zahl/)
|
||||
f.context.isAdmin = false
|
||||
f.context.permissions = ["organisation.tasks.read"]
|
||||
await assert.rejects(call(f.context, "restore", { entity: "tasks", id: 2 }), /Fehlende Berechtigung.*organisation\.tasks\.write/)
|
||||
f.context.permissions.push("organisation.tasks.write")
|
||||
await call(f.context, "restore", { entity: "tasks", id: 2 })
|
||||
assert.equal(f.rows(schema.tasks)[0].archived, false)
|
||||
})
|
||||
|
||||
test("list returns archived rows visibly and requires the read permission", async () => {
|
||||
const f = fixture({}, [{ id: 1, tenant: tenantId, archived: true, name: "Alt" }])
|
||||
const result = asToolResult(await call(f.context, "list", { entity: "customers", search: "Al" }))
|
||||
assert.deepEqual(result.structuredContent?.rows, [{ id: 1, tenant: tenantId, name: "Alt", isArchived: true }])
|
||||
assert.match(f.listed[0], /"customers"\."archived" = \$2/)
|
||||
f.context.isAdmin = false
|
||||
f.context.permissions = []
|
||||
await assert.rejects(call(f.context, "list", { entity: "customers" }), /masterdata\.customers\.read/)
|
||||
})
|
||||
|
||||
test("restore is advertised as a write tool", () => {
|
||||
assert.equal(mcpToolMap.get("archive.entities.restore")!.readOnly, false)
|
||||
})
|
||||
@@ -40,11 +40,12 @@ for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any
|
||||
return values
|
||||
}) }
|
||||
}, { schema })
|
||||
const context = { server: { db }, tenantId: 7, userId: "test" } as any
|
||||
const context = { server: { db }, tenantId: 7, userId: "test", isAdmin: true } as any
|
||||
const properties = tool.inputSchema.properties as any
|
||||
const ids: unknown[] = []
|
||||
let offset = 0
|
||||
for (let page = 0; page < 3; page++) {
|
||||
const result: any = await tool.handler(context, { ...((tool.inputSchema.properties as any).id ? { id: 1 } : {}), limit: 100, offset })
|
||||
const result: any = await tool.handler(context, { ...(properties.id ? { id: 1 } : {}), ...(properties.entity ? { entity: properties.entity.enum[0] } : {}), limit: 100, offset })
|
||||
assert.equal(result.rows.length, page < 2 ? 100 : 5)
|
||||
assert.equal(result.pagination.hasMore, page < 2)
|
||||
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)
|
||||
|
||||
Reference in New Issue
Block a user