/** * Unit-Tests fuer die Kernlogik (plan.md Abschnitt 6 und 19): * Kollisionspruefung, Invite-Tokens, Validierung, Berechtigungen. * * Bewusst ohne Datenbank: Die Regeln sind reine Funktionen und * damit ohne PostgreSQL testbar (schnelle Unit-Tests). */ import { describe, expect, it } from 'vitest'; import { intervalsOverlap, findConflicts, validateReservationPeriod } from '@/lib/reservations/conflicts'; import { generateInviteToken, hashInviteToken, isInviteUsable, defaultInviteExpiry } from '@/lib/invites/tokens'; import { createReservationSchema } from '@/lib/validation'; import { formatDuration } from '@/lib/reservations/service'; import { canViewCalendar, canManageCalendar, canCreateReservation, canModifyReservation, canDeleteReservation, } from '@/lib/permissions/permissions'; // ---------------------------------------------------------------- Konflikte describe('intervalsOverlap', () => { const base = new Date('2026-09-18T12:00:00Z'); it('erkennnt eine Ueberlappung in der Mitte', () => { expect( intervalsOverlap( new Date('2026-09-18T14:00:00Z'), new Date('2026-09-18T15:00:00Z'), new Date('2026-09-18T14:30:00Z'), new Date('2026-09-18T15:30:00Z'), ), ).toBe(true); }); it('erkennnt vollstaendiges Enthaltensein', () => { expect( intervalsOverlap( new Date('2026-09-18T14:00:00Z'), new Date('2026-09-18T15:00:00Z'), new Date('2026-09-18T14:00:00Z'), new Date('2026-09-18T14:30:00Z'), ), ).toBe(true); }); it('erkennnt 1-Minuten-Ueberschneidung (13:00-14:01 vs 14:00-15:00)', () => { expect( intervalsOverlap( new Date('2026-09-18T14:00:00Z'), new Date('2026-09-18T15:00:00Z'), new Date('2026-09-18T13:00:00Z'), new Date('2026-09-18T14:01:00Z'), ), ).toBe(true); }); it('erlaubt direktes Aneinandergrenzen (14:00-15:00 und 15:00-16:00)', () => { expect( intervalsOverlap( new Date('2026-09-18T14:00:00Z'), new Date('2026-09-18T15:00:00Z'), new Date('2026-09-18T15:00:00Z'), new Date('2026-09-18T16:00:00Z'), ), ).toBe(false); }); }); describe('findConflicts', () => { const existing = [ { id: 'r1', startAt: new Date('2026-09-18T14:00:00Z'), endAt: new Date('2026-09-18T15:00:00Z'), }, { id: 'r2', startAt: new Date('2026-09-18T16:00:00Z'), endAt: new Date('2026-09-18T17:00:00Z'), }, ]; it('findet kollidierende Reservierungen', () => { const conflicts = findConflicts( existing, new Date('2026-09-18T14:30:00Z'), new Date('2026-09-18T15:30:00Z'), ); expect(conflicts).toHaveLength(1); expect(conflicts[0]?.id).toBe('r1'); }); it('ignoriert die eigene Reservierung beim Bearbeiten', () => { const conflicts = findConflicts( existing, new Date('2026-09-18T14:30:00Z'), new Date('2026-09-18T15:30:00Z'), 'r1', ); expect(conflicts).toHaveLength(0); }); }); describe('validateReservationPeriod', () => { it('akzeptiert einen gueltigen zukuenftigen Zeitraum', () => { const now = new Date('2026-09-18T12:00:00Z'); expect(() => validateReservationPeriod( new Date('2026-09-18T14:00:00Z'), new Date('2026-09-18T15:00:00Z'), now, ), ).not.toThrow(); }); it('lehnt Start >= Ende ab', () => { expect(() => validateReservationPeriod( new Date('2026-09-18T15:00:00Z'), new Date('2026-09-18T15:00:00Z'), ), ).toThrow(/Startzeit muss vor der Endzeit/); }); it('lehnt Vergangenheit ab', () => { const now = new Date('2026-09-18T12:00:00Z'); expect(() => validateReservationPeriod( new Date('2026-09-18T10:00:00Z'), new Date('2026-09-18T11:00:00Z'), now, ), ).toThrow(/Vergangenheit/); }); }); // ------------------------------------------------------------------- Invites describe('Invite-Tokens', () => { it('erzeugt nicht erratbare Tokens', () => { const token = generateInviteToken(); expect(token).toHaveLength(43); // 32 Bytes base64url expect(token).not.toContain('='); }); it('hasht deterministisch und speichert nie den Klartext', () => { const token = generateInviteToken(); const hash = hashInviteToken(token); expect(hash).toHaveLength(64); expect(hash).not.toBe(token); expect(hashInviteToken(token)).toBe(hash); }); it('erkennt abgelaufene und verwendete Invites', () => { const now = new Date('2026-09-18T12:00:00Z'); expect( isInviteUsable({ expiresAt: new Date('2026-09-01T00:00:00Z'), usedAt: null }, now), ).toBe(false); expect( isInviteUsable({ expiresAt: null, usedAt: new Date('2026-09-01T00:00:00Z') }, now), ).toBe(false); expect( isInviteUsable({ expiresAt: new Date('2026-09-30T00:00:00Z'), usedAt: null }, now), ).toBe(true); }); it('setzt die Standard-Ablaufzeit auf 7 Tage', () => { const now = new Date('2026-09-18T12:00:00Z'); const expiry = defaultInviteExpiry(now); expect(expiry.getTime() - now.getTime()).toBe(7 * 24 * 60 * 60 * 1000); }); }); // ------------------------------------------------------------------ Validierung describe('createReservationSchema', () => { it('akzeptiert gueltige Eingaben (Start- und Endzeit)', () => { const parsed = createReservationSchema.parse({ calendarId: 'c1', startAt: '2026-09-18T14:00:00Z', endAt: '2026-09-18T15:00:00Z', title: 'Projektmeeting', }); expect(parsed.endAt).toEqual(new Date('2026-09-18T15:00:00Z')); }); it('lehnt fehlende Endzeit ab', () => { expect(() => createReservationSchema.parse({ calendarId: 'c1', startAt: '2026-09-18T14:00:00Z', title: 'X', }), ).toThrow(); }); }); describe('formatDuration', () => { it('formatiert Minuten unter 60 als Minuten', () => { expect(formatDuration(45)).toBe('45 Minuten'); }); it('formatiert ganze Stunden', () => { expect(formatDuration(240)).toBe('4 Stunden'); expect(formatDuration(60)).toBe('1 Stunde'); }); it('formatiert halbe Stunden mit Komma', () => { expect(formatDuration(90)).toBe('1,5 Stunden'); }); }); // ------------------------------------------------------------------ Berechtigungen describe('Berechtigungsschicht (plan.md Abschnitt 2 und 20)', () => { const admin = { id: 'admin-1', email: 'admin@test.local', username: null, role: 'ADMIN' as const }; const user = { id: 'user-1', email: 'user@test.local', username: null, role: 'USER' as const }; const ownCalendar = { id: 'cal-1', ownerId: 'admin-1', isActive: true }; const foreignCalendar = { id: 'cal-2', ownerId: 'admin-2', isActive: true }; const reservation = { id: 'res-1', calendarId: 'cal-1', userId: 'user-1' }; it('Admin sieht alle Kalender, User nur seinen einen', () => { expect(canViewCalendar(admin, ownCalendar, false)).toBe(true); expect(canViewCalendar(admin, foreignCalendar, false)).toBe(true); expect(canViewCalendar(user, ownCalendar, true)).toBe(true); expect(canViewCalendar(user, ownCalendar, false)).toBe(false); expect(canViewCalendar(user, foreignCalendar, false)).toBe(false); }); it('Admins verwalten alle Kalender, User keinen', () => { expect(canManageCalendar(admin, ownCalendar)).toBe(true); expect(canManageCalendar(admin, foreignCalendar)).toBe(true); expect(canManageCalendar(user, ownCalendar)).toBe(false); }); it('User kann in seinem Kalender reservieren, Admin in allen aktiven', () => { expect(canCreateReservation(user, ownCalendar, true)).toBe(true); expect(canCreateReservation(user, foreignCalendar, false)).toBe(false); expect(canCreateReservation(admin, ownCalendar, false)).toBe(true); expect(canCreateReservation(admin, foreignCalendar, false)).toBe(true); }); it('deaktivierte Kalender erlauben keine neuen Reservierungen', () => { const inactive = { ...ownCalendar, isActive: false }; expect(canCreateReservation(user, inactive, true)).toBe(false); }); it('User aendert nur eigene Reservierungen', () => { const foreignReservation = { ...reservation, userId: 'user-2' }; expect(canModifyReservation(user, reservation, ownCalendar, true)).toBe(true); expect(canModifyReservation(user, foreignReservation, ownCalendar, true)).toBe(false); }); it('Admin verwaltet alle Reservierungen in allen Kalendern', () => { expect(canModifyReservation(admin, reservation, ownCalendar, false)).toBe(true); expect(canModifyReservation(admin, reservation, foreignCalendar, false)).toBe(true); }); it('NUR Admins duerfen Reservierungen loeschen', () => { expect(canDeleteReservation(admin, reservation, ownCalendar)).toBe(true); expect(canDeleteReservation(user, reservation, ownCalendar)).toBe(false); }); });