diff --git a/scripts/lib/lotse-chat-fixture.ts b/scripts/lib/lotse-chat-fixture.ts new file mode 100644 index 0000000..e570c26 --- /dev/null +++ b/scripts/lib/lotse-chat-fixture.ts @@ -0,0 +1,143 @@ +// Shared fixture of the lane L16 tests (scripts/test-lotse-chat-*.ts): two zz test tenants with +// technicians (assigned / not assigned), admin, tenant B, customers with contact data (for the data +// minimisation checks) and work orders in different statuses. Not a test itself. + +import { prisma, dbForTenant } from "../../src/server/db"; +import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac"; +import { ServiceError, type ServiceCtx } from "../../src/server/services/context"; + +export let failures = 0; +export const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +export async function expectErr(fn: () => Promise, code: ServiceError["code"], msg: string, reason?: string) { + try { + await fn(); + ok(false, `${msg} — kein Fehler (erwartet ${code}${reason ? `/${reason}` : ""})`); + } catch (err) { + if (!(err instanceof ServiceError)) return ok(false, `${msg} — ${(err as Error).message}`); + const r = (err.details as { reason?: string } | undefined)?.reason; + ok(err.code === code && (!reason || r === reason), `${msg} (${err.code}${r ? `/${r}` : ""})`); + } +} + +export const ctxOf = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({ + db: dbForTenant(tenantId), + tenantId, + userId, + permissions: new Set(ROLE_DEFS[role].permissions), +}); + +export async function cleanupChatTenants(prefix: string) { + const slugs = [`zz-${prefix}-a`, `zz-${prefix}-b`]; + const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (ids.length) { + const w = { where: { tenantId: { in: ids } } }; + await prisma.lotseActionProposal.deleteMany(w); + await prisma.lotseMessage.deleteMany(w); + await prisma.lotseConversation.deleteMany(w); + await prisma.notification.deleteMany(w); + await prisma.mailLog.deleteMany(w); + await prisma.aiGeneration.deleteMany(w); + await prisma.signature.deleteMany(w); + await prisma.report.deleteMany(w); + await prisma.activityNote.deleteMany(w); + await prisma.voiceNote.deleteMany(w); + await prisma.materialUsage.deleteMany(w); + await prisma.materialPlan.deleteMany(w); + await prisma.timeEntry.deleteMany(w); + await prisma.workSession.deleteMany(w); + await prisma.photo.deleteMany(w); + await prisma.checklistItem.deleteMany(w); + await prisma.photoRequirement.deleteMany(w); + await prisma.workOrderStatusChange.deleteMany(w); + await prisma.workOrderAssignee.deleteMany(w); + await prisma.syncOperation.deleteMany(w); + await prisma.document.deleteMany(w); + await prisma.workOrder.deleteMany(w); + await prisma.site.deleteMany(w); + await prisma.contact.deleteMany(w); + await prisma.customer.deleteMany(w); + await prisma.numberSequence.deleteMany(w); + await prisma.auditLog.deleteMany(w); + await prisma.tenantModule.deleteMany(w); + await prisma.tenantSettings.deleteMany(w); + await prisma.user.deleteMany(w); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: `@zz-${prefix}.test` } } }); +} + +export async function createChatFixture(prefix: string) { + await cleanupChatTenants(prefix); + const domain = `@zz-${prefix}.test`; + const tA = await prisma.tenant.create({ data: { name: `Lotse-Chat ${prefix} A`, slug: `zz-${prefix}-a` } }); + const tB = await prisma.tenant.create({ data: { name: `Lotse-Chat ${prefix} B`, slug: `zz-${prefix}-b` } }); + await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Chat Test A GmbH", phone: "040 1234567", email: "buero@lotsechat-a.test", address: "Werftstraße 5, 20457 Hamburg" } }); + await prisma.tenantSettings.create({ data: { tenantId: tB.id, orgName: "Chat Test B" } }); + + const mkUser = async (tenantId: string, key: string, name: string) => { + const identity = await prisma.identity.create({ data: { email: `${key}${domain}`, passwordHash: "x" } }); + return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name } }); + }; + const tech = await mkUser(tA.id, "tech", "Max Monteur"); + const tech2 = await mkUser(tA.id, "tech2", "Nora Nordmann"); + const outsider = await mkUser(tA.id, "outsider", "Otto Fremd"); + const admin = await mkUser(tA.id, "admin", "Anna Admin"); + const techB = await mkUser(tB.id, "techb", "Tom Bader"); + + const now = Date.now(); + const today = { plannedStart: new Date(now - 3_600_000), plannedEnd: new Date(now + 3 * 3_600_000) }; + const tomorrow = { plannedStart: new Date(now + 26 * 3_600_000), plannedEnd: new Date(now + 30 * 3_600_000) }; + + const customer = await prisma.customer.create({ + data: { tenantId: tA.id, firstName: "Erika", lastName: "Mustermann", phone: "0171 9876543", email: "erika@kunde.test", street: "Lindenallee", houseNumber: "7", postalCode: "22111", city: "Hamburg" }, + }); + const contact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Klaus Kontakt", phone: "040 555666", email: "klaus@kunde.test" } }); + const site = await prisma.site.create({ + data: { tenantId: tA.id, customerId: customer.id, name: "Haus Mustermann", street: "Lindenallee", houseNumber: "7", postalCode: "22111", city: "Hamburg", accessNotes: "Schlüsselkasten Code 4711, Hausmeister 0170 1112223" }, + }); + const company = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Müller Haustechnik GmbH", city: "Buxtehude" } }); + const site2 = await prisma.site.create({ data: { tenantId: tA.id, customerId: company.id, name: "Objekt Müller", city: "Buxtehude" } }); + + const order = async (number: string, data: Record, assignees: string[]) => { + const wo = await prisma.workOrder.create({ data: { tenantId: tA.id, number, ...(data as object) } as never }); + for (const userId of assignees) await prisma.workOrderAssignee.create({ data: { tenantId: tA.id, workOrderId: wo.id, userId } }); + return wo as unknown as { id: string; number: string; status: string }; + }; + const wo1 = await order( + "A-LC-1", + { customerId: customer.id, siteId: site.id, contactId: contact.id, title: "Speicher tauschen", description: "Kundin unter 0171 9876543 oder erika@kunde.test erreichbar, Lindenallee 7.", status: "in_progress", signatureRequired: false, ...today }, + [tech.id, tech2.id], + ); + const plan = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo1.id, name: "Filter", plannedQuantity: 1, unit: "Stk" } }); + const wo2 = await order("A-LC-2", { customerId: company.id, siteId: site2.id, title: "Wartung Heizung", status: "assigned", signatureRequired: false, ...today }, [tech.id, tech2.id]); + const wo3 = await order("A-LC-3", { customerId: company.id, siteId: site2.id, title: "Pumpe montieren", status: "in_progress", signatureRequired: false, ...tomorrow }, [tech.id]); + await prisma.photoRequirement.create({ data: { tenantId: tA.id, workOrderId: wo3.id, key: "fertige_montage", label: "Fertige Montage" } }); + const wo4 = await order("A-LC-4", { customerId: company.id, siteId: site2.id, title: "Regler einstellen", status: "in_progress", signatureRequired: false, ...tomorrow }, [tech.id]); + + const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Kunde B" } }); + const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-LC-1", customerId: customerB.id, title: "Auftrag B", status: "in_progress", ...today } }); + await prisma.workOrderAssignee.create({ data: { tenantId: tB.id, workOrderId: woB.id, userId: techB.id } }); + + return { + tA, + tB, + users: { tech, tech2, outsider, admin, techB }, + ctx: { + tech: ctxOf(tA.id, tech.id, "technician"), + tech2: ctxOf(tA.id, tech2.id, "technician"), + outsider: ctxOf(tA.id, outsider.id, "technician"), + admin: ctxOf(tA.id, admin.id, "tenant-admin"), + techB: ctxOf(tB.id, techB.id, "technician"), + }, + customer, + contact, + site, + plan, + orders: { wo1, wo2, wo3, wo4, woB }, + }; +} diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts index 0a9cbfd..e97a54b 100644 --- a/scripts/smoke-auth.ts +++ b/scripts/smoke-auth.ts @@ -75,7 +75,7 @@ function plans(x: Awaited>): Plan[] { { path: "/settings/users", mustContain: "backoffice@demo.example" }, { path: "/settings/audit" }, { path: "/settings/email" }, - { path: "/settings/lotse" }, + { path: "/settings/lotse", mustContain: "Lotse-Chat für Monteure" }, { path: "/settings/lotse/protocol" }, { path: "/settings/order-types", mustContain: "Montage" }, { path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" }, @@ -185,6 +185,11 @@ function plans(x: Awaited>): Plan[] { { path: "/m/time/new", mustContain: ["Zeit nachtragen", "Begründung"] }, { path: "/m/approvals", mustContain: "nicht freigeschaltet" }, { path: m(x.d07, "/time"), mustContain: x.d07.number }, + // L16 Lotse-Chat für Monteure + { path: "/m/lotse", mustContain: ["Lotse-Chat", "Neuer Chat", "Sprechen"] }, + { path: `/m/lotse?order=${x.d07.id}`, mustContain: [x.d07.number, "Lotse-Chat"] }, + { path: m(x.d07), mustContain: "Lotse fragen" }, + ...(x.d08 ? [{ path: `/m/lotse?order=${x.d08.id}`, expect: [404] }] : []), // L14 Abrechnungsübersicht: Meilensteine mobil, Backoffice-Übersicht ohne Recht { path: m(x.d07), mustContain: ["Meilensteine", "Smoke-Meilenstein", "Erreicht melden"] }, // order numbers appear in the monteur's own notifications and all UI texts in the serialized messages → check the record link diff --git a/scripts/test-e2e-tenant-isolation.ts b/scripts/test-e2e-tenant-isolation.ts index f201d8f..d0d48a6 100644 --- a/scripts/test-e2e-tenant-isolation.ts +++ b/scripts/test-e2e-tenant-isolation.ts @@ -136,6 +136,11 @@ async function createModelRows(A: TenantFixture): Promise { // L14 Abrechnungsübersicht put("WorkOrderMilestone", (await prisma.workOrderMilestone.create({ data: { tenantId: t, workOrderId: wo.id, title: "ZZ" } })).id, { title: MARK }); put("BillingRecord", (await prisma.billingRecord.create({ data: { tenantId: t, workOrderId: wo.id, kind: "order_completion", periodFrom: new Date(Date.now() - 3600_000), periodTo: new Date() } })).id, { invoiceNumber: MARK }); + // L16 Lotse-Chat für Monteure + const lotseConversation = await prisma.lotseConversation.create({ data: { tenantId: t, userId: uid, workOrderId: wo.id } }); + put("LotseConversation", lotseConversation.id, { workOrderId: MARK }); + put("LotseMessage", (await prisma.lotseMessage.create({ data: { tenantId: t, conversationId: lotseConversation.id, role: "user", text: "ZZ" } })).id, { text: MARK }); + put("LotseActionProposal", (await prisma.lotseActionProposal.create({ data: { tenantId: t, conversationId: lotseConversation.id, userId: uid, kind: "add_note", payload: {}, payloadHash: "0".repeat(64), expiresAt: new Date(Date.now() + 1_800_000) } })).id, { kind: MARK }); return rows; } diff --git a/scripts/test-lotse-chat-confirm.ts b/scripts/test-lotse-chat-confirm.ts new file mode 100644 index 0000000..7a31829 --- /dev/null +++ b/scripts/test-lotse-chat-confirm.ts @@ -0,0 +1,186 @@ +// L16 Lotse-Chat für Monteure – Bestätigen, Bearbeiten, Verwerfen, „Alle bestätigen“. +// +// Deckt ab: Ausführung ausschließlich über bestehende Services mit dem Monteur-ctx (Status, Session, Zeitnachtrag mit +// Freigabepflicht, Material, Notiz, Berichtsvorschlag im L9-Mechanismus), Blocker (Pflichtfoto) → fehlgeschlagen mit +// Sprungziel und ohne Teiländerung, Einmaligkeit (Doppeltipp, parallel), Ablauf, fremder Nutzer / Mandant B → not_found, +// Monteur ohne Zuweisung → Service verweigert, manipulierte Nutzlast, Bearbeiten (nur erlaubte Felder), Audit mit Quelle, +// Reihenfolge und Stopp beim ersten Fehler, Einstellung aus → blocked. +// +// Lauf: npx tsx scripts/test-lotse-chat-confirm.ts + +import "dotenv/config"; +import { prisma, dbForTenant } from "../src/server/db"; +import type { ProposalKind } from "../src/lib/lotse/chat"; +import { sessionStartPayload } from "../src/lib/sync/ops"; +import { toWallTimeInput } from "../src/lib/work-orders/time"; +import { parseReportContent } from "../src/lib/reports/content"; +import type { ServiceCtx } from "../src/server/services/context"; +import { startSession } from "../src/server/services/field/sessions"; +import { createDailyReport } from "../src/server/services/reports/create"; +import { decideLotseSuggestion } from "../src/server/services/lotse/suggestions"; +import { updateLotseSettings } from "../src/server/services/lotse/settings"; +import { confirmAllProposals, confirmProposal, discardProposal } from "../src/server/services/lotse/chat/confirm"; +import { createProposal, hashMatches } from "../src/server/services/lotse/chat/proposals"; +import { getChatView } from "../src/server/services/lotse/chat/conversations"; +import { cleanupChatTenants, createChatFixture, expectErr, failures, ok } from "./lib/lotse-chat-fixture"; + +const PREFIX = "lotsechat-confirm"; +const TZ = "Europe/Berlin"; + +async function main() { + const f = await createChatFixture(PREFIX); + const { wo1, wo2, wo3, wo4 } = f.orders; + + const conversations = new Map(); + async function conversationOf(ctx: ServiceCtx) { + if (!conversations.has(ctx.userId)) { + const c = await prisma.lotseConversation.create({ data: { tenantId: ctx.tenantId, userId: ctx.userId } }); + conversations.set(ctx.userId, c.id); + } + return conversations.get(ctx.userId)!; + } + async function card(ctx: ServiceCtx, kind: ProposalKind, workOrderId: string, payload: Record, messageId?: string) { + const conversationId = await conversationOf(ctx); + const p = await createProposal(ctx, { conversationId, kind, payload: { workOrderId, ...payload }, workOrderId, now: new Date() }); + if (messageId) await prisma.lotseActionProposal.update({ where: { id: p.id }, data: { messageId } }); + return p; + } + const slot = (startMinutesAgo: number, endMinutesAgo: number) => { + const s = toWallTimeInput(new Date(Date.now() - startMinutesAgo * 60_000), TZ); + const e = toWallTimeInput(new Date(Date.now() - endMinutesAgo * 60_000), TZ); + return { date: s.slice(0, 10), from: s.slice(11, 16), to: e.slice(11, 16), durationMinutes: startMinutesAgo - endMinutesAgo }; + }; + + console.log("\n— Status über transitionWorkOrder / Sessions —"); + const accept = await card(f.ctx.tech2, "transition_work_order", wo2.id, { number: "A-LC-2", action: "accept" }); + const r1 = await confirmProposal(f.ctx.tech2, { proposalId: accept.id }); + ok(r1.status === "confirmed", "Annehmen bestätigt"); + ok((await prisma.workOrder.findUnique({ where: { id: wo2.id } }))?.status === "accepted", "Auftrag → accepted (Service)"); + ok((await prisma.workOrderStatusChange.count({ where: { workOrderId: wo2.id, toStatus: "accepted", actorId: f.users.tech2.id } })) === 1, "Statuswechsel mit Monteur als Akteur"); + const again = await confirmProposal(f.ctx.tech2, { proposalId: accept.id }); + ok(again.status === "confirmed" && again.idempotent === true, "Doppeltipp → idempotent"); + const auditRow = await prisma.auditLog.findFirst({ where: { tenantId: f.tA.id, entity: "lotse_action_proposal", entityId: accept.id } }); + ok((auditRow?.after as { source?: string; status?: string } | null)?.source === "lotse_chat" && (auditRow?.before as { status?: string } | null)?.status === "proposed", "Audit before/after mit Quelle lotse_chat"); + + const start = await card(f.ctx.tech2, "transition_work_order", wo2.id, { number: "A-LC-2", action: "start_work" }); + ok((await confirmProposal(f.ctx.tech2, { proposalId: start.id })).status === "confirmed", "Arbeit starten bestätigt"); + ok((await prisma.workSession.count({ where: { workOrderId: wo2.id, userId: f.users.tech2.id, status: "running" } })) === 1, "laufende Session angelegt"); + ok((await prisma.workOrder.findUnique({ where: { id: wo2.id } }))?.status === "in_progress", "Auftrag → in_progress"); + + console.log("\n— Zeitnachtrag nach L12-Regeln —"); + const time = await card(f.ctx.tech2, "book_time", wo2.id, { number: "A-LC-2", type: "work", reason: "Uhr vergessen", ...slot(95, 65) }); + ok((await confirmProposal(f.ctx.tech2, { proposalId: time.id })).status === "confirmed", "Zeit bestätigt"); + const entry = await prisma.timeEntry.findFirst({ where: { userId: f.users.tech2.id, source: "manual" }, orderBy: { createdAt: "desc" } }); + ok(entry?.approvalStatus === "pending" && entry.correctionReason === "Uhr vergessen", "Nachtrag pending (Freigabepflicht) mit Grund"); + ok(((await prisma.lotseActionProposal.findUnique({ where: { id: time.id } }))?.result as { approvalStatus?: string } | null)?.approvalStatus === "pending", "Ergebnis der Karte: zur Freigabe"); + + const edited = await card(f.ctx.tech2, "book_time", wo2.id, { number: "A-LC-2", type: "work", reason: "Uhr vergessen", ...slot(160, 130) }); + ok((await confirmProposal(f.ctx.tech2, { proposalId: edited.id, edits: { reason: "Kein Netz", workOrderId: wo1.id, number: "X" } })).status === "confirmed", "bearbeitete Zeit bestätigt"); + const editedEntry = await prisma.timeEntry.findFirst({ where: { userId: f.users.tech2.id, source: "manual" }, orderBy: { createdAt: "desc" }, include: { workSession: { select: { workOrderId: true } } } }); + ok(editedEntry?.correctionReason === "Kein Netz" && editedEntry.workSession.workOrderId === wo2.id, "Änderung übernommen, Auftrag nicht änderbar"); + const editedRow = await prisma.lotseActionProposal.findUniqueOrThrow({ where: { id: edited.id } }); + ok((editedRow.payload as { reason?: string }).reason === "Kein Netz" && hashMatches(editedRow), "Nutzlast + Hash nach Bearbeitung aktualisiert"); + const editAudit = await prisma.auditLog.findFirst({ where: { entity: "lotse_action_proposal", entityId: edited.id } }); + ok((editAudit?.after as { edited?: boolean } | null)?.edited === true, "Audit: bearbeitet"); + const overlapCard = await card(f.ctx.tech2, "book_time", wo2.id, { number: "A-LC-2", type: "work", reason: "Uhr vergessen", ...slot(90, 70) }); + const overlap = await confirmProposal(f.ctx.tech2, { proposalId: overlapCard.id }); + ok(overlap.status === "failed" && overlap.code === "overlap", `Überschneidung beim Bestätigen → fehlgeschlagen (${overlap.code})`); + + console.log("\n— Material, Notiz, Einmaligkeit —"); + const mat = await card(f.ctx.tech, "record_material", wo1.id, { number: "A-LC-1", materialPlanId: f.plan.id, name: "Filter", quantity: 1, unit: "Stk", usageStatus: "fully_used" }); + ok((await confirmProposal(f.ctx.tech, { proposalId: mat.id })).status === "confirmed", "Material bestätigt"); + const usage = await prisma.materialUsage.findFirst({ where: { workOrderId: wo1.id, materialPlanId: f.plan.id } }); + ok(usage?.usageStatus === "fully_used" && Number(usage.actualQuantity) === 1 && usage.recordedById === f.users.tech.id, "MaterialUsage über Service erfasst"); + + const note = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "work_done", text: "Speicher installiert" }); + const notesBefore = await prisma.activityNote.count({ where: { workOrderId: wo1.id } }); + const parallel = await Promise.all([confirmProposal(f.ctx.tech, { proposalId: note.id }), confirmProposal(f.ctx.tech, { proposalId: note.id })]); + ok(parallel.every((r) => r.status === "confirmed") && parallel.some((r) => r.idempotent), "paralleler Doppeltipp: einmal ausgeführt, einmal idempotent"); + ok((await prisma.activityNote.count({ where: { workOrderId: wo1.id } })) === notesBefore + 1, "genau eine Notiz angelegt"); + + console.log("\n— Blocker → Rückfrage mit Sprungziel —"); + await startSession(f.ctx.tech, sessionStartPayload.parse({ workOrderId: wo3.id, mode: "work" })); + const complete = await card(f.ctx.tech, "transition_work_order", wo3.id, { number: "A-LC-3", action: "complete" }); + const blocked = await confirmProposal(f.ctx.tech, { proposalId: complete.id }); + ok(blocked.status === "failed" && blocked.code === "blocked", `Abschluss mit fehlendem Pflichtfoto → fehlgeschlagen (${blocked.code})`); + ok((await prisma.workOrder.findUnique({ where: { id: wo3.id } }))?.status === "in_progress", "Auftrag unverändert"); + ok((await prisma.workSession.count({ where: { workOrderId: wo3.id, userId: f.users.tech.id, status: "running" } })) === 1, "Session nicht beendet (Blocker vor jeder Änderung geprüft)"); + const view = await getChatView(f.ctx.tech, { conversationId: await conversationOf(f.ctx.tech) }); + const failNotice = view.messages.find((m) => m.role === "tool" && m.content.noticeKey === "failed"); + ok(Boolean(failNotice?.content.links?.some((l) => l.href === `/m/orders/${wo3.id}/photos` && l.label === "Fertige Montage")), "Lotse-Antwort mit Sprungziel zum Pflichtfoto"); + await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: complete.id }), "conflict", "fehlgeschlagene Karte nicht erneut ausführbar", "already_decided"); + + console.log("\n— Ablauf, fremde Nutzer, Manipulation, Verwerfen —"); + const late = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "zu spät" }); + await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: late.id }, new Date(Date.now() + 31 * 60_000)), "conflict", "nach 30 Minuten abgelaufen", "expired"); + ok((await prisma.lotseActionProposal.findUnique({ where: { id: late.id } }))?.status === "expired", "Status expired gespeichert"); + + const foreign = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "fremd" }); + await expectErr(() => confirmProposal(f.ctx.tech2, { proposalId: foreign.id }), "not_found", "anderer Monteur (gleicher Auftrag) → not_found"); + await expectErr(() => confirmProposal(f.ctx.admin, { proposalId: foreign.id }), "not_found", "Admin bestätigt keine fremde Karte"); + await expectErr(() => confirmProposal(f.ctx.techB, { proposalId: foreign.id }), "not_found", "Mandant B → not_found"); + await expectErr(() => discardProposal(f.ctx.techB, { proposalId: foreign.id }), "not_found", "Mandant B verwirft nicht"); + ok((await dbForTenant(f.tB.id).lotseActionProposal.findFirst({ where: { id: foreign.id } })) === null, "B-Client sieht die Karte nicht"); + + await prisma.lotseActionProposal.update({ where: { id: foreign.id }, data: { payload: { ...(foreign.payload as object), text: "manipuliert" } } }); + await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: foreign.id }), "conflict", "veränderte Nutzlast → nicht ausgeführt", "tampered"); + ok((await prisma.activityNote.count({ where: { text: "manipuliert" } })) === 0, "keine manipulierte Notiz"); + + const discard = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "verwerfen" }); + ok((await discardProposal(f.ctx.tech, { proposalId: discard.id })).status === "discarded", "verworfen"); + ok((await discardProposal(f.ctx.tech, { proposalId: discard.id })).status === "discarded", "Verwerfen idempotent"); + await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: discard.id }), "conflict", "verworfene Karte nicht bestätigbar", "already_decided"); + ok((await prisma.activityNote.count({ where: { text: "verwerfen" } })) === 0, "nichts ausgeführt"); + + const notAssigned = await card(f.ctx.outsider, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "ohne Zuweisung" }); + const outsiderResult = await confirmProposal(f.ctx.outsider, { proposalId: notAssigned.id }); + ok(outsiderResult.status === "failed" && outsiderResult.code === "not_found", "Monteur ohne Zuweisung: Service verweigert (not_found)"); + ok((await prisma.activityNote.count({ where: { text: "ohne Zuweisung" } })) === 0, "keine Notiz ohne Zuweisung"); + + console.log("\n— Alle bestätigen —"); + const conv = await conversationOf(f.ctx.tech); + const msg = await prisma.lotseMessage.create({ data: { tenantId: f.tA.id, conversationId: conv, role: "assistant", text: "Drei Karten" } }); + const pause = await card(f.ctx.tech, "transition_work_order", wo3.id, { number: "A-LC-3", action: "pause" }, msg.id); + const noteAll = await card(f.ctx.tech, "add_note", wo3.id, { number: "A-LC-3", kind: "work_done", text: "Pumpe gesetzt" }, msg.id); + const badMaterial = await card(f.ctx.tech, "record_material", wo3.id, { number: "A-LC-3", name: "Dichtung", quantity: 2, unit: "Stk", usageStatus: "additional" }, msg.id); + const all1 = await confirmAllProposals(f.ctx.tech, { messageId: msg.id }); + ok(all1.stopped && all1.results.length === 1 && all1.results[0].proposalId === badMaterial.id && all1.remaining === 2, "Reihenfolge (Material vor Notiz vor Status), Stopp beim ersten Fehler"); + ok((await prisma.lotseActionProposal.findUnique({ where: { id: noteAll.id } }))?.status === "proposed" && (await prisma.workOrder.findUnique({ where: { id: wo3.id } }))?.status === "in_progress", "nach Fehler nichts weiter ausgeführt"); + const stoppedNotice = await prisma.lotseMessage.findFirst({ where: { conversationId: conv, role: "tool" }, orderBy: { createdAt: "desc" } }); + ok((stoppedNotice?.content as { noticeKey?: string } | null)?.noticeKey === "stopped", "Klartext-Hinweis „gestoppt“"); + const all2 = await confirmAllProposals(f.ctx.tech, { messageId: msg.id }); + ok(!all2.stopped && all2.results.map((r) => r.proposalId).join() === [noteAll.id, pause.id].join(), "zweiter Lauf: Notiz, dann Pause"); + ok((await prisma.workOrder.findUnique({ where: { id: wo3.id } }))?.status === "paused", "Auftrag pausiert"); + await expectErr(() => confirmAllProposals(f.ctx.tech2, { messageId: msg.id }), "not_found", "„Alle bestätigen“ fremder Nachricht → not_found"); + + console.log("\n— Berichtsfelder als Lotse-Vorschlag (L9) —"); + const { report } = await createDailyReport(f.ctx.tech, { workOrderId: wo4.id }); + const fields = await card(f.ctx.tech, "suggest_report_fields", wo4.id, { number: "A-LC-4", reportId: report.id, reportType: "daily", fields: [{ field: "workPerformed", text: "Regler eingestellt und geprüft" }] }); + ok((await confirmProposal(f.ctx.tech, { proposalId: fields.id })).status === "confirmed", "Berichtsvorschlag bestätigt"); + const updated = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + const content = parseReportContent(updated.content); + ok(updated.aiDrafted && content.lotse?.suggestions.some((s) => s.field === "workPerformed" && s.state === "pending" && s.text === "Regler eingestellt und geprüft") === true, "Vorschlag in content.lotse (pending), aiDrafted"); + ok(content.texts.workPerformed === "", "Berichtstext selbst unverändert"); + await decideLotseSuggestion(f.ctx.tech, { reportId: report.id, field: "workPerformed", decision: "accept" }); + ok(parseReportContent((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).content).texts.workPerformed === "Regler eingestellt und geprüft", "im Bericht über den L9-Mechanismus übernommen"); + + console.log("\n— Einstellung aus —"); + const offCard = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "aus" }); + await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "neutral", chatEnabled: false }); + await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: offCard.id }), "blocked", "Lotse-Chat aus → Bestätigen blocked", "chat_disabled"); + await updateLotseSettings(f.ctx.admin, { enabled: false, addressForm: "neutral", chatEnabled: true }); + await expectErr(() => discardProposal(f.ctx.tech, { proposalId: offCard.id }), "blocked", "Lotse-Modul aus → Verwerfen blocked", "disabled"); + await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "neutral", chatEnabled: true }); + + await cleanupChatTenants(PREFIX); + console.log(failures ? `\n${failures} Fehler` : "\nAlle Prüfungen grün."); + await prisma.$disconnect(); + process.exit(failures ? 1 : 0); +} + +main().catch(async (err) => { + console.error(err); + await cleanupChatTenants(PREFIX).catch(() => undefined); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/scripts/test-lotse-chat-engine.ts b/scripts/test-lotse-chat-engine.ts new file mode 100644 index 0000000..6dcc2a6 --- /dev/null +++ b/scripts/test-lotse-chat-engine.ts @@ -0,0 +1,214 @@ +// L16 Lotse-Chat für Monteure – Chat-Schleife mit Fake-Provider (geskriptete Tool-Aufrufe). +// +// Deckt ab: Auftragszuordnung (Kontext > laufende Uhr > heutige Aufträge, Suchbegriff, Mehrdeutigkeit → Chips), +// Rückfrage (ask_user beendet die Schleife), Vorschläge schreiben nichts vor der Bestätigung, fehlende Pflichtwerte → +// keine Karte, Datenminimierung (Modell-Input ohne Telefon/E-Mail/Adresse/Namen, Platzhalter serverseitig eingesetzt), +// Monteur ohne Zuweisung → not_found, Mandantentrennung + nur eigener Verlauf, Einstellung/Modul aus → blocked, +// Token-Kontingent, Runden- und Token-Grenze je Nachricht, Anbieterfehler, AiGeneration/Protokoll, Aufbewahrung. +// +// Lauf: npx tsx scripts/test-lotse-chat-engine.ts + +import "dotenv/config"; +import { prisma, dbForTenant } from "../src/server/db"; +import { FakeLotseChatProvider, type FakeChatStep } from "../src/server/ai/lotse/chat-fake"; +import { chatSystemPrompt } from "../src/server/ai/lotse/chat-prompt"; +import type { ServiceCtx } from "../src/server/services/context"; +import { startSession } from "../src/server/services/field/sessions"; +import { sessionStartPayload } from "../src/lib/sync/ops"; +import { sendLotseMessage } from "../src/server/services/lotse/chat/engine"; +import { getChatView, startNewConversation } from "../src/server/services/lotse/chat/conversations"; +import { canUseLotseChat } from "../src/server/services/lotse/chat/access"; +import { renderPlaceholders } from "../src/server/services/lotse/chat/placeholders"; +import { queryWords } from "../src/server/services/lotse/chat/orders"; +import { updateLotseSettings } from "../src/server/services/lotse/settings"; +import { listAiGenerations } from "../src/server/services/lotse/protocol"; +import { purgeExpiredAiGenerations } from "../src/server/services/lotse/retention"; +import { cleanupChatTenants, createChatFixture, expectErr, failures, ok } from "./lib/lotse-chat-fixture"; + +const PREFIX = "lotsechat-engine"; + +async function send(ctx: ServiceCtx, text: string, script: FakeChatStep[], opts: { conversationId?: string | null; workOrderId?: string | null; maxRounds?: number; turnTokenLimit?: number; fail?: Error } = {}) { + const provider = new FakeLotseChatProvider(script, opts.fail ? { fail: opts.fail } : {}); + const view = await sendLotseMessage(ctx, { text, conversationId: opts.conversationId, workOrderId: opts.workOrderId }, { provider, maxRounds: opts.maxRounds, turnTokenLimit: opts.turnTokenLimit }); + return { view, provider, last: view.messages[view.messages.length - 1] }; +} + +const lastToolResult = (p: FakeLotseChatProvider): string => { + const call = p.calls[p.calls.length - 1]; + const turn = [...call.turns].reverse().find((t) => t.role === "tool_results"); + return turn && turn.role === "tool_results" ? turn.results.map((r) => r.content).join("\n") : ""; +}; + +async function main() { + const f = await createChatFixture(PREFIX); + const { wo1, wo2 } = f.orders; + + console.log("\n— Prompt & Hilfsfunktionen —"); + const prompt = chatSystemPrompt({ addressForm: "du", now: "Dienstag", today: "2026-09-15", contextOrder: "A-1", runningClockOrder: null }); + ok(prompt.includes("du führst nie selbst etwas aus") && prompt.includes("„du“") && prompt.includes("ask_user"), "System-Prompt: nie ausführen, Anrede, Rückfrage"); + ok(queryWords("Auftrag bei Objekt Müller").join(",") === "müller", "Suchbegriff ohne Füllwörter"); + + // tech: laufende Uhr auf A-LC-1 + await startSession(f.ctx.tech, sessionStartPayload.parse({ workOrderId: wo1.id, mode: "work", at: new Date(Date.now() - 20 * 60_000).toISOString() })); + + console.log("\n— Auftragszuordnung —"); + const noteScript = (input: Record = {}): FakeChatStep[] => [{ toolCalls: [{ name: "add_note", input: { text: "Speicher installiert", ...input } }] }, { text: "Bitte prüfen und bestätigen." }]; + const ctxTurn = await send(f.ctx.tech, "Speicher installiert", noteScript(), { workOrderId: wo2.id }); + ok(ctxTurn.last.proposals.length === 1 && ctxTurn.last.proposals[0].workOrderId === wo2.id, "Kontext-Auftrag hat Vorrang vor der laufenden Uhr"); + ok(ctxTurn.view.workOrder?.id === wo2.id && ctxTurn.provider.calls[0].system.includes("Kontext-Auftrag (Chat aus dem Auftrag geöffnet): A-LC-2"), "Kontext steht im System-Prompt"); + const clockTurn = await send(f.ctx.tech, "Speicher installiert", noteScript()); + ok(clockTurn.last.proposals[0]?.workOrderId === wo1.id, "ohne Kontext: Auftrag der laufenden Uhr"); + const generalConversation = clockTurn.view.conversationId!; + + const ambiguous = await send(f.ctx.tech2, "Filter getauscht", [{ toolCalls: [{ name: "add_note", input: { text: "Filter getauscht" } }] }, { text: "Welcher Auftrag?" }]); + ok(ambiguous.last.proposals.length === 0, "mehrdeutig (zwei heutige Aufträge, keine Uhr) → keine Karte"); + const chipValues = (ambiguous.last.content.chips ?? []).map((c) => c.value).sort(); + ok(chipValues.join("|") === "Auftrag A-LC-1|Auftrag A-LC-2", `Auswahl-Chips mit beiden Aufträgen (${chipValues.join(", ")})`); + ok(lastToolResult(ambiguous.provider).includes("order_ambiguous"), "Modell erhält order_ambiguous"); + ok(!(ambiguous.last.content.chips ?? []).some((c) => c.value.includes("Mustermann")) && ambiguous.last.content.chips!.some((c) => c.label.includes("Mustermann")), "Chip-Beschriftung für den Monteur mit Kunde, Wert ohne Namen"); + + const byQuery = await send(f.ctx.tech2, "Bei Müller Notiz", noteScript({ order: "Objekt Müller" })); + ok(byQuery.last.proposals[0]?.workOrderId === wo2.id, "Suchbegriff Kunde/Objekt → eindeutiger Auftrag"); + const byNumber = await send(f.ctx.tech2, "Notiz zu 1", noteScript({ order: "A-LC-1" })); + ok(byNumber.last.proposals[0]?.workOrderId === wo1.id, "Suchbegriff Auftragsnummer"); + + const asked = await send(f.ctx.tech2, "2 Std. gebucht", [{ toolCalls: [{ name: "ask_user", input: { question: "Für welchen Auftrag?", options: ["A-LC-1", "A-LC-2"] } }] }, { text: "darf nicht kommen" }]); + ok(asked.provider.calls.length === 1 && asked.last.text.includes("Für welchen Auftrag?"), "ask_user beendet die Schleife, Frage als Antwort"); + ok((asked.last.content.chips ?? []).length === 2, "Antwortoptionen als Chips"); + + console.log("\n— Vorschläge schreiben nichts —"); + const counts = async () => ({ + time: await prisma.timeEntry.count({ where: { tenantId: f.tA.id, source: "manual" } }), + material: await prisma.materialUsage.count({ where: { tenantId: f.tA.id } }), + notes: await prisma.activityNote.count({ where: { tenantId: f.tA.id } }), + status: (await prisma.workOrder.findMany({ where: { tenantId: f.tA.id }, orderBy: { number: "asc" }, select: { status: true } })).map((w) => w.status).join(","), + changes: await prisma.workOrderStatusChange.count({ where: { tenantId: f.tA.id } }), + }); + const before = await counts(); + const multi = await send(f.ctx.tech2, "Auftrag fertig, Speicher installiert, 30 Min., 1 Filter verbaut", [ + { + toolCalls: [ + { name: "book_time", input: { order: "A-LC-2", duration_minutes: 30, reason: "Uhr vergessen" } }, + { name: "record_material", input: { order: "A-LC-1", name: "Filter", quantity: 1 } }, + { name: "add_note", input: { order: "A-LC-1", text: "Speicher installiert" } }, + { name: "transition_work_order", input: { order: "A-LC-2", action: "accept" } }, + ], + }, + { text: "Vier Karten liegen bereit. Die Zeit zählt nach Freigabe." }, + ]); + const kinds = multi.last.proposals.map((p) => p.kind).join(","); + ok(kinds === "book_time,record_material,add_note,transition_work_order", `vier Aktionskarten (${kinds})`); + ok(multi.last.proposals.every((p) => p.status === "proposed"), "alle Karten offen"); + ok(JSON.stringify(await counts()) === JSON.stringify(before), "keine Zeit, kein Material, keine Notiz, kein Statuswechsel vor Bestätigung"); + const timeCard = multi.last.proposals[0].payload; + ok(timeCard.durationMinutes === 30 && typeof timeCard.from === "string" && typeof timeCard.to === "string" && timeCard.type === "work", "Zeitkarte: Dauer 30 Min. bis jetzt, von–bis gesetzt"); + const materialCard = multi.last.proposals[1].payload; + ok(materialCard.usageStatus === "fully_used" && materialCard.unit === "Stk" && materialCard.materialPlanId === f.plan.id, "Material der geplanten Position zugeordnet"); + const proposalRows = await prisma.lotseActionProposal.findMany({ where: { id: { in: multi.last.proposals.map((p) => p.id) } } }); + ok(proposalRows.every((p) => p.payloadHash.length === 64 && p.expiresAt.getTime() - p.createdAt.getTime() >= 29 * 60_000), "Hash + Ablauf nach 30 Minuten gespeichert"); + + const noReason = await send(f.ctx.tech2, "30 Min. auf Müller", [{ toolCalls: [{ name: "book_time", input: { order: "A-LC-2", duration_minutes: 30 } }] }, { text: "Grund?" }]); + ok(noReason.last.proposals.length === 0 && lastToolResult(noReason.provider).includes("missing_values") && lastToolResult(noReason.provider).includes("reason"), "Zeit ohne Grund → Rückfrage statt Karte"); + const noDuration = await send(f.ctx.tech2, "Zeit buchen", [{ toolCalls: [{ name: "book_time", input: { order: "A-LC-2", reason: "Uhr vergessen" } }] }, { text: "Wie lange?" }]); + ok(noDuration.last.proposals.length === 0 && lastToolResult(noDuration.provider).includes("duration_minutes"), "Zeit ohne Dauer → Rückfrage"); + const extra = await send(f.ctx.tech2, "2 Dichtungen", [{ toolCalls: [{ name: "record_material", input: { order: "A-LC-1", name: "Dichtung", quantity: 2, unit: "Stk" } }] }, { text: "Grund?" }]); + ok(extra.last.proposals.length === 0 && lastToolResult(extra.provider).includes("reason"), "Zusatzmaterial ohne Grund → Rückfrage"); + const partial = await send(f.ctx.tech2, "halber Filter", [{ toolCalls: [{ name: "record_material", input: { order: "A-LC-1", name: "Filter", quantity: 0.5 } }] }, { text: "Grund?" }]); + ok(partial.last.proposals.length === 0 && lastToolResult(partial.provider).includes("missing_values"), "Mindermenge ohne Grund → Rückfrage"); + const overlap = await send(f.ctx.tech, "30 Min. gearbeitet", [{ toolCalls: [{ name: "book_time", input: { order: "A-LC-1", duration_minutes: 30, reason: "Uhr vergessen" } }] }, { text: "Läuft schon." }]); + ok(overlap.last.proposals.length === 0 && lastToolResult(overlap.provider).includes("overlap"), "Überschneidung mit laufender Uhr → keine Karte"); + const blocked = await send(f.ctx.tech, "Pumpe fertig", [{ toolCalls: [{ name: "transition_work_order", input: { order: "A-LC-3", action: "complete" } }] }, { text: "Pflichtfoto fehlt." }]); + ok(blocked.last.proposals.length === 0 && lastToolResult(blocked.provider).includes("blocked"), "Abschluss mit fehlendem Pflichtfoto → keine Karte"); + ok((blocked.last.content.links ?? []).some((l) => l.href === `/m/orders/${f.orders.wo3.id}/photos`), "Sprungziel zum Pflichtfoto"); + const noReport = await send(f.ctx.tech, "Bericht", [{ toolCalls: [{ name: "suggest_report_fields", input: { order: "A-LC-4", fields: { workPerformed: "Regler eingestellt" } } }] }, { text: "Kein Bericht." }]); + ok(noReport.last.proposals.length === 0 && lastToolResult(noReport.provider).includes("no_open_report"), "Berichtsfelder ohne offenen Bericht → keine Karte"); + + console.log("\n— Datenminimierung —"); + const privacy = await send(f.ctx.tech, "Kundin ruft unter 0171 9876543 an, Mail erika@kunde.test. Telefonnummer vom Ansprechpartner?", [ + { toolCalls: [{ name: "get_order_details", input: { order: "A-LC-1" } }, { name: "list_my_orders", input: { days: 1 } }] }, + { text: "Telefon: {{phone:A-LC-1}}" }, + ]); + const sent = JSON.stringify(privacy.provider.calls); + for (const secret of ["9876543", "erika@kunde.test", "Mustermann", "Lindenallee", "22111", "555666", "klaus@kunde.test", "Klaus", "1112223", "Müller Haustechnik", "Max Monteur", "1234567", "buero@lotsechat-a.test"]) { + ok(!sent.includes(secret), `Modell-Input ohne „${secret}“`); + } + ok(sent.includes("[Telefon]") && sent.includes("[E-Mail]"), "Nutzertext: Telefon/E-Mail durch Platzhalter ersetzt"); + ok(sent.includes("{{phone:A-LC-1}}") && sent.includes("{{customer:A-LC-1}}"), "Kontaktdaten nur als Platzhalter-Token"); + ok(sent.includes("4711") && sent.includes("Speicher tauschen"), "Fachliche Hinweise (Zugangscode, Titel) bleiben erhalten"); + ok(privacy.last.text === "Telefon: 040 555666", `Platzhalter serverseitig eingesetzt (${privacy.last.text})`); + const gen = await prisma.aiGeneration.findFirst({ where: { tenantId: f.tA.id, kind: "lotse_chat" }, orderBy: { createdAt: "desc" } }); + ok(Boolean(gen) && !JSON.stringify(gen?.input).includes("555666") && JSON.stringify(gen?.output).includes("{{phone:A-LC-1}}"), "AiGeneration: Input minimiert, Output mit Token"); + ok(gen?.entityType === "lotse_conversation" && gen?.createdById === f.users.tech.id && gen?.inputTokens === 1000, "AiGeneration: Gespräch, Nutzer, Tokens aller Runden"); + ok((await renderPlaceholders(f.ctx.outsider, "{{phone:A-LC-1}}")) === "—", "Platzhalter für nicht sichtbaren Auftrag → „—“"); + const userRow = await prisma.lotseMessage.findFirst({ where: { conversationId: privacy.view.conversationId!, role: "user" }, orderBy: { createdAt: "desc" } }); + ok(JSON.stringify(userRow?.content).includes("[Telefon]"), "gesendete (minimierte) Fassung am Verlauf gespeichert"); + + console.log("\n— Rollen/Scope und Mandantentrennung —"); + await expectErr(() => getChatView(f.ctx.outsider, { workOrderId: wo1.id }), "not_found", "Monteur ohne Zuweisung: Chat mit Auftragskontext → not_found"); + const outsiderTurn = await send(f.ctx.outsider, "Notiz A-LC-1", noteScript({ order: "A-LC-1" })); + ok(outsiderTurn.last.proposals.length === 0 && lastToolResult(outsiderTurn.provider).includes("order_not_found"), "Monteur ohne Zuweisung findet den Auftrag nicht"); + await expectErr(() => getChatView(f.ctx.admin, { conversationId: generalConversation }), "not_found", "fremder Verlauf im selben Mandanten → not_found"); + await expectErr(() => getChatView(f.ctx.techB, { conversationId: generalConversation }), "not_found", "Mandant B liest Verlauf von A nicht"); + const bProvider = new FakeLotseChatProvider([{ text: "x" }]); + await expectErr(() => sendLotseMessage(f.ctx.techB, { text: "hallo", conversationId: generalConversation }, { provider: bProvider }), "not_found", "Mandant B schreibt nicht in A-Verlauf"); + await expectErr(() => sendLotseMessage(f.ctx.techB, { text: "hallo", workOrderId: wo1.id }, { provider: bProvider }), "not_found", "Mandant B: Auftrag von A als Kontext → not_found"); + ok(bProvider.calls.length === 0, "Modell bei Fremdzugriff nie aufgerufen"); + ok((await dbForTenant(f.tB.id).lotseMessage.count({ where: { conversationId: generalConversation } })) === 0, "B-Client sieht keine Nachrichten von A"); + const bTurn = await send(f.ctx.techB, "Notiz", noteScript({ order: "A-LC-1" })); + ok(bTurn.last.proposals.length === 0 && lastToolResult(bTurn.provider).includes("order_not_found"), "Mandant B findet Aufträge von A nicht"); + const withoutField = { ...f.ctx.tech, permissions: new Set([...f.ctx.tech.permissions].filter((p) => p !== "lotse:use")) }; + await expectErr(() => getChatView(withoutField), "forbidden", "ohne lotse:use → forbidden"); + const fresh = await startNewConversation(f.ctx.tech, { workOrderId: null }); + ok(fresh.messages.length === 0 && fresh.conversationId !== generalConversation, "„Neuer Chat“ beginnt leer"); + + console.log("\n— Einstellung, Modul, Kontingent, Grenzen —"); + await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "neutral", chatEnabled: false }); + const offProvider = new FakeLotseChatProvider([{ text: "x" }]); + await expectErr(() => sendLotseMessage(f.ctx.tech, { text: "hallo" }, { provider: offProvider }), "blocked", "Lotse-Chat ausgeschaltet → blocked", "chat_disabled"); + ok(!(await canUseLotseChat(f.ctx.tech)) && offProvider.calls.length === 0, "ausgeschaltet: kein Einstieg, kein Modellaufruf"); + const settingsAudit = await prisma.auditLog.findFirst({ where: { tenantId: f.tA.id, entity: "lotse_settings" }, orderBy: { createdAt: "desc" } }); + ok((settingsAudit?.after as { chatEnabled?: boolean } | null)?.chatEnabled === false, "Audit der Einstellung enthält chatEnabled"); + await updateLotseSettings(f.ctx.admin, { enabled: false, addressForm: "neutral", chatEnabled: true }); + await expectErr(() => sendLotseMessage(f.ctx.tech, { text: "hallo" }, { provider: offProvider }), "blocked", "Lotse-Modul aus → blocked", "disabled"); + await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "du", chatEnabled: true }); + ok(await canUseLotseChat(f.ctx.tech), "wieder eingeschaltet"); + + await expectErr(() => sendLotseMessage(f.ctx.tech, { text: "hallo" }, { provider: null }), "invalid", "ohne Anbieter → Lotse ist nicht eingerichtet", "not_configured"); + await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "du", monthlyTokenLimit: 100 }); + await expectErr(() => sendLotseMessage(f.ctx.tech, { text: "hallo" }, { provider: offProvider }), "blocked", "Monatskontingent aufgebraucht → blocked", "budget_exceeded"); + await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "du", monthlyTokenLimit: null }); + + const loop: FakeChatStep[] = Array.from({ length: 10 }, () => ({ toolCalls: [{ name: "get_running_clock", input: {} }] })); + const rounds = await send(f.ctx.tech, "Uhr?", loop, { maxRounds: 3 }); + ok(rounds.provider.calls.length === 3 && rounds.last.content.noticeKey === "rounds_exceeded", "Rundengrenze: nach 3 Schritten Schluss mit Hinweis"); + const heavy: FakeChatStep[] = Array.from({ length: 10 }, () => ({ toolCalls: [{ name: "get_running_clock", input: {} }], tokens: { input: 9000, output: 1000 } })); + const tokens = await send(f.ctx.tech, "Uhr?", heavy, { turnTokenLimit: 15_000 }); + ok(tokens.provider.calls.length === 2 && tokens.last.content.noticeKey === "turn_budget", "Token-Grenze je Nachricht greift"); + ok(tokens.provider.calls[1].system.includes("„du“"), "Anrede aus der Lotse-Einstellung"); + const failed = await send(f.ctx.tech, "hallo", [], { fail: new Error("503") }); + ok(failed.last.role === "assistant" && failed.last.content.noticeKey === "provider_failed", "Anbieterfehler → Hinweis statt Absturz, Eingabe gespeichert"); + + const protocol = await listAiGenerations(f.ctx.admin); + ok(protocol.items.some((i) => i.kind === "lotse_chat"), "KI-Protokoll zeigt Chat-Generierungen"); + const audit = await prisma.auditLog.count({ where: { tenantId: f.tA.id, entity: "lotse_chat_message" } }); + ok(audit > 10, "Audit je Chat-Antwort"); + + console.log("\n— Aufbewahrung —"); + const future = new Date(Date.now() + 400 * 86_400_000); + const purge = await purgeExpiredAiGenerations({ now: future, tenantIds: [f.tA.id] }); + ok(purge.chatConversations > 0 && (await prisma.lotseConversation.count({ where: { tenantId: f.tA.id } })) === 0, `Chatverläufe nach Frist gelöscht (${purge.chatConversations})`); + ok((await prisma.lotseMessage.count({ where: { tenantId: f.tA.id } })) === 0 && (await prisma.lotseActionProposal.count({ where: { tenantId: f.tA.id } })) === 0, "Nachrichten und Karten mitgelöscht"); + ok((await prisma.lotseConversation.count({ where: { tenantId: f.tB.id } })) === 1, "Mandant B unberührt"); + + await cleanupChatTenants(PREFIX); + console.log(failures ? `\n${failures} Fehler` : "\nAlle Prüfungen grün."); + await prisma.$disconnect(); + process.exit(failures ? 1 : 0); +} + +main().catch(async (err) => { + console.error(err); + await cleanupChatTenants(PREFIX).catch(() => undefined); + await prisma.$disconnect(); + process.exit(1); +});