Files
craftvia/scripts/test-lotse-chat-confirm.ts
msolarczekandClaude Opus 5 2f46552970 L16 Lotse-Chat für Monteure: Tests für Chat-Schleife und Bestätigen, Isolation und Smoke
Fake-Provider mit geskripteten Tool-Aufrufen: Zuordnung, Vorschlag ohne Schreibzugriff,
Ausführung über Services, Blocker, Einmaligkeit/Ablauf/fremder Nutzer, Scope,
Mandantentrennung, Datenminimierung, Einstellung aus, Budget- und Rundengrenze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:57:20 +02:00

187 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, string>();
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<string, unknown>, 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);
});