import { execFileSync } from 'node:child_process'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { AuthUser } from './auth.js'; import { finalizeShow, getCompletionSummary } from './completion.js'; import { closeDatabase, getDatabase } from './database.js'; import { addPieceUsage, addShowSection, createShow, duplicateCompletedShow, getPieceUsageContexts, getShow } from './shows.js'; let directory: string; const actor: AuthUser = { id: 'completion-actor', email: 'completion@example.test', displayName: 'Abschluss Test', role: 'barkeeper', lockedAtUtc: null }; beforeEach(() => { directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-completion-test-')); process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite'); process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads'); execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env }); execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env }); getDatabase() .prepare( `INSERT INTO users (id, email, email_normalized, display_name, password_hash, role, password_changed_at_utc, created_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, 'unused', 'barkeeper', ?, ?, ?)` ) .run( actor.id, actor.email, actor.email, actor.displayName, ...Array(3).fill(new Date().toISOString()) ); }); afterEach(() => { closeDatabase(); delete process.env.SENDEWERK_DB_PATH; delete process.env.SENDEWERK_UPLOAD_DIR; rmSync(directory, { recursive: true, force: true }); }); function endedShow() { const created = createShow({ title: 'Finale Sendung', startsAtUtc: null, templateId: null }); const section = addShowSection(created.id, created.revision, { name: 'Gesendeter Block', categoryId: 'cat-jc', explicitDurationSeconds: null }); const done = addPieceUsage(created.id, section.revision, { pieceId: 'piece-console', sectionId: section.id }); const skipped = addPieceUsage(created.id, done.revision, { pieceId: 'piece-console', sectionId: section.id }); const database = getDatabase(); database.prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(done.id); database.prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?").run(skipped.id); database .prepare( `INSERT INTO show_live_states (show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc) VALUES (?, 0, ?, ?, 0, ?)` ) .run( created.id, '2026-10-25T18:00:00.000Z', '2026-10-25T19:00:00.000Z', '2026-10-25T19:00:00.000Z' ); return { showId: created.id, doneUsageId: done.id, revision: skipped.revision }; } describe('show completion', () => { it('finalizes once, records only sent usages and archives only the explicit selection', () => { const prepared = endedShow(); expect(getCompletionSummary(prepared.showId)).toMatchObject({ doneCount: 1, skippedCount: 1, candidates: [{ pieceId: 'piece-console', usageCount: 1 }] }); const first = finalizeShow({ showId: prepared.showId, commandId: 'complete-once', expectedShowRevision: prepared.revision, expectedLiveRevision: 0, archivePieceIds: ['piece-console'], actor, now: new Date('2026-10-25T19:05:00.000Z') }); expect(first.duplicate).toBe(false); expect( finalizeShow({ showId: prepared.showId, commandId: 'complete-once', expectedShowRevision: prepared.revision, expectedLiveRevision: 0, archivePieceIds: ['piece-console'], actor }).duplicate ).toBe(true); expect(() => finalizeShow({ showId: prepared.showId, commandId: 'different-command', expectedShowRevision: prepared.revision, expectedLiveRevision: 0, archivePieceIds: [], actor }) ).toThrow('SHOW_COMPLETED'); const database = getDatabase(); expect( ( database .prepare('SELECT count(*) AS count FROM piece_usage_history WHERE show_id = ?') .get(prepared.showId) as { count: number } ).count ).toBe(1); expect(database.prepare('SELECT status FROM pieces WHERE id = ?').get('piece-console')).toEqual( { status: 'archived' } ); expect(getPieceUsageContexts(['piece-console'])['piece-console'].sentHistory).toHaveLength(1); }); it('keeps historical snapshots stable and duplicates them into an editable clean run', () => { const prepared = endedShow(); finalizeShow({ showId: prepared.showId, commandId: 'freeze', expectedShowRevision: prepared.revision, expectedLiveRevision: 0, archivePieceIds: [], actor }); const historical = getShow(prepared.showId)!; getDatabase() .prepare("UPDATE section_categories SET name = 'Später geändert' WHERE id = 'cat-jc'") .run(); getDatabase() .prepare("UPDATE pieces SET title = 'Später geändert' WHERE id = 'piece-console'") .run(); const unchanged = getShow(prepared.showId)!; expect(unchanged.sections[0].category).toBe(historical.sections[0].category); expect(unchanged.sections[0].usages[0].title).toBe(historical.sections[0].usages[0].title); const copy = duplicateCompletedShow(prepared.showId, 'copy-once'); expect(duplicateCompletedShow(prepared.showId, 'copy-once').id).toBe(copy.id); expect(getShow(copy.id)).toMatchObject({ status: 'draft', startsAtUtc: null, sections: [ { category: historical.sections[0].category, usages: [{ status: 'open' }, { status: 'open' }] } ] }); expect( getDatabase().prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(copy.id) ).toBeUndefined(); getDatabase() .prepare( `UPDATE piece_usages SET status = 'done' WHERE show_section_id IN (SELECT id FROM show_sections WHERE show_id = ?)` ) .run(copy.id); getDatabase() .prepare( `INSERT INTO show_live_states (show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc) VALUES (?, 0, ?, ?, 0, ?)` ) .run( copy.id, '2026-10-26T18:00:00.000Z', '2026-10-26T19:00:00.000Z', '2026-10-26T19:00:00.000Z' ); finalizeShow({ showId: copy.id, commandId: 'finalize-copy', expectedShowRevision: 1, expectedLiveRevision: 0, archivePieceIds: [], actor }); expect(getShow(copy.id)?.sections[0].category).toBe(historical.sections[0].category); }); });