- handleEvent (Signatur unverändert) löst alle 17 Domain-Events auf: Team/Assignees,
Backoffice (read_all + report:approve), Teamleiter-Freigaben, Ersteller, Abrechnung,
Notdienst, Import, Sync; Akteur ausgenommen, alle IDs über ctx.db neu aufgelöst.
- In-App-Notification über ctx.db (ungelesene gleiche Meldung wird aufgefrischt),
E-Mail über enqueueMail mit dedupeKey event:entity:user (+ optional occurrenceId),
Opt-out je Typ, Notdienst als Pflichtmail, feste Empfänger ohne Doppelmail.
- Mail-Templates craftvia_* (de/en) inkl. Notdienst-Format Spec §19.4 und Craftvia-Fußzeile.
- Migration tenant_mail_settings: mailFromName, mailReplyTo, emergencyRecipients,
billingRecipients an tenant_settings (keine neue Tabelle).
- Texte aus messages/{de,en}/notifications.json.
- Test scripts/test-benachrichtigungen-events.ts (63 Prüfungen).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
20 KiB
TypeScript
291 lines
20 KiB
TypeScript
// L6 Benachrichtigungen — Empfängerauflösung je Event, Akteur ausgenommen, Mandantentrennung,
|
|
// Dedupe, Opt-out, Pflichtmail Notdienst, feste Empfänger, handleEvent wirft nie, Templates.
|
|
//
|
|
// Lauf: npx tsx scripts/test-benachrichtigungen-events.ts (lokale DB aus .env, SMTP optional)
|
|
|
|
import "dotenv/config";
|
|
import { prisma, dbForTenant } from "../src/server/db";
|
|
import { provisionTenant } from "../src/server/provision";
|
|
import { ROLE_DEFS, type RoleKey } from "../src/server/rbac";
|
|
import type { ServiceCtx } from "../src/server/services/context";
|
|
import { EVENT_TYPES, type DomainEvent } from "../src/lib/events";
|
|
import { handleEvent } from "../src/server/services/notifications/handle-event";
|
|
import { emitEvent } from "../src/server/events";
|
|
import { setPreferences } from "../src/server/services/notifications/preferences";
|
|
import { CRAFTVIA_TEMPLATE_KEYS, renderTemplate } from "../src/server/mail/templates";
|
|
import { closeQueues } from "../src/server/mail/queue";
|
|
import { closeMailProvider } from "../src/server/mail/provider-smtp";
|
|
|
|
let failures = 0;
|
|
const ok = (cond: boolean, msg: string) => {
|
|
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
|
if (!cond) failures++;
|
|
};
|
|
|
|
const SLUG_A = "zz-l6-events-a";
|
|
const SLUG_B = "zz-l6-events-b";
|
|
const MAIL_DOMAIN = "zz-l6-events.test";
|
|
|
|
async function cleanup() {
|
|
const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } });
|
|
const ids = tenants.map((t) => t.id);
|
|
if (ids.length) {
|
|
const where = { tenantId: { in: ids } };
|
|
await prisma.notification.deleteMany({ where });
|
|
await prisma.notificationPreference.deleteMany({ where });
|
|
await prisma.mailLog.deleteMany({ where });
|
|
await prisma.syncOperation.deleteMany({ where });
|
|
await prisma.importJob.deleteMany({ where });
|
|
await prisma.document.deleteMany({ where });
|
|
await prisma.report.deleteMany({ where });
|
|
await prisma.workOrderAssignee.deleteMany({ where });
|
|
await prisma.workOrder.deleteMany({ where });
|
|
await prisma.teamMember.deleteMany({ where });
|
|
await prisma.team.deleteMany({ where });
|
|
await prisma.customer.deleteMany({ where });
|
|
await prisma.auditLog.deleteMany({ where });
|
|
await prisma.tenantModule.deleteMany({ where });
|
|
await prisma.tenantSettings.deleteMany({ where });
|
|
await prisma.user.deleteMany({ where });
|
|
await prisma.role.deleteMany({ where });
|
|
await prisma.numberSequence.deleteMany({ where });
|
|
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
|
}
|
|
await prisma.mailLog.deleteMany({ where: { to: { endsWith: `@${MAIL_DOMAIN}` } } });
|
|
await prisma.identity.deleteMany({ where: { email: { endsWith: `@${MAIL_DOMAIN}` }, memberships: { none: {} } } });
|
|
}
|
|
|
|
async function createUser(tenantId: string, local: string, name: string, role: RoleKey) {
|
|
const email = `${local}@${MAIL_DOMAIN}`;
|
|
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
|
|
const roleRow = await prisma.role.findUniqueOrThrow({ where: { tenantId_key: { tenantId, key: role } } });
|
|
return prisma.user.create({
|
|
data: { tenantId, identityId: identity.id, email, name, status: "ACTIVE", userRoles: { create: [{ roleId: roleRow.id }] } },
|
|
});
|
|
}
|
|
|
|
const ctxFor = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({
|
|
db: dbForTenant(tenantId),
|
|
tenantId,
|
|
userId,
|
|
permissions: new Set(ROLE_DEFS[role].permissions),
|
|
});
|
|
|
|
const notifs = (userId: string, type: string, entityId: string) =>
|
|
prisma.notification.count({ where: { userId, type, entityId } });
|
|
const mails = (dedupeKey: string) => prisma.mailLog.count({ where: { dedupeKey } });
|
|
|
|
async function main() {
|
|
await cleanup();
|
|
|
|
// ---------- Fixtures ----------
|
|
const tA = await provisionTenant(prisma, { name: "L6 Events A", slug: SLUG_A, admin: { email: `admin-a@${MAIL_DOMAIN}`, name: "Admin A", password: "Zz-Test-1234!" } });
|
|
const tB = await provisionTenant(prisma, { name: "L6 Events B", slug: SLUG_B, admin: { email: `admin-b@${MAIL_DOMAIN}`, name: "Admin B", password: "Zz-Test-1234!" } });
|
|
const adminA = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: tA.id, email: `admin-a@${MAIL_DOMAIN}` } } });
|
|
|
|
const backoffice = await createUser(tA.id, "backoffice-a", "Bea Backoffice", "backoffice");
|
|
const lead = await createUser(tA.id, "lead-a", "Tim Teamleiter", "team-lead");
|
|
const tech1 = await createUser(tA.id, "tech1-a", "Max Monteur", "technician");
|
|
const tech2 = await createUser(tA.id, "tech2-a", "Ole Ohneteam", "technician");
|
|
const backofficeB = await createUser(tB.id, "backoffice-b", "Bernd Fremd", "backoffice");
|
|
|
|
const customerA = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Lutz Meier GmbH" } });
|
|
const teamA = await prisma.team.create({ data: { tenantId: tA.id, name: "Team Nord", leaderUserId: lead.id } });
|
|
await prisma.teamMember.create({ data: { tenantId: tA.id, teamId: teamA.id, userId: tech1.id, validFrom: new Date(Date.now() - 86_400_000) } });
|
|
const wo = await prisma.workOrder.create({
|
|
data: { tenantId: tA.id, number: "A-ZZ-1", title: "Heizung warten", customerId: customerA.id, assignedTeamId: teamA.id, status: "assigned" },
|
|
});
|
|
const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Fremdkunde" } });
|
|
const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-ZZ-1", title: "Fremdauftrag", customerId: customerB.id } });
|
|
|
|
const boCtx = ctxFor(tA.id, backoffice.id, "backoffice");
|
|
const leadCtx = ctxFor(tA.id, lead.id, "team-lead");
|
|
const tech1Ctx = ctxFor(tA.id, tech1.id, "technician");
|
|
const ev = (type: DomainEvent["type"], entityId = wo.id, data?: DomainEvent["data"]): DomainEvent => ({
|
|
type, entityType: type.startsWith("report.") ? "report" : "work_order", entityId, data,
|
|
});
|
|
|
|
// ---------- 1) work_order.assigned → Team (Mitglieder + Leiter), Akteur ausgenommen ----------
|
|
await handleEvent(boCtx, ev("work_order.assigned"));
|
|
ok((await notifs(tech1.id, "work_order.assigned", wo.id)) === 1, "(1) assigned: Teammitglied erhält In-App-Benachrichtigung");
|
|
ok((await notifs(lead.id, "work_order.assigned", wo.id)) === 1, "(1) assigned: Teamleiter erhält In-App-Benachrichtigung");
|
|
ok((await notifs(tech2.id, "work_order.assigned", wo.id)) === 0, "(1) assigned: Monteur ohne Zuweisung erhält nichts");
|
|
ok((await notifs(backoffice.id, "work_order.assigned", wo.id)) === 0, "(1) assigned: Akteur (Backoffice) ausgenommen");
|
|
ok((await mails(`work_order.assigned:${wo.id}:${tech1.id}`)) === 1, "(1) assigned: E-Mail an Teammitglied eingestellt");
|
|
const assignedMail = await prisma.mailLog.findFirst({ where: { dedupeKey: `work_order.assigned:${wo.id}:${lead.id}` } });
|
|
ok(assignedMail?.template === "craftvia_team_assigned" && assignedMail.tenantId === tA.id, "(1) assigned: Template craftvia_team_assigned, Mandant A");
|
|
const n1 = await prisma.notification.findFirst({ where: { userId: tech1.id, type: "work_order.assigned" } });
|
|
ok(n1?.link === `/m/orders/${wo.id}` && !!n1?.title.includes("A-ZZ-1"), "(1) assigned: Titel mit Auftragsnummer, Link in die mobile Ansicht");
|
|
|
|
// Einzel-Assignee außerhalb des Teams
|
|
await prisma.workOrderAssignee.create({ data: { tenantId: tA.id, workOrderId: wo.id, userId: tech2.id } });
|
|
await handleEvent(boCtx, ev("work_order.assigned"));
|
|
ok((await notifs(tech2.id, "work_order.assigned", wo.id)) === 1, "(1b) assigned: Einzel-Assignee erhält Benachrichtigung");
|
|
await prisma.workOrderAssignee.deleteMany({ where: { workOrderId: wo.id } });
|
|
|
|
// ---------- 2) Dedupe: gleiches Event zweimal → eine Mail, keine doppelte In-App-Zeile ----------
|
|
ok((await mails(`work_order.assigned:${wo.id}:${tech1.id}`)) === 1, "(2) Dedupe: zweites assigned-Event → weiterhin genau eine Mail");
|
|
ok((await notifs(tech1.id, "work_order.assigned", wo.id)) === 1, "(2) Dedupe: ungelesene Benachrichtigung aufgefrischt statt verdoppelt");
|
|
|
|
// ---------- 3) Akteur ausgenommen (Teamleiter storniert) ----------
|
|
await handleEvent(leadCtx, ev("work_order.cancelled"));
|
|
ok((await notifs(lead.id, "work_order.cancelled", wo.id)) === 0, "(3) cancelled: auslösender Teamleiter erhält nichts");
|
|
ok((await notifs(tech1.id, "work_order.cancelled", wo.id)) === 1, "(3) cancelled: Teammitglied erhält Benachrichtigung");
|
|
|
|
// ---------- 4) started → Backoffice (work_order:read_all + report:approve) ----------
|
|
await handleEvent(tech1Ctx, ev("work_order.started"));
|
|
ok((await notifs(backoffice.id, "work_order.started", wo.id)) === 1, "(4) started: Backoffice erhält Benachrichtigung");
|
|
ok((await notifs(adminA.id, "work_order.started", wo.id)) === 1, "(4) started: Mandantenadmin (hat read_all + approve) erhält Benachrichtigung");
|
|
ok((await notifs(tech1.id, "work_order.started", wo.id)) === 0, "(4) started: Akteur (Monteur) ausgenommen");
|
|
ok((await notifs(lead.id, "work_order.started", wo.id)) === 0, "(4) started: Teamleiter ist kein Backoffice");
|
|
ok((await notifs(backofficeB.id, "work_order.started", wo.id)) === 0, "(4) started: Backoffice des Mandanten B erhält nichts");
|
|
const started = await prisma.notification.findFirst({ where: { userId: backoffice.id, type: "work_order.started" } });
|
|
ok(started?.link === `/work-orders/${wo.id}` && !!started?.message.includes("Max Monteur"), "(4) started: Backoffice-Link + Akteurname im Text");
|
|
|
|
// ---------- 5) Mandantentrennung ----------
|
|
const beforeCount = await prisma.notification.count({ where: { tenantId: { in: [tA.id, tB.id] } } });
|
|
await handleEvent(ctxFor(tB.id, backofficeB.id, "backoffice"), ev("work_order.assigned", wo.id));
|
|
ok((await prisma.notification.count({ where: { tenantId: { in: [tA.id, tB.id] } } })) === beforeCount, "(5) Kontext Mandant B + Auftrag von A → keinerlei Benachrichtigung");
|
|
await handleEvent(boCtx, ev("work_order.assigned", woB.id));
|
|
ok((await prisma.notification.count({ where: { tenantId: { in: [tA.id, tB.id] } } })) === beforeCount, "(5) Kontext Mandant A + Auftrag von B → keinerlei Benachrichtigung");
|
|
|
|
const report = await prisma.report.create({
|
|
data: { tenantId: tA.id, workOrderId: wo.id, type: "completion", reportDate: new Date(), lineageId: "zz-l6-lineage", content: {}, status: "approved", createdById: backofficeB.id },
|
|
});
|
|
await handleEvent(boCtx, ev("report.approved", report.id));
|
|
ok((await prisma.notification.count({ where: { userId: backofficeB.id } })) === 0, "(5) report.approved: fremde User-ID als Ersteller wird nie Empfänger");
|
|
ok((await notifs(tech1.id, "report.approved", report.id)) === 1, "(5) report.approved: Team des Auftrags erhält Benachrichtigung");
|
|
ok((await prisma.notification.count({ where: { tenantId: tB.id } })) === 0, "(5) Mandant B hat insgesamt keine Benachrichtigungen erhalten");
|
|
ok((await prisma.mailLog.count({ where: { tenantId: tA.id, to: backofficeB.email } })) === 0, "(5) keine Mail an Nutzer des Mandanten B");
|
|
|
|
// ---------- 6) report.submitted: Stufe team → nur Teamleiter; ohne Stufe → Backoffice + Teamleiter ----------
|
|
await prisma.report.update({ where: { id: report.id }, data: { status: "submitted" } });
|
|
await handleEvent(tech1Ctx, ev("report.submitted", report.id, { approvalStage: "team" }));
|
|
ok((await notifs(lead.id, "report.submitted", report.id)) === 1, "(6) submitted/team: Teamleiter erhält Prüfauftrag");
|
|
ok((await notifs(backoffice.id, "report.submitted", report.id)) === 0, "(6) submitted/team: Backoffice nicht beteiligt");
|
|
await handleEvent(leadCtx, ev("report.submitted", report.id));
|
|
ok((await notifs(backoffice.id, "report.submitted", report.id)) === 1, "(6) submitted ohne Stufe: Backoffice erhält Prüfauftrag");
|
|
const reviewMail = await prisma.mailLog.findFirst({ where: { dedupeKey: `report.submitted:${report.id}:${backoffice.id}` } });
|
|
ok(reviewMail?.template === "craftvia_report_review", "(6) submitted: Template craftvia_report_review");
|
|
|
|
// ---------- 7) Opt-out je Typ ----------
|
|
const allButChanged = EVENT_TYPES.filter((t) => t !== "work_order.changed");
|
|
await setPreferences(tech1Ctx, { emailOn: allButChanged });
|
|
await handleEvent(boCtx, ev("work_order.changed"));
|
|
ok((await notifs(tech1.id, "work_order.changed", wo.id)) === 1, "(7) Opt-out: In-App-Benachrichtigung bleibt");
|
|
ok((await mails(`work_order.changed:${wo.id}:${tech1.id}`)) === 0, "(7) Opt-out: keine E-Mail an abbestellenden Monteur");
|
|
ok((await mails(`work_order.changed:${wo.id}:${lead.id}`)) === 1, "(7) Opt-out: Teamleiter ohne Opt-out erhält E-Mail");
|
|
|
|
// occurrenceId erlaubt wiederholbare Events
|
|
await handleEvent(tech1Ctx, ev("work_order.daily_report_created", wo.id, { occurrenceId: "2026-09-14" }));
|
|
await handleEvent(tech1Ctx, ev("work_order.daily_report_created", wo.id, { occurrenceId: "2026-09-15" }));
|
|
ok((await prisma.mailLog.count({ where: { dedupeKey: { startsWith: `work_order.daily_report_created:${wo.id}:${backoffice.id}` } } })) === 2,
|
|
"(7b) occurrenceId: zwei Tagesberichte → zwei Mails");
|
|
|
|
// ---------- 8) Pflichtmail Notdienst trotz Opt-out + feste Notdienst-Empfänger ----------
|
|
await prisma.notificationPreference.upsert({
|
|
where: { userId_eventType: { userId: backoffice.id, eventType: "emergency.completed" } },
|
|
update: { email: false },
|
|
create: { tenantId: tA.id, userId: backoffice.id, eventType: "emergency.completed", email: false },
|
|
});
|
|
await prisma.tenantSettings.update({
|
|
where: { tenantId: tA.id },
|
|
data: { emergencyRecipients: [`notdienst@${MAIL_DOMAIN}`, backoffice.email], billingRecipients: [`abrechnung@${MAIL_DOMAIN}`] },
|
|
});
|
|
await prisma.workOrder.update({ where: { id: wo.id }, data: { isEmergency: true } });
|
|
await handleEvent(tech1Ctx, ev("emergency.completed", wo.id, { startedAt: "2026-07-28T21:42:00.000Z", endedAt: "2026-07-28T23:18:00.000Z" }));
|
|
const emergencyMail = await prisma.mailLog.findFirst({ where: { dedupeKey: `emergency.completed:${wo.id}:${backoffice.id}` } });
|
|
ok(!!emergencyMail, "(8) Notdienst: Pflichtmail an Backoffice trotz gespeichertem Opt-out");
|
|
ok(emergencyMail?.template === "craftvia_emergency", "(8) Notdienst: Template craftvia_emergency");
|
|
ok((await mails(`emergency.completed:${wo.id}:ext:notdienst@${MAIL_DOMAIN}`)) === 1, "(8) Notdienst: fester Notdienst-Empfänger erhält Mail");
|
|
ok((await mails(`emergency.completed:${wo.id}:ext:${backoffice.email}`)) === 0, "(8) Notdienst: keine Doppelmail, wenn fester Empfänger zugleich Nutzer ist");
|
|
ok((await notifs(tech1.id, "emergency.completed", wo.id)) === 0, "(8) Notdienst: auslösender Monteur ausgenommen");
|
|
await setPreferences(ctxFor(tA.id, backoffice.id, "backoffice"), { emailOn: [] });
|
|
const emPref = await prisma.notificationPreference.findUnique({ where: { userId_eventType: { userId: backoffice.id, eventType: "emergency.created" } } });
|
|
ok(emPref?.email === true, "(8) Notdienst: Pflichttyp lässt sich über setPreferences nicht abbestellen");
|
|
|
|
// ---------- 9) Abrechnung → feste Abrechnungsempfänger (Mail), Backoffice In-App ----------
|
|
await handleEvent(leadCtx, ev("work_order.released_for_billing"));
|
|
ok((await mails(`work_order.released_for_billing:${wo.id}:ext:abrechnung@${MAIL_DOMAIN}`)) === 1, "(9) Abrechnung: fester Abrechnungsempfänger erhält Mail");
|
|
ok((await notifs(backoffice.id, "work_order.released_for_billing", wo.id)) === 1, "(9) Abrechnung: Backoffice erhält In-App-Benachrichtigung");
|
|
ok((await mails(`work_order.released_for_billing:${wo.id}:${backoffice.id}`)) === 0, "(9) Abrechnung: bei festen Empfängern keine zusätzliche Nutzer-Mail");
|
|
|
|
// ---------- 10) import.* → importierender Nutzer (auch wenn er Akteur ist); sync.failed ----------
|
|
await setPreferences(boCtx, { emailOn: [...EVENT_TYPES] }); // undo the opt-out-all from step 8
|
|
const doc = await prisma.document.create({
|
|
data: {
|
|
tenantId: tA.id, category: "order_confirmation", fileName: "auftrag.pdf", storageKey: `${tA.id}/zz-l6.pdf`,
|
|
mimeType: "application/pdf", fileSize: 10, checksum: "zz", lineageId: "zz-l6-doc-lineage",
|
|
},
|
|
});
|
|
{
|
|
const job = await prisma.importJob.create({ data: { tenantId: tA.id, documentId: doc.id, status: "failed", errorMessage: "unlesbar", importedById: backoffice.id } });
|
|
await handleEvent(boCtx, { type: "import.failed", entityType: "import_job", entityId: job.id });
|
|
ok((await notifs(backoffice.id, "import.failed", job.id)) === 1, "(10) import.failed: importierender Nutzer informiert (System-Ergebnis)");
|
|
ok((await prisma.mailLog.findFirst({ where: { dedupeKey: `import.failed:${job.id}:${backoffice.id}` } }))?.template === "craftvia_document_failed",
|
|
"(10) import.failed: Template craftvia_document_failed");
|
|
ok((await notifs(adminA.id, "import.failed", job.id)) === 0, "(10) import.failed: nur der importierende Nutzer");
|
|
}
|
|
const op = await prisma.syncOperation.create({
|
|
data: { tenantId: tA.id, userId: tech1.id, clientOpId: "zz-l6-op", opType: "work_order.transition", entityType: "work_order", entityId: wo.id, payload: {}, status: "conflict", errorCode: "version_conflict", clientCreatedAt: new Date() },
|
|
});
|
|
await handleEvent(tech1Ctx, { type: "sync.failed", entityType: "sync_operation", entityId: op.id });
|
|
ok((await notifs(tech1.id, "sync.failed", op.id)) === 1, "(10) sync.failed: betroffener Nutzer informiert");
|
|
ok((await notifs(backoffice.id, "sync.failed", op.id)) === 1, "(10) sync.failed: Backoffice informiert");
|
|
ok((await prisma.notification.findFirst({ where: { userId: tech1.id, type: "sync.failed" } }))?.link === "/m/sync", "(10) sync.failed: Monteur-Link /m/sync");
|
|
|
|
// ---------- 11) handleEvent / emitEvent werfen nie ----------
|
|
const brokenDb = new Proxy({}, { get: () => { throw new Error("db down"); } }) as ServiceCtx["db"];
|
|
const origError = console.error;
|
|
console.error = () => {};
|
|
let threw = false;
|
|
try {
|
|
await handleEvent({ ...boCtx, db: brokenDb }, ev("work_order.assigned"));
|
|
await emitEvent({ ...boCtx, db: brokenDb }, ev("work_order.started"));
|
|
await handleEvent(boCtx, ev("work_order.changed", "does-not-exist"));
|
|
} catch {
|
|
threw = true;
|
|
} finally {
|
|
console.error = origError;
|
|
}
|
|
ok(!threw, "(11) handleEvent/emitEvent werfen bei DB-Fehler oder unbekannter Entität nicht");
|
|
|
|
// ---------- 12) Craftvia-Templates de/en ----------
|
|
const sample = {
|
|
craftvia_team_assigned: { name: "Max", number: "A-1", title: "Heizung", customer: "Meier", actionUrl: "https://x.test/a" },
|
|
craftvia_report_review: { name: "", number: "A-1", title: "Heizung", customer: "Meier", reportType: "completion", actionUrl: "https://x.test/r" },
|
|
craftvia_billing_release: { name: "", number: "A-1", title: "Heizung", customer: "Meier", actionUrl: "https://x.test/b", footer: "configured" },
|
|
craftvia_emergency: { name: "Bea", phase: "completed", number: "N-1", technician: "Martin Solarczek", customer: "Lutz Meier", start: "28.07.2026, 23:42", end: "29.07.2026, 01:18", actionUrl: "https://x.test/e" },
|
|
craftvia_document_failed: { name: "Bea", fileName: "a.pdf", error: "unlesbar", actionUrl: "https://x.test/i" },
|
|
craftvia_notification: { name: "Bea", subject: "Hinweis", body: "Text", actionUrl: "https://x.test/n" },
|
|
} as const;
|
|
for (const key of CRAFTVIA_TEMPLATE_KEYS) {
|
|
for (const locale of ["de", "en"] as const) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const r = renderTemplate(key, locale, sample[key] as any);
|
|
ok(r.subject.includes("Craftvia") && r.html.includes("<!doctype html>") && r.text.length > 0 && !r.text.includes("<"), `(12) Template ${key}/${locale} rendert`);
|
|
}
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const em = renderTemplate("craftvia_emergency", "de", sample.craftvia_emergency as any);
|
|
ok(em.text.includes("Neuer Notdiensteinsatz abgeschlossen") && em.text.includes("Monteur: Martin Solarczek") && em.text.includes("Einsatzende: 29.07.2026, 01:18") && em.text.includes("Status: Zur Prüfung und Abrechnung"),
|
|
"(12) Notdienst-Mail im Format Spec §19.4");
|
|
ok(em.text.includes("nicht abbestellen"), "(12) Notdienst-Mail trägt Pflicht-Hinweis");
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
ok(renderTemplate("craftvia_report_review", "de", sample.craftvia_report_review as any).text.includes("Guten Tag,"), "(12) Anrede ohne Namen für feste Empfänger");
|
|
}
|
|
|
|
main()
|
|
.catch((err) => {
|
|
console.error(err);
|
|
failures++;
|
|
})
|
|
.finally(async () => {
|
|
await cleanup().catch((e) => console.error("cleanup:", e));
|
|
await closeQueues().catch(() => {});
|
|
await closeMailProvider().catch(() => {});
|
|
await prisma.$disconnect();
|
|
console.log(failures ? `\n${failures} Fehler.` : "\nAlle Prüfungen bestanden.");
|
|
process.exit(failures ? 1 : 0);
|
|
});
|