265 lines
8.7 KiB
TypeScript
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);
|
|
});
|
|
}); |