Files
kalendartool/tests/core.test.ts

265 lines
8.7 KiB
TypeScript

/**
* 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);
});
});