Files
kita-planer/tests/calendar-actions.integration.test.cjs
T

194 lines
12 KiB
JavaScript

/* eslint-disable @typescript-eslint/no-require-imports -- Test real actions with isolated request and mail boundaries. */
const { test, before, beforeEach, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
const { PrismaClient } = require('@prisma/client');
const { randomUUID } = require('node:crypto');
let prisma, current, fixture, edit, swap, accept;
const tenants = [], emails = [];
function loader(overrides = {}) {
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = { exports: {} }; cache.set(file, mod);
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
fileName: file,
}).outputText;
const req = spec => {
if (Object.hasOwn(overrides, spec)) return overrides[spec];
if (spec === 'server-only') return {};
if (spec === '@/lib/prisma') return { prisma };
if (spec === 'next/headers') return { headers: async () => new Headers() };
if (spec === 'next/cache') return { revalidatePath() {} };
if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } };
if (spec.startsWith('@/') || spec.startsWith('.')) {
const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec);
for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix);
}
return require(spec);
};
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports);
return mod.exports;
}
return load;
}
before(async () => {
const raw = process.env.TEST_DATABASE_URL;
if (!raw) throw new Error('TEST_DATABASE_URL muss ausdrücklich auf die lokale kita_quickwins_test zeigen.');
const url = new URL(raw);
assert.ok(['127.0.0.1','localhost'].includes(url.hostname));
assert.equal(url.pathname,'/kita_quickwins_test');
prisma = new PrismaClient({ datasourceUrl: raw });
const load = loader({
'@/lib/auth-utils': {
requireKitaSession: async () => ({ user: current }),
requireRole: async roles => { assert.ok(roles.includes(current.role)); return { user: current }; },
},
'@/lib/mail': { sendAppEmail: async input => { emails.push(input); return { success: true }; } },
});
({ updateMitbringsel: edit, createNotdienstSwapRequest: swap, acceptNotdienstSwapRequest: accept } = load('src/app/dashboard/kalender/actions.ts'));
});
after(async () => {
if (!prisma) return;
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
await prisma.$disconnect();
});
beforeEach(async () => {
emails.length = 0;
const kita = await prisma.kita.create({ data: { name: 'Kalender-Test', slug: `calendar-test-${randomUUID()}` } }); tenants.push(kita.id);
const family = await prisma.family.create({ data: { kitaId: kita.id, name: 'Eigener Haushalt' } });
const targetFamily = await prisma.family.create({ data: { kitaId: kita.id, name: 'Angefragter Haushalt' } });
const makeUser = (role, familyId) => prisma.user.create({ data: { kitaId: kita.id, familyId, role, email: `${randomUUID()}@example.invalid`, firstName: 'Test', lastName: role, passwordHash: 'test', emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
const owner = await makeUser('ELTERN',family.id);
const targetAdmin = await makeUser('ADMIN',targetFamily.id);
const targetCoord = await makeUser('KOORDINATOR',targetFamily.id);
const event = await prisma.termin.create({ data: { kitaId: kita.id, title: 'Sommerfest', type: 'KITA_FEST', status: 'CONFIRMED', mitbringselListEnabled: true, startDate: new Date('2030-11-04T10:00:00Z'), endDate: new Date('2030-11-04T16:00:00Z') } });
const item = await prisma.mitbringselItem.create({ data: { kitaId: kita.id, terminId: event.id, userId: owner.id, content: 'Nudelsalat' } });
const child = await prisma.child.create({ data: { kitaId: kita.id, familyId: family.id, firstName: 'Eigenes Kind', lastName: 'Test' } });
const targetChild = await prisma.child.create({ data: { kitaId: kita.id, familyId: targetFamily.id, firstName: 'Anderes Kind', lastName: 'Test' } });
const plan = await prisma.notdienstPlan.create({ data: { kitaId: kita.id, year: 2030, month: 11, status: 'PUBLISHED' } });
const offered = await prisma.notdienstAssignment.create({ data: { kitaId: kita.id, planId: plan.id, childId: child.id, date: new Date('2030-11-04T00:00:00Z') } });
const target = await prisma.notdienstAssignment.create({ data: { kitaId: kita.id, planId: plan.id, childId: targetChild.id, date: new Date('2030-11-05T00:00:00Z') } });
fixture = { kita, family, targetFamily, owner, targetAdmin, targetCoord, event, item, child, targetChild, plan, offered, target };
current = owner;
});
const edited = content => edit({ itemId: fixture.item.id, content });
const swapPayload = () => ({ targetAssignmentId: fixture.target.id, offeredAssignmentId: fixture.offered.id, message: 'Kannst du tauschen?' });
const storedItem = () => prisma.mitbringselItem.findUniqueOrThrow({ where: { id: fixture.item.id } });
async function setRole(role) { current = await prisma.user.update({ where: { id: fixture.owner.id }, data: { role } }); }
test('Eigener Beitrag wird direkt bearbeitet und behält Autor, Termin und ID', async () => {
assert.equal((await edited(' Kartoffelsalat ')).success,true);
const item = await storedItem();
assert.equal(item.content,'Kartoffelsalat');
assert.equal(item.userId,fixture.owner.id);
assert.equal(item.terminId,fixture.event.id);
assert.equal(await prisma.mitbringselItem.count({ where: { terminId: fixture.event.id } }),1);
});
for (const [label,content] of [['leer',''],['Leerzeichen',' '],['zu lang','a'.repeat(501)]]) {
test(`Ungültiger Beitrag (${label}) erhält den alten Inhalt`, async () => {
assert.ok((await edited(content)).error);
assert.equal((await storedItem()).content,'Nudelsalat');
});
}
test('500 Zeichen werden vollständig gespeichert', async () => {
assert.equal((await edited('a'.repeat(500))).success,true);
assert.equal((await storedItem()).content.length,500);
});
for (const role of ['ELTERN','ADMIN','KOORDINATOR']) {
test(`${role} kann fremde Mitbringbeiträge nicht bearbeiten`, async () => {
current = { ...fixture.targetAdmin, role };
assert.match((await edited('Fremde Änderung')).error,/nur eigene/);
assert.equal((await storedItem()).content,'Nudelsalat');
});
}
for (const role of ['ADMIN','KOORDINATOR']) {
test(`${role} kann einen eigenen Mitbringbeitrag bearbeiten`, async () => {
await setRole(role);
assert.equal((await edited('Gemüsesalat')).success,true);
});
}
test('Mitbringbeiträge einer anderen Kita sind nicht bearbeitbar', async () => {
const kita = await prisma.kita.create({ data: { name: 'Andere Kita', slug: randomUUID() } }); tenants.push(kita.id);
current = { ...fixture.owner, kitaId: kita.id };
assert.ok((await edited('Tenant-Wechsel')).error);
assert.equal((await storedItem()).content,'Nudelsalat');
});
for (const status of ['PENDING','REJECTED']) {
test(`Ein ${status}-Termin erlaubt keine Bearbeitung`, async () => {
await prisma.termin.update({ where: { id: fixture.event.id }, data: { status } });
assert.match((await edited('Brot')).error,/nicht mehr aktiv/);
assert.equal((await storedItem()).content,'Nudelsalat');
});
}
test('Deaktivierte Mitbringliste schützt vorhandene Beiträge', async () => {
await prisma.termin.update({ where: { id: fixture.event.id }, data: { mitbringselListEnabled: false } });
assert.ok((await edited('Brot')).error);
assert.equal((await storedItem()).content,'Nudelsalat');
});
test('Gelöschter Beitrag wird nicht neu angelegt', async () => {
await prisma.mitbringselItem.delete({ where: { id: fixture.item.id } });
assert.ok((await edited('Brot')).error);
assert.equal(await prisma.mitbringselItem.count({ where: { id: fixture.item.id } }),0);
});
for (const role of ['ELTERN','ADMIN','KOORDINATOR']) {
test(`${role} fragt mit eigenem Haushalt einen Diensttausch an und benachrichtigt Eltern mit Verwaltungsrolle`, async () => {
await setRole(role);
assert.equal((await swap(swapPayload())).success,true);
const request = await prisma.notdienstSwapRequest.findFirstOrThrow({ where: { kitaId: fixture.kita.id } });
assert.equal(request.requesterUserId,current.id);
assert.equal(request.requesterFamilyId,fixture.family.id);
assert.deepEqual(emails.map(e => e.to).sort(),[fixture.targetAdmin.email,fixture.targetCoord.email].sort());
current = fixture.targetCoord;
assert.equal((await accept(request.id)).success,true);
assert.equal((await prisma.notdienstAssignment.findUniqueOrThrow({ where: { id: fixture.target.id } })).childId,fixture.child.id);
assert.equal((await prisma.notdienstAssignment.findUniqueOrThrow({ where: { id: fixture.offered.id } })).childId,fixture.targetChild.id);
assert.ok(emails.some(e => e.to === fixture.owner.email && e.subject === 'Notdienst-Tausch bestätigt'));
});
}
for (const role of ['ADMIN','KOORDINATOR']) {
test(`${role} ohne eigenen Haushalt erhält kein Tauschrecht`, async () => {
await setRole(role); current = { ...current, familyId: null };
assert.ok((await swap(swapPayload())).error);
assert.equal(await prisma.notdienstSwapRequest.count({ where: { kitaId: fixture.kita.id } }),0);
});
}
test('Erzieherrolle erhält auch mit einem Haushalt kein Tauschrecht', async () => {
await setRole('ERZIEHER');
assert.ok((await swap(swapPayload())).error);
});
test('Admin kann keine fremde Einteilung als eigenes Angebot verwenden', async () => {
await setRole('ADMIN');
assert.match((await swap({ targetAssignmentId: fixture.offered.id, offeredAssignmentId: fixture.target.id })).error,/nur eigene/);
assert.equal(await prisma.notdienstSwapRequest.count({ where: { kitaId: fixture.kita.id } }),0);
});
test('Koordination kann keinen Tausch innerhalb des eigenen Haushalts starten', async () => {
await setRole('KOORDINATOR');
await prisma.notdienstAssignment.update({ where: { id: fixture.target.id }, data: { childId: fixture.child.id } });
assert.match((await swap(swapPayload())).error,/eigenen Haushalt/);
});
test('Aktiver Notdienstalarm blockiert auch einen Admin-Tausch', async () => {
await setRole('ADMIN');
await prisma.notdienstAlarm.create({ data: { kitaId: fixture.kita.id, assignmentId: fixture.offered.id, activeAssignmentId: fixture.offered.id, familyId: fixture.family.id, familyName: fixture.family.name, childName: 'Eigenes Kind', date: fixture.offered.date, expiresAt: new Date('2030-11-04T18:00:00Z'), deadline: new Date('2030-11-04T10:00:00Z') } });
assert.match((await swap(swapPayload())).error,/aktiver Alarmierung/);
});
test('Unverifizierte und deaktivierte Konten bleiben vom Tausch-Mailversand ausgeschlossen', async () => {
await prisma.user.update({ where: { id: fixture.targetAdmin.id }, data: { emailVerifiedAt: null } });
await prisma.user.update({ where: { id: fixture.targetCoord.id }, data: { disabledAt: new Date() } });
await setRole('ADMIN');
assert.equal((await swap(swapPayload())).success,true);
assert.equal(emails.length,0);
});
test('Doppelte offene Anfrage bleibt auch für Koordination gesperrt', async () => {
await setRole('KOORDINATOR');
assert.equal((await swap(swapPayload())).success,true);
assert.match((await swap(swapPayload())).error,/offene Tauschanfrage/);
assert.equal(await prisma.notdienstSwapRequest.count({ where: { kitaId: fixture.kita.id } }),1);
});