208 lines
16 KiB
TypeScript
208 lines
16 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { Pool } from 'pg';
|
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
import Fastify from 'fastify';
|
|
import { normalizeSpan, evaluate, range, transition, overlaps } from '../src/modules/time-v2/domain';
|
|
import { TimeService, authorize } from '../src/modules/time-v2/service';
|
|
import { TimeMigration, previewLegacy } from '../src/modules/time-v2/migration';
|
|
import { legacySpans, installTimeCompatibility, legacyEvent } from '../src/modules/time-v2/compatibility';
|
|
import timeV2Routes from '../src/routes/staff/time-v2';
|
|
import { timeSpanInput } from '../../frontend/utils/timeSpanInput';
|
|
import { staffTimeRows, workingMinutes } from '../../frontend/utils/staffTimeSpans';
|
|
const at = (day: string, time: string) => `${day}T${time}:00Z`;
|
|
const work = (start = '2026-10-01T06:00:00Z', end: string | null = '2026-10-01T14:30:00Z') => normalizeSpan({ type: 'work', started_at: start, ended_at: end,
|
|
breaks: end ? [{ started_at: '2026-10-01T10:00:00Z', ended_at: '2026-10-01T10:30:00Z' }] : [] });
|
|
const profile = { weekly_regular_working_hours: { 1: 8, 2: 8, 3: 8, 4: 8, 5: 8 }, recreation_days_compensation: true };
|
|
test('attendance subtracts breaks and clips both at month boundaries', () => {
|
|
const s = work();
|
|
s.status = 'approved';
|
|
const summary = evaluate([s], profile, [], '2026-10-01', '2026-10-01');
|
|
assert.equal(summary.presenceMinutes, 510);
|
|
assert.equal(summary.breakMinutes, 30);
|
|
assert.equal(summary.sumWorkingMinutesApproved, 480);
|
|
assert.equal(summary.saldoApproved, 0);
|
|
const night = normalizeSpan({ type: 'work', started_at: '2026-09-30T21:00:00Z', ended_at: '2026-10-01T02:00:00Z', breaks: [{ started_at: '2026-09-30T21:45:00Z', ended_at: '2026-09-30T22:15:00Z' }] });
|
|
night.status = 'approved';
|
|
assert.equal(evaluate([night], profile, [], '2026-10-01', '2026-10-01').sumWorkingMinutesApproved, 225);
|
|
});
|
|
test('DST uses elapsed instants; all-day absence uses working days and avoids holiday double credit', () => {
|
|
const dst = normalizeSpan({ type: 'work', started_at: '2026-10-25T00:00:00Z', ended_at: '2026-10-25T03:00:00Z' });
|
|
dst.status = 'approved';
|
|
assert.equal(evaluate([dst], profile, [], '2026-10-25', '2026-10-25').sumWorkingMinutesApproved, 180);
|
|
const vacation = normalizeSpan({ type: 'vacation', date_from: '2026-10-01', date_to: '2026-10-06' });
|
|
vacation.status = 'approved';
|
|
const r = evaluate([vacation], profile, ['2026-10-02', '2026-10-02'], '2026-10-01', '2026-10-05');
|
|
assert.equal(r.sumVacationDays, 2);
|
|
assert.equal(r.sumWorkingMinutesVacationDays, 960);
|
|
assert.equal(r.sumWorkingMinutesRecreationDays, 480);
|
|
assert.equal(r.saldoApproved, 0);
|
|
const comp = normalizeSpan({ type: 'overtime_compensation', date_from: '2026-10-01', date_to: '2026-10-02' });
|
|
comp.status = 'approved';
|
|
assert.equal(evaluate([comp], profile, [], '2026-10-01', '2026-10-01').saldoApproved, -480);
|
|
assert.throws(() => range('2026-02-30', '2026-03-01'));
|
|
});
|
|
test('reject invalid intervals, pause collisions and projects inside breaks; allow adjacent attendances', () => {
|
|
assert.throws(() => normalizeSpan({ type: 'work', started_at: at('2026-10-01', '08:00'), ended_at: at('2026-10-01', '07:00') }));
|
|
const s = work();
|
|
assert.throws(() => normalizeSpan({ ...s, breaks: [...s.breaks, { started_at: '2026-10-01T10:15:00Z', ended_at: '2026-10-01T11:00:00Z' }] }));
|
|
assert.throws(() => normalizeSpan({ ...s, projects: [{ project_id: 1, started_at: '2026-10-01T09:00:00Z', ended_at: '2026-10-01T11:00:00Z' }] }));
|
|
assert.equal(overlaps(s, { started_at: s.ended_at!, ended_at: null }), false);
|
|
assert.throws(() => transition(work(undefined, null), 'submit'));
|
|
assert.throws(() => transition(s, 'approve'));
|
|
s.status = 'submitted';
|
|
assert.throws(() => transition(s, 'reject', ''));
|
|
assert.equal(transition(s, 'reject', 'Korrektur'), 'rejected');
|
|
});
|
|
test('migration preserves the overtime type and flags missing boundaries, mixed approvals and duplicate models', () => {
|
|
const user = randomUUID();
|
|
const events = [{ id: 'a', eventtype: 'overtime_compensation_start', eventtime: at('2026-10-01', '06:00') }, { id: 'b', eventtype: 'overtime_compensation_end', eventtime: at('2026-10-01', '14:00') }];
|
|
let p = previewLegacy(events, [], []);
|
|
assert.equal(p.issues.length, 0);
|
|
assert.equal(p.candidates[0].span.type, 'overtime_compensation');
|
|
p = previewLegacy(events.slice(0, 1), [], []);
|
|
assert.match(p.issues.join(' '), /Offener/);
|
|
p = previewLegacy([...events, { id: 'c', eventtype: 'approved', related_event_id: 'a', created_at: at('2026-10-02', '08:00') }], [], []);
|
|
assert.match(p.issues.join(' '), /Uneinheitliche/);
|
|
p = previewLegacy(events, [{ id: 'd', user_id: user, type: 'work', started_at: events[0].eventtime, stopped_at: events[1].eventtime, state: 'draft' }], []);
|
|
assert.match(p.issues.join(' '), /Überlappende/);
|
|
});
|
|
test('own access and role separation', () => {
|
|
const a = { tenant: 1, user: 'a' };
|
|
assert.doesNotThrow(() => authorize(a, 'a', 'write'));
|
|
assert.throws(() => authorize(a, 'b', 'read'));
|
|
assert.throws(() => authorize(a, 'a', 'approve'));
|
|
assert.doesNotThrow(() => authorize({ ...a, permissions: ['staff.time.approve'] }, 'b', 'read'));
|
|
assert.throws(() => authorize({ ...a, permissions: ['staff.time.read_all'] }, 'b', 'write'));
|
|
});
|
|
const databaseUrl = process.env.TIME_V2_TEST_DATABASE_URL;
|
|
async function fixture() {
|
|
if (!databaseUrl || new URL(databaseUrl).pathname !== '/time_v2_test')
|
|
throw new Error('Use an explicitly designated /time_v2_test database.');
|
|
const schema = `time_v2_${randomUUID().replaceAll('-', '')}`;
|
|
const admin = new Pool({ connectionString: databaseUrl, max: 1 });
|
|
await admin.query(`CREATE SCHEMA ${schema}`);
|
|
const pool = new Pool({ connectionString: databaseUrl, options: `-c search_path=${schema}`, max: 8 });
|
|
const db = drizzle(pool);
|
|
try {
|
|
await pool.query(`CREATE TABLE tenants(id bigint PRIMARY KEY);
|
|
CREATE TABLE auth_users(id uuid PRIMARY KEY);
|
|
CREATE TABLE auth_profiles(user_id uuid,tenant_id bigint,full_name text,weekly_regular_working_hours jsonb,state_code text,recreation_days_compensation boolean);
|
|
CREATE TABLE projects(id bigint PRIMARY KEY,tenant bigint,name text,archived boolean DEFAULT false);
|
|
CREATE TABLE holidays(date date,state_code text);
|
|
CREATE TABLE staff_time_events(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id bigint,user_id uuid,event_type text,event_time timestamptz,created_at timestamptz DEFAULT now(),invalidates_event_id uuid,related_event_id uuid,metadata jsonb);
|
|
CREATE TABLE staff_time_entries(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id bigint,user_id uuid,type text,started_at timestamptz,stopped_at timestamptz,state text,description text,archived boolean DEFAULT false);
|
|
CREATE TABLE staff_time_entry_connects(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),time_entry_id uuid,project_id bigint,started_at timestamptz,stopped_at timestamptz,notes text);`);
|
|
await pool.query(await readFile(new URL('../db/migrations/0080_staff_time_spans.sql', import.meta.url), 'utf8'));
|
|
const user = randomUUID(), other = randomUUID(), outsider = randomUUID();
|
|
await pool.query('INSERT INTO tenants VALUES (1),(2)');
|
|
await pool.query('INSERT INTO auth_users VALUES ($1),($2),($3)', [user, other, outsider]);
|
|
await pool.query(`INSERT INTO auth_profiles VALUES ($1,1,'Anna',$4,'DE-NI',true),($2,1,'Ben',$4,'DE-NI',true),($3,2,'Outside',$4,'DE-NI',true)`, [user, other, outsider, JSON.stringify(profile.weekly_regular_working_hours)]);
|
|
await pool.query("INSERT INTO projects VALUES (1,1,'Project',false),(2,2,'Other',false)");
|
|
const service = new TimeService(db), migration = new TimeMigration(service), actor = { tenant: 1, user, admin: true };
|
|
return { pool, db, user, other, outsider, service, migration, actor, close: async () => { await pool.end(); await admin.query(`DROP SCHEMA ${schema} CASCADE`); await admin.end(); } };
|
|
}
|
|
catch (e) {
|
|
await pool.end();
|
|
await admin.query(`DROP SCHEMA ${schema} CASCADE`);
|
|
await admin.end();
|
|
throw e;
|
|
}
|
|
}
|
|
test('PostgreSQL: atomic adoption, idempotence, concurrency, workflow and tenant isolation', { skip: !databaseUrl }, async () => {
|
|
const f = await fixture();
|
|
const { service: s, migration: m, actor: a, user, pool } = f;
|
|
try {
|
|
await pool.query(`INSERT INTO staff_time_events(tenant_id,user_id,event_type,event_time) VALUES (1,$1,'work_start','2026-09-01T06:00:00Z'),(1,$1,'pause_start','2026-09-01T10:00:00Z'),(1,$1,'pause_end','2026-09-01T10:30:00Z'),(1,$1,'work_end','2026-09-01T14:30:00Z')`, [user]);
|
|
const preview = await m.preview(a, user);
|
|
assert.equal(preview.count, 1);
|
|
assert.equal(preview.totalMinutes, 480);
|
|
await assert.rejects(m.activate(a, user, 'stale'), /Vorschau/);
|
|
await m.activate(a, user, preview.fingerprint);
|
|
assert.equal((await s.list(a, user)).length, 1);
|
|
assert.equal((await m.activate(a, user, preview.fingerprint)).alreadyActivated, true);
|
|
await assert.rejects(pool.query(`INSERT INTO staff_time_events(tenant_id,user_id,event_type,event_time) VALUES(1,$1,'work_start',now())`, [user]), /2.0/);
|
|
const key = randomUUID(), cmd = { action: 'start', at: '2026-10-01T06:00:00Z' };
|
|
const [one, two] = await Promise.all([s.mutate(a, user, key, cmd), s.mutate(a, user, key, cmd)]);
|
|
assert.equal(one.id, two.id);
|
|
await assert.rejects(s.mutate(a, user, randomUUID(), { action: 'create', id: one.id, version: one.version, data: work() }), /bestehende ID/);
|
|
const concurrent = await Promise.allSettled([s.mutate(a, user, randomUUID(), cmd), s.mutate(a, user, randomUUID(), cmd)]);
|
|
assert.equal(concurrent.filter(r => r.status === 'fulfilled').length, 0);
|
|
await assert.rejects(s.mutate(a, user, key, { ...cmd, at: '2026-10-01T07:00:00Z' }), /Anfrage-ID/);
|
|
await assert.rejects(s.mutate(a, user, randomUUID(), cmd), /überschneidet/);
|
|
let item = await s.mutate(a, user, randomUUID(), { action: 'pause', id: one.id, version: one.version, at: '2026-10-01T10:00:00Z' });
|
|
await assert.rejects(s.mutate(a, user, randomUUID(), { action: 'stop', id: one.id, version: one.version, at: '2026-10-01T10:30:00Z' }), /zwischenzeitlich/);
|
|
item = await s.mutate(a, user, randomUUID(), { action: 'stop', id: item.id, version: item.version, at: '2026-10-01T10:30:00Z' });
|
|
assert.equal(item.breaks[0].ended_at, item.ended_at);
|
|
assert.equal((await s.evaluation(a, user, '2026-10-01', '2026-10-01')).summary.presenceMinutes, 270);
|
|
await assert.rejects(s.mutate({ ...a, admin: false }, user, randomUUID(), { action: 'approve', id: item.id, version: item.version }), /Berechtigung/);
|
|
item = await s.mutate(a, user, randomUUID(), { action: 'submit', id: item.id, version: item.version });
|
|
item = await s.mutate(a, user, randomUUID(), { action: 'approve', id: item.id, version: item.version });
|
|
assert.equal((await s.evaluation(a, user, '2026-10-01', '2026-10-01')).summary.sumWorkingMinutesApproved, 240);
|
|
const changed = await s.mutate(a, user, randomUUID(), { action: 'update', id: item.id, version: item.version, reason: 'Notiz ergänzt', data: { ...item, description: 'Neu' } });
|
|
assert.equal(changed.status, 'factual');
|
|
assert.equal((await s.history(a, item.id)).length, 6);
|
|
await assert.rejects(s.get({ ...a, tenant: 2, user: f.outsider }, item.id), /nicht gefunden/);
|
|
await assert.rejects(s.mutate(a, user, randomUUID(), { action: 'update', id: changed.id, version: changed.version, reason: 'Projekt', data: { ...changed, projects: [{ project_id: 2, started_at: '2026-10-01T06:00:00Z', ended_at: '2026-10-01T07:00:00Z' }] } }), /Mandanten/);
|
|
assert.equal((await s.get(a, item.id)).version, changed.version);
|
|
const compat = legacySpans([changed]);
|
|
assert.equal(compat.length, 2);
|
|
assert.equal(compat[0].endedAt, '2026-10-01T10:00:00.000Z');
|
|
}
|
|
finally {
|
|
await f.close();
|
|
}
|
|
});
|
|
test('HTTP routes: validation, permissions, compatibility and migration conflicts', { skip: !databaseUrl }, async () => {
|
|
const f = await fixture(), app = Fastify();
|
|
app.decorate('db', f.db as any);
|
|
app.addHook('preHandler', async (req) => { req.user = { user_id: f.user, tenant_id: 1, email: 'test@example.test', is_admin: req.headers['x-admin'] === 'yes' }; req.permissions = []; });
|
|
await app.register(timeV2Routes, { prefix: '/api' });
|
|
await app.register(async (legacy) => { installTimeCompatibility(legacy); legacy.get('/staff/time/spans', async () => []); legacy.post('/staff/time/event', async () => ({ old: true })); }, { prefix: '/api' });
|
|
try {
|
|
assert.equal((await app.inject('/api/v2/time/spans?user_id=bad')).statusCode, 400);
|
|
assert.equal((await app.inject(`/api/v2/time/spans?user_id=${f.other}&from=2026-10-01&to=2026-10-02`)).statusCode, 403);
|
|
assert.equal((await app.inject('/api/v2/time/migration')).statusCode, 403);
|
|
const preview = (await app.inject({ url: '/api/v2/time/migration', headers: { 'x-admin': 'yes' } })).json();
|
|
assert.equal((await app.inject({ method: 'POST', url: '/api/v2/time/activate', headers: { 'x-admin': 'yes' }, payload: { fingerprint: preview.fingerprint } })).statusCode, 200);
|
|
const body = { eventtype: 'work_start', eventtime: '2026-10-01T06:00:00Z', user_id: f.user };
|
|
const created = await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: body });
|
|
assert.equal(created.statusCode, 200, created.body);
|
|
assert.equal((await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: body })).json().id, created.json().id);
|
|
assert.equal((await app.inject('/api/staff/time/spans?from=2026-09-01&to=2026-09-02')).json().length, 1, 'Running attendance survives a different selected month');
|
|
const stop = { ...body, eventtype: 'work_end', eventtime: '2026-10-01T08:00:00Z' };
|
|
assert.equal((await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: stop })).statusCode, 200);
|
|
assert.equal((await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: stop })).statusCode, 200);
|
|
const spans = (await app.inject('/api/staff/time/spans')).json();
|
|
assert.equal(spans.length, 1);
|
|
assert.equal(spans[0].model, 'spans-v2');
|
|
await f.pool.query(`INSERT INTO staff_time_events(tenant_id,user_id,event_type,event_time) VALUES(1,$1,'work_start',now())`, [f.other]);
|
|
const p = await f.migration.preview(f.actor, f.other);
|
|
assert.ok(p.issues.length);
|
|
await assert.rejects(f.migration.activate(f.actor, f.other, p.fingerprint), /Konflikte/);
|
|
assert.equal(await f.service.active(f.actor, f.other), null);
|
|
}
|
|
finally {
|
|
await app.close();
|
|
await f.close();
|
|
}
|
|
});
|
|
test('editing repeated local times preserves the original instant and rejects ambiguous new inputs', () => {
|
|
assert.equal(timeSpanInput('2026-10-25T02:15', 'Europe/Berlin', '2026-10-25T01:15:00.000Z'), '2026-10-25T01:15:00.000Z');
|
|
assert.equal(timeSpanInput('2026-10-25T02:15', 'Europe/Berlin', '2026-10-25T00:15:00.000Z'), '2026-10-25T00:15:00.000Z');
|
|
assert.throws(() => timeSpanInput('2026-10-25T02:15', 'Europe/Berlin'), /zweimal/);
|
|
assert.throws(() => timeSpanInput('2026-03-29T02:15', 'Europe/Berlin'), /existiert nicht/);
|
|
assert.equal(timeSpanInput('2026-10-25T01:15', 'UTC'), '2026-10-25T01:15:00.000Z');
|
|
});
|
|
|
|
test('dashboard keeps a paused attendance active and freezes its net working duration', () => {
|
|
const s = work(undefined, null); s.id = randomUUID(); s.user_id = randomUUID();
|
|
s.breaks = [{started_at:'2026-10-01T10:00:00Z',ended_at:null}];
|
|
const entries = staffTimeRows(legacySpans([s]),s.user_id,Date.parse('2026-10-01T11:00:00Z'));
|
|
assert.equal(entries.length,1); assert.equal(entries[0].type,'work');
|
|
assert.equal((entries[0] as any).paused,true);assert.equal(entries[0].stopped_at,null);
|
|
assert.equal(workingMinutes(entries[0],Date.parse('2026-10-01T12:00:00Z')),240);
|
|
});
|