Add educator email invitations and staff notification preferences
This commit is contained in:
@@ -9,6 +9,10 @@ import {
|
||||
type StaffActor,
|
||||
} from "../src/lib/staff/service";
|
||||
import { dateOnly, getTimeBalance } from "../src/lib/staff/calculations";
|
||||
import { deliverStaffEmails } from "../src/lib/staff/email";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
process.env.AUTH_URL = "http://127.0.0.1:3008";
|
||||
|
||||
// Never run integration tests against a user's application database.
|
||||
const db = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
|
||||
@@ -105,6 +109,195 @@ async function fixture() {
|
||||
staffUser,
|
||||
};
|
||||
}
|
||||
test("E-Mail-Aufträge gehen an alle Admins derselben Kita; Versandfehler bleiben wiederholbar", async () => {
|
||||
const f = await fixture(),
|
||||
foreign = await fixture();
|
||||
const second = await prisma.user.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
email: `${randomUUID()}@staff-test.invalid`,
|
||||
passwordHash: "test-only",
|
||||
firstName: "Zweiter",
|
||||
lastName: "Admin",
|
||||
role: "ADMIN",
|
||||
privacyPolicyAcceptedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const result = await f.run(f.staff, "leave", {
|
||||
...vacation(),
|
||||
note: "VERTRAULICHE_NOTIZ",
|
||||
});
|
||||
const jobs = await prisma.staffEmail.findMany({
|
||||
where: { id: { in: result.emailIds! } },
|
||||
});
|
||||
assert.deepEqual(
|
||||
jobs.map((j) => j.userId).sort(),
|
||||
[f.admin.id, second.id].sort(),
|
||||
);
|
||||
assert.ok(jobs.every((j) => !j.message.includes("VERTRAULICHE_NOTIZ")));
|
||||
assert.equal((await f.run(f.other, "emailRetry")).emailIds?.length, 0);
|
||||
assert.equal(
|
||||
(await foreign.run(foreign.admin, "emailRetry")).emailIds?.length,
|
||||
0,
|
||||
);
|
||||
const failure = await deliverStaffEmails(result.emailIds!, async () => ({
|
||||
success: false,
|
||||
error: "Testausfall",
|
||||
}));
|
||||
assert.equal(failure.failed, 2);
|
||||
assert.equal(
|
||||
await prisma.staffLeave.count({ where: { profileId: f.profile.id } }),
|
||||
1,
|
||||
);
|
||||
const retry = await f.run(f.admin, "emailRetry");
|
||||
const messages: string[] = [];
|
||||
const delivered = await deliverStaffEmails(retry.emailIds!, async (input) => {
|
||||
messages.push(await render(input.react));
|
||||
assert.ok(input.idempotencyKey?.startsWith("staff-email/"));
|
||||
return { success: true, id: "test-only" };
|
||||
});
|
||||
assert.equal(delivered.sent, 2);
|
||||
assert.ok(
|
||||
messages.every(
|
||||
(html) =>
|
||||
html.includes("2026-01-05") && !html.includes("VERTRAULICHE_NOTIZ"),
|
||||
),
|
||||
);
|
||||
assert.equal((await f.run(f.admin, "emailRetry")).emailIds?.length, 0);
|
||||
});
|
||||
|
||||
test("Fachkräfte schalten nur eigene Entscheidungsmails ab; interne Hinweise bleiben erhalten", async () => {
|
||||
const f = await fixture();
|
||||
await f.run(f.staff, "leave", vacation());
|
||||
const leave = await prisma.staffLeave.findFirstOrThrow({
|
||||
where: { profileId: f.profile.id },
|
||||
});
|
||||
const decision = await f.run(f.admin, "leaveReview", {
|
||||
id: leave.id,
|
||||
approve: true,
|
||||
reason: "PRIVATE_BEGRUENDUNG",
|
||||
});
|
||||
assert.equal(decision.emailIds?.length, 1);
|
||||
const queued = await prisma.staffEmail.findUniqueOrThrow({
|
||||
where: { id: decision.emailIds![0] },
|
||||
});
|
||||
assert.equal(queued.userId, f.staff.id);
|
||||
assert.ok(!queued.message.includes("PRIVATE_BEGRUENDUNG"));
|
||||
await f.run(f.staff, "emailPreference", { enabled: false });
|
||||
assert.equal(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: f.other.id } }))
|
||||
.staffDecisionEmails,
|
||||
true,
|
||||
);
|
||||
let calls = 0;
|
||||
await deliverStaffEmails(decision.emailIds!, async () => {
|
||||
calls++;
|
||||
return { success: true };
|
||||
});
|
||||
assert.equal(calls, 0);
|
||||
await f.run(f.staff, "leave", vacation("2026-01-06"));
|
||||
const next = await prisma.staffLeave.findFirstOrThrow({
|
||||
where: { profileId: f.profile.id, startDate: dateOnly("2026-01-06") },
|
||||
});
|
||||
const disabled = await f.run(f.admin, "leaveReview", {
|
||||
id: next.id,
|
||||
approve: false,
|
||||
reason: "Testentscheidung",
|
||||
});
|
||||
assert.equal(disabled.emailIds?.length, 0);
|
||||
assert.ok(
|
||||
(await prisma.staffNotification.count({ where: { userId: f.staff.id } })) >=
|
||||
2,
|
||||
);
|
||||
const sickness = await f.run(f.admin, "leave", {
|
||||
...vacation("2026-01-08"),
|
||||
kind: "SICKNESS",
|
||||
});
|
||||
assert.equal(sickness.emailIds?.length, 0);
|
||||
await assert.rejects(
|
||||
f.run(f.admin, "emailPreference", { enabled: false }),
|
||||
/Fachkräfte/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Arbeitswochen erzeugen getrennte Einreichungs- und Entscheidungsmails ohne private Rückmeldung", async () => {
|
||||
const f = await fixture();
|
||||
const submitted = await f.run(f.staff, "weekSubmit", {
|
||||
weekStart: "2026-01-05",
|
||||
});
|
||||
const adminJob = await prisma.staffEmail.findUniqueOrThrow({
|
||||
where: { id: submitted.emailIds![0] },
|
||||
});
|
||||
assert.equal(adminJob.userId, f.admin.id);
|
||||
assert.ok(adminJob.path.includes("week=2026-01-05"));
|
||||
const reviewed = await f.run(f.admin, "weekReview", {
|
||||
weekStart: "2026-01-05",
|
||||
approve: false,
|
||||
reason: "PRIVATE_RUECKMELDUNG",
|
||||
});
|
||||
const job = await prisma.staffEmail.findUniqueOrThrow({
|
||||
where: { id: reviewed.emailIds![0] },
|
||||
});
|
||||
assert.equal(job.userId, f.staff.id);
|
||||
assert.ok(job.message.includes("zur Korrektur"));
|
||||
assert.ok(!job.message.includes("PRIVATE_RUECKMELDUNG"));
|
||||
});
|
||||
|
||||
test("Erneuerte Aktivierungslinks ersetzen ausstehende alte Mails und funktionieren trotz Opt-out", async () => {
|
||||
const f = await fixture();
|
||||
await prisma.staffProfile.update({
|
||||
where: { id: f.profile.id },
|
||||
data: { userId: null },
|
||||
});
|
||||
const email = `${randomUUID()}@staff-test.invalid`;
|
||||
const old = await f.run(f.admin, "invite", { email });
|
||||
const linked = await prisma.user.findUniqueOrThrow({ where: { email } });
|
||||
await prisma.user.update({
|
||||
where: { id: linked.id },
|
||||
data: { staffDecisionEmails: false },
|
||||
});
|
||||
const renewed = await f.run(f.admin, "invite", { email });
|
||||
const html: string[] = [];
|
||||
const result = await deliverStaffEmails(
|
||||
[...old.emailIds!, ...renewed.emailIds!],
|
||||
async (input) => {
|
||||
assert.equal(input.to, email);
|
||||
html.push(await render(input.react, { plainText: true }));
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
assert.equal(result.sent, 1);
|
||||
assert.ok(html[0].includes(renewed.invite!.token));
|
||||
assert.ok(!html[0].includes(old.invite!.token));
|
||||
assert.ok(
|
||||
html[0].includes("Fachkraft-Account") && html[0].includes("sieben Tage"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Parallele Versandversuche senden einmal und beachten inzwischen entzogene Rollen", async () => {
|
||||
const f = await fixture();
|
||||
const result = await f.run(f.staff, "leave", vacation());
|
||||
let calls = 0;
|
||||
const send = async () => {
|
||||
calls++;
|
||||
return { success: true as const };
|
||||
};
|
||||
await Promise.all([
|
||||
deliverStaffEmails(result.emailIds!, send),
|
||||
deliverStaffEmails(result.emailIds!, send),
|
||||
]);
|
||||
assert.equal(calls, 1);
|
||||
await deliverStaffEmails(result.emailIds!, send);
|
||||
assert.equal(calls, 1);
|
||||
const changed = await f.run(f.staff, "leave", vacation("2026-01-06"));
|
||||
await prisma.user.update({
|
||||
where: { id: f.admin.id },
|
||||
data: { role: "ELTERN" },
|
||||
});
|
||||
await deliverStaffEmails(changed.emailIds!, send);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
const vacation = (date = "2026-01-05") => ({
|
||||
kind: "VACATION",
|
||||
startDate: date,
|
||||
|
||||
Reference in New Issue
Block a user