// Lane L15 „Testphase & Onboarding" — Lebenszyklus-Job und Plattform-Aktionen: // Erinnerungen 7/3/1 Tage genau einmal (auch nach verpasstem Lauf), Ablaufmail, Löschhinweis, // Löschung nur fällig + TRIAL (über das bestehende Offboarding inkl. Speicher), umgewandelte, // verlängerte oder abgebrochene Mandanten bleiben erhalten, Mandant B unberührt; // Plattform-Rechte (Mandanten-Admin/Read-only-Admin können die Testphase nicht ändern), // Plattform-Wizard mit Einladung, Mail-Vorlagen, Worker-Registrierung. // // Lauf: npx tsx scripts/test-testphase-lifecycle.ts (lokale Postgres-DB + Garage aus .env) import "dotenv/config"; import { readFileSync } from "node:fs"; import { prisma, dbForTenant } from "../src/server/db"; import { PROCESSORS } from "../src/server/jobs/processors"; import { renderTemplate, TRIAL_TEMPLATE_KEYS, type TemplateVars } from "../src/server/mail/templates"; import { addDaysToKey, todayKey, trialEndInstant } from "../src/lib/trial/dates"; import { cancelTrialDeletion, changeTrialEndDate, convertTenantToFull, createPlatformTrialTenant, endTrialNow, scheduleTrialDeletion, } from "../src/server/services/trial/admin"; import { deleteTrialTenant, runTrialLifecycle } from "../src/server/services/trial/lifecycle"; import { getTrialState } from "../src/server/services/trial/state"; import { storage } from "../src/server/storage/adapter"; import { readStoredBytes } from "../src/server/services/documents/read"; import { captureMail, cleanupL15, ctxFor, DOMAIN, expectCode, failures, ok, platformAdmin, trialTenant } from "./lib/testphase-fixture"; const HOUR = 3600_000; const DAY = 24 * HOUR; async function main() { await cleanupL15(); const full = await platformAdmin("full"); const readonly = await platformAdmin("readonly"); const actor = { platformAdminId: full.id }; const today = todayKey(); console.log("\n— Erinnerungen 7/3/1 genau einmal —"); const R = await trialTenant("Lifecycle R", addDaysToKey(today, 7)); const endR = trialEndInstant(addDaysToKey(today, 7)); const at = (dayOffset: number) => new Date(endR.getTime() - DAY * 7 + dayOffset * DAY - 12 * HOUR); // noon-ish of "today + offset" const mail = captureMail(); const run = (now: Date, ids: string[]) => runTrialLifecycle({ now, tenantIds: ids, sendMail: mail.fn }); const count = (tpl: string, tenantId: string) => mail.sent.filter((m) => m.template === tpl && m.tenantId === tenantId).length; await run(at(0), [R.tenantId]); ok(count("trial_reminder", R.tenantId) === 1 && (mail.sent[0].vars as { daysLeft: number }).daysLeft === 7, "7 Tage vorher: Erinnerung an den Admin"); ok(mail.sent[0].to === `admin-lifecycle-r${DOMAIN}`, "Empfänger = aktiver Mandanten-Admin"); await run(at(0), [R.tenantId]); await run(at(1), [R.tenantId]); ok(count("trial_reminder", R.tenantId) === 1, "zweiter Lauf / Folgetag: keine weitere Erinnerung"); await run(at(4), [R.tenantId]); ok(count("trial_reminder", R.tenantId) === 2, "3 Tage vorher: zweite Erinnerung"); await run(at(4), [R.tenantId]); await run(at(6), [R.tenantId]); await run(at(6), [R.tenantId]); ok(count("trial_reminder", R.tenantId) === 3, "1 Tag vorher: dritte Erinnerung, jeweils genau einmal"); const markers = await prisma.tenant.findUniqueOrThrow({ where: { id: R.tenantId } }); ok(!!markers.trialReminder7At && !!markers.trialReminder3At && !!markers.trialReminder1At, "Versandmarker gesetzt"); const R2 = await trialTenant("Lifecycle R2", addDaysToKey(today, 2)); await run(new Date(), [R2.tenantId]); const r2 = await prisma.tenant.findUniqueOrThrow({ where: { id: R2.tenantId } }); ok(count("trial_reminder", R2.tenantId) === 1 && !!r2.trialReminder7At && !!r2.trialReminder3At && !r2.trialReminder1At, "verpasster Lauf: nur die nächstliegende Erinnerung, ältere als erledigt markiert"); console.log("\n— Ablauf, Löschhinweis, Löschung —"); const endExpired = new Date(endR.getTime() + HOUR); await run(endExpired, [R.tenantId]); await run(endExpired, [R.tenantId]); ok(count("trial_expired", R.tenantId) === 1, "Ablaufmail am Endtag genau einmal"); const expiredRow = await prisma.tenant.findUniqueOrThrow({ where: { id: R.tenantId } }); ok(expiredRow.readOnlySince?.getTime() === endR.getTime(), "readOnlySince = Ende der Testphase"); const expiredMail = mail.sent.find((m) => m.template === "trial_expired" && m.tenantId === R.tenantId)!; ok(!!(expiredMail.vars as { deletionDate?: string }).deletionDate && (expiredMail.vars as { exportUrl: string }).exportUrl.endsWith("/settings/export"), "Ablaufmail nennt Löschdatum und Export"); const due = expiredRow.deletionDueAt!; ok(due.getTime() === endR.getTime() + 30 * DAY, "Löschung 30 Tage nach Ende fällig"); await run(new Date(due.getTime() - 8 * DAY), [R.tenantId]); ok(count("trial_deletion_notice", R.tenantId) === 0, "8 Tage vor Löschung noch kein Hinweis"); await run(new Date(due.getTime() - 6 * DAY), [R.tenantId]); await run(new Date(due.getTime() - 5 * DAY), [R.tenantId]); ok(count("trial_deletion_notice", R.tenantId) === 1, "Löschhinweis 7 Tage vorher genau einmal"); // tenant data + stored object that must disappear const dbR = dbForTenant(R.tenantId); const customerR = await dbR.customer.create({ data: { tenantId: R.tenantId, companyName: "ZZ Löschkunde", city: "Bremen" } }); const stored = await storage.put({ tenantId: R.tenantId, filename: "zz-loeschen.txt", contentType: "text/plain", bytes: Buffer.from("zz") }); const identityR = await prisma.user.findFirstOrThrow({ where: { tenantId: R.tenantId }, select: { identityId: true } }); // untouched neighbours: FULL tenant B (via provisioning then converted), not due trial B2 const B = await trialTenant("Lifecycle B", addDaysToKey(today, 3)); await convertTenantToFull(actor, B.tenantId); const customerB = await dbForTenant(B.tenantId).customer.create({ data: { tenantId: B.tenantId, companyName: "ZZ Kunde B", city: "Kiel" } }); const B2 = await trialTenant("Lifecycle B2", addDaysToKey(today, 20)); ok(!(await deleteTrialTenant(R.tenantId, { now: new Date(due.getTime() - HOUR) })).deleted, "vor Fälligkeit: keine Löschung"); const summary = await run(new Date(due.getTime() + HOUR), [R.tenantId, B.tenantId, B2.tenantId]); ok(summary.deleted.length === 1 && summary.deleted[0] === R.tenantId, "fälliger Testmandant gelöscht, nur dieser"); const gone = await prisma.tenant.findUniqueOrThrow({ where: { id: R.tenantId } }); ok(gone.status === "ARCHIVED" && !!gone.trialDeletedAt && gone.deletionDueAt === null, "Mandant archiviert, Löschung protokolliert am Mandanten"); ok((await prisma.customer.count({ where: { tenantId: R.tenantId } })) === 0 && (await prisma.user.count({ where: { tenantId: R.tenantId } })) === 0 && (await prisma.role.count({ where: { tenantId: R.tenantId } })) === 0, "alle Mandanten-Tabellen geleert (Topologie)"); ok((await prisma.identity.findUnique({ where: { id: identityR.identityId } })) === null, "Identity ohne weitere Mitgliedschaft gelöscht"); ok((await readStoredBytes(stored.storageKey)) === null, "Speicherobjekte unter / entfernt"); ok((await prisma.deletionCertificate.count({ where: { tenantId: R.tenantId, scope: "tenant" } })) === 1, "Löschnachweis erstellt"); ok((await prisma.auditLog.count({ where: { scope: "platform", entity: "trial_tenant", entityId: R.tenantId, action: "delete" } })) === 1, "Plattform-Audit der Löschung"); ok((await prisma.customer.findUnique({ where: { id: customerR.id } })) === null, "Kunde des gelöschten Mandanten entfernt"); ok((await dbForTenant(B.tenantId).customer.count({ where: { id: customerB.id } })) === 1 && (await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } })).status === "ACTIVE", "Mandant B (Vollversion) unberührt"); ok((await prisma.tenant.findUniqueOrThrow({ where: { id: B2.tenantId } })).trialDeletedAt === null, "nicht fälliger Testmandant unberührt"); await run(new Date(due.getTime() + 2 * HOUR), [R.tenantId]); ok((await prisma.auditLog.count({ where: { scope: "platform", entity: "trial_tenant", entityId: R.tenantId } })) === 1, "gelöschter Mandant wird nicht erneut verarbeitet"); console.log("\n— Umwandeln, Verlängern, Abbrechen verhindern die Löschung —"); const X = await trialTenant("Lifecycle X", addDaysToKey(today, 1)); await endTrialNow(actor, X.tenantId); const xDue = (await prisma.tenant.findUniqueOrThrow({ where: { id: X.tenantId } })).deletionDueAt!; await convertTenantToFull(actor, X.tenantId); const xRow = await prisma.tenant.findUniqueOrThrow({ where: { id: X.tenantId } }); ok(xRow.plan === "FULL" && !!xRow.convertedAt && xRow.deletionDueAt === null && !(await getTrialState(X.tenantId)).readOnly, "Umwandeln: Vollversion, schreibbar, keine Löschung vorgemerkt"); await run(new Date(xDue.getTime() + DAY), [X.tenantId]); ok(!(await deleteTrialTenant(X.tenantId, { now: new Date(xDue.getTime() + DAY) })).deleted && (await prisma.tenant.findUniqueOrThrow({ where: { id: X.tenantId } })).status === "ACTIVE", "umgewandelter Mandant wird nie gelöscht (Doppelprüfung)"); const Y = await trialTenant("Lifecycle Y", addDaysToKey(today, 1)); await endTrialNow(actor, Y.tenantId); const yOldDue = (await prisma.tenant.findUniqueOrThrow({ where: { id: Y.tenantId } })).deletionDueAt!; await changeTrialEndDate(actor, Y.tenantId, addDaysToKey(today, 60)); const yRow = await prisma.tenant.findUniqueOrThrow({ where: { id: Y.tenantId } }); ok(yRow.trialEndsAt!.getTime() === trialEndInstant(addDaysToKey(today, 60)).getTime() && yRow.deletionDueAt!.getTime() > yOldDue.getTime() && yRow.readOnlySince === null && yRow.trialExpiredNoticeAt === null, "Verlängern: neues Ende, Löschtermin verschoben, Marker zurückgesetzt"); await run(new Date(yOldDue.getTime() + DAY), [Y.tenantId]); ok((await prisma.tenant.findUniqueOrThrow({ where: { id: Y.tenantId } })).trialDeletedAt === null, "verlängerter Mandant nach altem Löschtermin nicht gelöscht"); const Z = await trialTenant("Lifecycle Z", addDaysToKey(today, 1)); await endTrialNow(actor, Z.tenantId); const zDue = (await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } })).deletionDueAt!; await cancelTrialDeletion(actor, Z.tenantId); await run(new Date(zDue.getTime() + 10 * DAY), [Z.tenantId]); const zRow = await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } }); ok(zRow.trialDeletedAt === null && (await getTrialState(Z.tenantId, new Date(zDue.getTime() + 10 * DAY))).readOnly, "Löschung abgebrochen: bleibt erhalten, weiter nur lesbar"); await scheduleTrialDeletion(actor, Z.tenantId); const zScheduled = (await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } })).deletionDueAt!; ok(zScheduled.getTime() >= Date.now() + 7 * DAY - HOUR, "Löschung vormerken: frühestens in 7 Tagen (Hinweis möglich)"); await changeTrialEndDate(actor, Z.tenantId, addDaysToKey(today, 3)); ok((await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } })).deletionDueAt!.getTime() === trialEndInstant(addDaysToKey(today, 3)).getTime() + 30 * DAY, "vorgemerkte Löschung folgt dem neuen Enddatum"); const auditZ = await prisma.auditLog.findMany({ where: { tenantId: Z.tenantId, scope: "platform", entity: { startsWith: "trial_" } }, orderBy: { createdAt: "asc" } }); ok(auditZ.map((a) => a.entity).join(",") === "trial_ended,trial_deletion_cancelled,trial_deletion_scheduled,trial_end_date" && auditZ.every((a) => a.actorId === full.id && a.before && a.after), "Plattform-Audit je Aktion mit before/after"); console.log("\n— Plattform-Rechte —"); const P = await trialTenant("Lifecycle P", addDaysToKey(today, 5)); const tenantAdminActor = { platformAdminId: P.adminUserId }; const endBefore = (await prisma.tenant.findUniqueOrThrow({ where: { id: P.tenantId } })).trialEndsAt!.getTime(); await expectCode(() => changeTrialEndDate(tenantAdminActor, P.tenantId, addDaysToKey(today, 30)), "forbidden", "Mandanten-Admin kann die Testphase nicht verlängern"); await expectCode(() => convertTenantToFull(tenantAdminActor, P.tenantId), "forbidden", "Mandanten-Admin kann nicht umwandeln"); await expectCode(() => cancelTrialDeletion(tenantAdminActor, P.tenantId), "forbidden", "Mandanten-Admin kann die Löschung nicht abbrechen"); await expectCode(() => changeTrialEndDate({ platformAdminId: readonly.id }, P.tenantId, addDaysToKey(today, 30)), "forbidden", "Read-only-Plattform-Admin kann nichts ändern"); await expectCode(() => createPlatformTrialTenant(tenantAdminActor, { companyName: "ZZ L15 Hack", adminName: "Hack", adminEmail: `hack${DOMAIN}`, endDate: addDaysToKey(today, 5), sampleData: false }), "forbidden", "Mandanten-Admin kann keinen Testmandanten anlegen"); await expectCode(() => changeTrialEndDate(actor, P.tenantId, addDaysToKey(today, -1)), "invalid", "Enddatum in der Vergangenheit → invalid"); await expectCode(() => changeTrialEndDate(actor, P.tenantId, addDaysToKey(today, 400)), "invalid", "Enddatum > 1 Jahr → invalid"); await expectCode(() => changeTrialEndDate(actor, X.tenantId, addDaysToKey(today, 10)), "invalid", "Vollversion: keine Testphasen-Aktion"); ok((await prisma.tenant.findUniqueOrThrow({ where: { id: P.tenantId } })).trialEndsAt!.getTime() === endBefore, "Testphase von P unverändert"); const { trialLifecycleAction } = await import("../src/server/actions/trial-platform"); const fd = new FormData(); fd.set("confirm", "on"); fd.set("endDate", addDaysToKey(today, 40)); await expectCode(() => trialLifecycleAction(P.tenantId, "extend", { status: "idle" }, fd), "*", "Action ohne Plattform-Session wird abgewiesen"); ok((await prisma.tenant.findUniqueOrThrow({ where: { id: P.tenantId } })).trialEndsAt!.getTime() === endBefore, "… und ändert nichts"); const platformSrc = readFileSync("src/server/actions/trial-platform.ts", "utf8"); const exportsP = [...platformSrc.matchAll(/export async function (\w+)/g)].map((m) => m[1]); ok(exportsP.length === 2 && exportsP.every((n) => new RegExp(`export async function ${n}[\\s\\S]*?await requirePlatformFullAdmin\\(\\)`).test(platformSrc)), "jede Plattform-Action verlangt einen Plattform-Voll-Admin"); const tenantSrc = readFileSync("src/server/actions/trial-tenant.ts", "utf8"); ok(!/services\/trial\/admin/.test(tenantSrc) && !/trialEndsAt|deletionDueAt|convertedAt/.test(tenantSrc), "Mandanten-Actions enthalten keine Testphasen-Änderung"); console.log("\n— Plattform-Wizard mit Einladung —"); const invites: { to: string; tenantId: string }[] = []; const created = await createPlatformTrialTenant(actor, { companyName: "ZZ L15 Wizard GmbH", sector: "Elektro", adminName: "Wanda Wizard", adminEmail: `wanda${DOMAIN}`, endDate: addDaysToKey(today, 45), sampleData: true }, { invite: async (i) => void invites.push({ to: i.to, tenantId: i.tenantId }) }); const w = await prisma.tenant.findUniqueOrThrow({ where: { id: created.tenantId } }); ok(w.plan === "TRIAL" && w.trialSource === "platform" && w.trialEndsAt!.getTime() === trialEndInstant(addDaysToKey(today, 45)).getTime(), "Wizard: Testmandant mit frei gesetztem Enddatum (> TRIAL_MAX_DAYS)"); ok(created.invited && invites.length === 1 && invites[0].to === `wanda${DOMAIN}` && invites[0].tenantId === w.id, "Einladung über den bestehenden Einladungsweg"); const wId = await prisma.identity.findUniqueOrThrow({ where: { email: `wanda${DOMAIN}` } }); ok(wId.mustChangePassword, "neue Identity: Passwort wird über die Einladung gesetzt"); ok((await prisma.authToken.count({ where: { principalId: wId.id, type: "invitation", usedAt: null } })) === 1, "Einladungs-Token ausgestellt (Hash)"); ok((await dbForTenant(w.id).workOrder.count()) === 4, "Wizard mit Beispieldaten"); const again = await createPlatformTrialTenant(actor, { companyName: "ZZ L15 Wizard Zwei", adminName: "Wanda Wizard", adminEmail: `wanda${DOMAIN}`, endDate: addDaysToKey(today, 10), sampleData: false }, { invite: async (i) => void invites.push({ to: i.to, tenantId: i.tenantId }) }); ok(!again.invited && invites.length === 1 && again.identityId === wId.id, "bestehende Person: nur verknüpft, keine neue Einladung"); ok((await prisma.auditLog.count({ where: { tenantId: w.id, entity: "trial", actorId: full.id } })) === 1, "Audit mit handelndem Plattform-Admin"); console.log("\n— Vorlagen und Worker —"); const sample: { [K in (typeof TRIAL_TEMPLATE_KEYS)[number]]: TemplateVars[K] } = { trial_confirm: { name: "Paula", companyName: "Muster", trialEnd: "30.09.2026", actionUrl: "https://x.example/testen/bestaetigen?token=abc", expires: "16.09.2026, 10:00" }, trial_existing_account: { name: "Paula", loginUrl: "https://x.example/login", resetUrl: "https://x.example/forgot-password" }, trial_reminder: { name: "Paula", tenantName: "Muster", daysLeft: 3, endDate: "30.09.2026", actionUrl: "https://x.example/dashboard", contact: "vertrieb@craftvia.example" }, trial_expired: { name: "Paula", tenantName: "Muster", endDate: "30.09.2026", deletionDate: "30.10.2026", exportUrl: "https://x.example/settings/export" }, trial_deletion_notice: { name: "Paula", tenantName: "Muster", deletionDate: "30.10.2026", exportUrl: "https://x.example/settings/export" }, }; for (const key of TRIAL_TEMPLATE_KEYS) { for (const locale of ["de", "en"] as const) { const r = renderTemplate(key, locale, sample[key] as never); ok(r.subject.length > 5 && !/undefined|\{/.test(r.subject + r.text) && r.html.includes("<"), `Vorlage ${key} (${locale}) rendert`); } } ok(renderTemplate("trial_reminder", "de", { ...sample.trial_reminder, daysLeft: 1 }).subject.includes("morgen") && renderTemplate("trial_reminder", "de", { ...sample.trial_reminder, daysLeft: 0 }).subject.includes("heute"), "Erinnerung: „morgen“/„heute“"); ok(typeof PROCESSORS["trial-lifecycle"] === "function" && typeof PROCESSORS["tenant-export"] === "function", "Processor trial-lifecycle und tenant-export registriert"); ok(readFileSync("src/server/jobs/queues.ts", "utf8").includes('"trial-lifecycle-daily"'), "täglicher Job-Scheduler angelegt"); const ctxP = ctxFor(P.tenantId, P.adminUserId, "tenant-admin"); ok((await ctxP.db.tenantSettings.findFirst())?.orgName === "ZZ L15 Lifecycle P", "Mandanten-Einstellungen provisioniert"); await cleanupL15(); await prisma.$disconnect(); console.log(failures ? `\n${failures} Prüfung(en) fehlgeschlagen` : "\nAlle Prüfungen bestanden"); process.exit(failures ? 1 : 0); } main().catch(async (err) => { console.error(err); await cleanupL15().catch(() => undefined); process.exit(1); });