insertGetId([ 'title' => 'Way Maker', 'ccli_id' => '7115744', 'created_at' => now(), 'updated_at' => now(), ]); app()->instance(ChurchToolsService::class, new ChurchToolsService( eventFetcher: fn () => [ new FakeEvent( id: 100, title: 'Gottesdienst Sonntag', startDate: '2026-03-08T10:00:00+00:00', note: 'Probe', eventServices: [ new FakeEventService('Predigt', new FakePerson('Max', 'Mustermann')), new FakeEventService('Beamer', new FakePerson('Lisa', 'Technik')), ], ), ], songFetcher: fn () => [new FakeSong(id: 1, title: 'Way Maker', ccli: '7115744')], agendaFetcher: fn () => new FakeAgenda([ new FakeAgendaItem(id: 1, position: '1', title: 'Begrüssung', type: 'Default'), new FakeAgendaItem(id: 2, position: '2', title: 'Way Maker', type: 'Song', song: new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744')), new FakeAgendaItem(id: 3, position: '3', title: 'Unbekannt', type: 'Song', song: new FakeSong(id: 5002, title: 'Unbekannt', ccli: '9999999')), ]), eventServiceFetcher: fn (int $eventId) => [ new FakeEventService('Predigt', new FakePerson('Max', 'Mustermann')), new FakeEventService('Beamer', new FakePerson('Lisa', 'Technik')), ], )); Artisan::call('cts:sync'); expect(Artisan::output())->toContain('Daten wurden aktualisiert'); $service = DB::table('services')->where('cts_event_id', '100')->first(); expect($service)->not->toBeNull(); expect($service->title)->toBe('Gottesdienst Sonntag'); expect($service->preacher_name)->toBe('Max Mustermann'); expect($service->beamer_tech_name)->toBe('Lisa Technik'); $matchedSong = DB::table('service_songs')->where('order', 2)->first(); expect($matchedSong)->not->toBeNull(); expect((int) $matchedSong->song_id)->toBe($localSongId); expect($matchedSong->matched_at)->not->toBeNull(); $unmatchedSong = DB::table('service_songs')->where('order', 3)->first(); expect($unmatchedSong)->not->toBeNull(); expect($unmatchedSong->song_id)->toBeNull(); expect($unmatchedSong->cts_ccli_id)->toBe('9999999'); $syncLog = DB::table('cts_sync_log')->latest('id')->first(); expect($syncLog)->not->toBeNull(); expect($syncLog->status)->toBe('success'); expect((int) $syncLog->events_count)->toBe(1); expect((int) $syncLog->songs_count)->toBe(2); }); test('sync speichert alle agenda items (songs und nicht-songs) in service_agenda_items', function () { Carbon::setTestNow('2026-03-01 09:00:00'); app()->instance(ChurchToolsService::class, new ChurchToolsService( eventFetcher: fn () => [ new FakeEvent(id: 200, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'), ], songFetcher: fn () => [], agendaFetcher: fn () => new FakeAgenda([ new FakeAgendaItem(id: 10, position: '1', title: 'Begrüssung', type: 'Default'), new FakeAgendaItem(id: 11, position: '2', title: 'Lobpreis', type: 'Song', song: new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744')), new FakeAgendaItem(id: 12, position: '3', title: 'Predigt', type: 'Default', note: 'Zum Thema Liebe'), new FakeAgendaItem(id: 13, position: '4', title: 'Abschluss', type: 'Song', song: new FakeSong(id: 5002, title: 'Amazing Grace', ccli: '1234567')), ]), eventServiceFetcher: fn (int $eventId) => [], )); Artisan::call('cts:sync'); $service = DB::table('services')->where('cts_event_id', '200')->first(); $agendaItems = DB::table('service_agenda_items') ->where('service_id', $service->id) ->orderBy('sort_order') ->get(); expect($agendaItems)->toHaveCount(4); expect($agendaItems[0]->title)->toBe('Begrüssung'); expect($agendaItems[0]->type)->toBe('Default'); expect((int) $agendaItems[0]->sort_order)->toBe(1); expect($agendaItems[1]->title)->toBe('Lobpreis'); expect($agendaItems[1]->type)->toBe('Song'); expect((int) $agendaItems[1]->sort_order)->toBe(2); expect($agendaItems[2]->title)->toBe('Predigt'); expect($agendaItems[2]->note)->toBe('Zum Thema Liebe'); expect($agendaItems[3]->title)->toBe('Abschluss'); }); test('song items erstellen service_song UND service_agenda_item mit korrekter service_song_id', function () { Carbon::setTestNow('2026-03-01 09:00:00'); app()->instance(ChurchToolsService::class, new ChurchToolsService( eventFetcher: fn () => [ new FakeEvent(id: 300, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'), ], songFetcher: fn () => [], agendaFetcher: fn () => new FakeAgenda([ new FakeAgendaItem(id: 20, position: '1', title: 'Lobpreis', type: 'Song', song: new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744')), ]), eventServiceFetcher: fn (int $eventId) => [], )); Artisan::call('cts:sync'); $service = DB::table('services')->where('cts_event_id', '300')->first(); $serviceSong = DB::table('service_songs')->where('service_id', $service->id)->first(); expect($serviceSong)->not->toBeNull(); expect($serviceSong->cts_song_name)->toBe('Way Maker'); $agendaItem = DB::table('service_agenda_items')->where('service_id', $service->id)->first(); expect($agendaItem)->not->toBeNull(); expect($agendaItem->title)->toBe('Lobpreis'); expect((int) $agendaItem->service_song_id)->toBe((int) $serviceSong->id); }); test('nicht-song items erstellen keine service_song eintraege', function () { Carbon::setTestNow('2026-03-01 09:00:00'); app()->instance(ChurchToolsService::class, new ChurchToolsService( eventFetcher: fn () => [ new FakeEvent(id: 400, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'), ], songFetcher: fn () => [], agendaFetcher: fn () => new FakeAgenda([ new FakeAgendaItem(id: 30, position: '1', title: 'Begrüssung', type: 'Default'), new FakeAgendaItem(id: 31, position: '2', title: 'Predigt', type: 'Default'), ]), eventServiceFetcher: fn (int $eventId) => [], )); Artisan::call('cts:sync'); $service = DB::table('services')->where('cts_event_id', '400')->first(); $agendaItems = DB::table('service_agenda_items')->where('service_id', $service->id)->get(); expect($agendaItems)->toHaveCount(2); $serviceSongs = DB::table('service_songs')->where('service_id', $service->id)->get(); expect($serviceSongs)->toHaveCount(0); }); test('is_before_event items werden uebersprungen', function () { Carbon::setTestNow('2026-03-01 09:00:00'); app()->instance(ChurchToolsService::class, new ChurchToolsService( eventFetcher: fn () => [ new FakeEvent(id: 500, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'), ], songFetcher: fn () => [], agendaFetcher: fn () => new FakeAgenda([ new FakeAgendaItem(id: 40, position: '0', title: 'Soundcheck', type: 'Default', isBeforeEvent: true), new FakeAgendaItem(id: 41, position: '1', title: 'Begrüssung', type: 'Default'), new FakeAgendaItem(id: 42, position: '2', title: 'Probe', type: 'Default', isBeforeEvent: true), new FakeAgendaItem(id: 43, position: '3', title: 'Lobpreis', type: 'Song', song: new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744')), ]), eventServiceFetcher: fn (int $eventId) => [], )); Artisan::call('cts:sync'); $service = DB::table('services')->where('cts_event_id', '500')->first(); $agendaItems = DB::table('service_agenda_items')->where('service_id', $service->id)->get(); expect($agendaItems)->toHaveCount(2); $titles = $agendaItems->pluck('title')->toArray(); expect($titles)->not->toContain('Soundcheck'); expect($titles)->not->toContain('Probe'); expect($titles)->toContain('Begrüssung'); expect($titles)->toContain('Lobpreis'); }); test('verwaiste agenda items werden bei re-sync entfernt', function () { Carbon::setTestNow('2026-03-01 09:00:00'); // First sync with 3 items app()->instance(ChurchToolsService::class, new ChurchToolsService( eventFetcher: fn () => [ new FakeEvent(id: 600, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'), ], songFetcher: fn () => [], agendaFetcher: fn () => new FakeAgenda([ new FakeAgendaItem(id: 50, position: '1', title: 'Begrüssung', type: 'Default'), new FakeAgendaItem(id: 51, position: '2', title: 'Lobpreis', type: 'Song', song: new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744')), new FakeAgendaItem(id: 52, position: '3', title: 'Predigt', type: 'Default'), ]), eventServiceFetcher: fn (int $eventId) => [], )); Artisan::call('cts:sync'); $service = DB::table('services')->where('cts_event_id', '600')->first(); expect(DB::table('service_agenda_items')->where('service_id', $service->id)->count())->toBe(3); // Re-sync with only 1 item app()->instance(ChurchToolsService::class, new ChurchToolsService( eventFetcher: fn () => [ new FakeEvent(id: 600, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'), ], songFetcher: fn () => [], agendaFetcher: fn () => new FakeAgenda([ new FakeAgendaItem(id: 50, position: '1', title: 'Begrüssung', type: 'Default'), ]), eventServiceFetcher: fn (int $eventId) => [], )); Artisan::call('cts:sync'); $agendaItems = DB::table('service_agenda_items')->where('service_id', $service->id)->get(); expect($agendaItems)->toHaveCount(1); expect($agendaItems[0]->title)->toBe('Begrüssung'); }); test('slides werden erhalten (FK genullt) wenn verwaiste agenda items entfernt werden', function () { Carbon::setTestNow('2026-03-01 09:00:00'); // First sync with 2 items app()->instance(ChurchToolsService::class, new ChurchToolsService( eventFetcher: fn () => [ new FakeEvent(id: 700, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'), ], songFetcher: fn () => [], agendaFetcher: fn () => new FakeAgenda([ new FakeAgendaItem(id: 60, position: '1', title: 'Begrüssung', type: 'Default'), new FakeAgendaItem(id: 61, position: '2', title: 'Predigt', type: 'Default'), ]), eventServiceFetcher: fn (int $eventId) => [], )); Artisan::call('cts:sync'); $service = DB::table('services')->where('cts_event_id', '700')->first(); $agendaItem = DB::table('service_agenda_items') ->where('service_id', $service->id) ->where('sort_order', 2) ->first(); // Create a slide linked to the second agenda item $slideId = DB::table('slides')->insertGetId([ 'service_id' => $service->id, 'service_agenda_item_id' => $agendaItem->id, 'type' => 'sermon', 'original_filename' => 'predigt.jpg', 'stored_filename' => 'predigt_stored.jpg', 'thumbnail_filename' => 'predigt_thumb.jpg', 'uploaded_at' => now(), 'sort_order' => 1, 'created_at' => now(), 'updated_at' => now(), ]); // Re-sync with only 1 item (second item removed) app()->instance(ChurchToolsService::class, new ChurchToolsService( eventFetcher: fn () => [ new FakeEvent(id: 700, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'), ], songFetcher: fn () => [], agendaFetcher: fn () => new FakeAgenda([ new FakeAgendaItem(id: 60, position: '1', title: 'Begrüssung', type: 'Default'), ]), eventServiceFetcher: fn (int $eventId) => [], )); Artisan::call('cts:sync'); // Slide still exists but FK is null $slide = DB::table('slides')->where('id', $slideId)->first(); expect($slide)->not->toBeNull(); expect($slide->service_agenda_item_id)->toBeNull(); // Orphaned agenda item is gone expect(DB::table('service_agenda_items')->where('service_id', $service->id)->count())->toBe(1); }); test('sync summary enthaelt agenda_items_count', function () { Carbon::setTestNow('2026-03-01 09:00:00'); $service = new ChurchToolsService( eventFetcher: fn () => [ new FakeEvent(id: 800, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'), ], songFetcher: fn () => [], agendaFetcher: fn () => new FakeAgenda([ new FakeAgendaItem(id: 70, position: '1', title: 'Begrüssung', type: 'Default'), new FakeAgendaItem(id: 71, position: '2', title: 'Lobpreis', type: 'Song', song: new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744')), ]), eventServiceFetcher: fn (int $eventId) => [], ); $summary = $service->sync(); expect($summary)->toHaveKey('agenda_items_count'); expect($summary['agenda_items_count'])->toBe(2); expect($summary['songs_count'])->toBe(1); expect($summary['services_count'])->toBe(1); }); test('responsible feld wird als json gespeichert', function () { Carbon::setTestNow('2026-03-01 09:00:00'); app()->instance(ChurchToolsService::class, new ChurchToolsService( eventFetcher: fn () => [ new FakeEvent(id: 900, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'), ], songFetcher: fn () => [], agendaFetcher: fn () => new FakeAgenda([ new FakeAgendaItem( id: 80, position: '1', title: 'Begrüssung', type: 'Default', responsible: ['name' => 'Max Mustermann'], ), ]), eventServiceFetcher: fn (int $eventId) => [], )); Artisan::call('cts:sync'); $service = DB::table('services')->where('cts_event_id', '900')->first(); $agendaItem = DB::table('service_agenda_items')->where('service_id', $service->id)->first(); $responsible = json_decode($agendaItem->responsible, true); expect($responsible)->toBe(['name' => 'Max Mustermann']); }); function ensureSyncTables(): void { if (! Schema::hasTable('services')) { Schema::create('services', function (Blueprint $table) { $table->id(); $table->string('cts_event_id')->unique(); $table->string('title'); $table->date('date')->nullable(); $table->string('preacher_name')->nullable(); $table->string('beamer_tech_name')->nullable(); $table->timestamp('last_synced_at')->nullable(); $table->json('cts_data')->nullable(); $table->timestamps(); }); } if (! Schema::hasTable('songs')) { Schema::create('songs', function (Blueprint $table) { $table->id(); $table->string('title'); $table->string('ccli_id')->nullable()->unique(); $table->string('cts_song_id')->nullable()->index(); $table->timestamps(); }); } if (! Schema::hasTable('service_songs')) { Schema::create('service_songs', function (Blueprint $table) { $table->id(); $table->foreignId('service_id')->constrained('services')->cascadeOnDelete(); $table->foreignId('song_id')->nullable()->constrained('songs')->nullOnDelete(); $table->string('cts_song_name'); $table->string('cts_ccli_id')->nullable(); $table->string('cts_song_id')->nullable(); $table->unsignedInteger('order')->default(0); $table->timestamp('matched_at')->nullable(); $table->timestamps(); $table->unique(['service_id', 'order']); }); } if (! Schema::hasTable('service_agenda_items')) { Schema::create('service_agenda_items', function (Blueprint $table) { $table->id(); $table->foreignId('service_id')->constrained('services')->cascadeOnDelete(); $table->string('cts_agenda_item_id')->nullable()->index(); $table->string('position'); $table->string('title'); $table->string('type'); $table->text('note')->nullable(); $table->string('duration')->nullable(); $table->string('start')->nullable(); $table->boolean('is_before_event')->default(false); $table->json('responsible')->nullable(); $table->foreignId('service_song_id')->nullable()->constrained('service_songs')->nullOnDelete(); $table->unsignedInteger('sort_order'); $table->timestamps(); $table->unique(['service_id', 'sort_order']); }); } if (! Schema::hasTable('slides')) { Schema::create('slides', function (Blueprint $table) { $table->id(); $table->enum('type', ['information', 'moderation', 'sermon']); $table->foreignId('service_id')->nullable()->constrained()->nullOnDelete(); $table->foreignId('service_agenda_item_id')->nullable()->constrained('service_agenda_items')->nullOnDelete(); $table->string('original_filename'); $table->string('stored_filename'); $table->string('thumbnail_filename'); $table->date('expire_date')->nullable(); $table->string('uploader_name')->nullable(); $table->timestamp('uploaded_at'); $table->unsignedInteger('sort_order')->default(0); $table->softDeletes(); $table->timestamps(); }); } if (! Schema::hasTable('cts_sync_log')) { Schema::create('cts_sync_log', function (Blueprint $table) { $table->id(); $table->timestamp('synced_at')->nullable(); $table->unsignedInteger('events_count')->default(0); $table->unsignedInteger('songs_count')->default(0); $table->string('status'); $table->text('error')->nullable(); $table->timestamps(); }); } } final class FakeEvent { public function __construct( private readonly int $id, private readonly string $title, private readonly string $startDate, private readonly ?string $note = null, private readonly array $eventServices = [], ) {} public function getId(): string { return (string) $this->id; } public function getName(): string { return $this->title; } public function getStartDate(): string { return $this->startDate; } public function getNote(): ?string { return $this->note; } public function getEventServices(): array { return $this->eventServices; } } final class FakeEventService { public function __construct( private readonly string $name, private readonly ?FakePerson $person, ) {} public function getName(): string { return $this->name; } public function getPerson(): ?FakePerson { return $this->person; } } final class FakePerson { public function __construct( private readonly string $firstName, private readonly string $lastName, ) {} public function getFirstName(): string { return $this->firstName; } public function getLastName(): string { return $this->lastName; } } final class FakeAgenda { public function __construct(private readonly array $items) {} public function getItems(): array { return $this->items; } public function getSongs(): array { return array_values(array_filter( array_map(fn ($item) => $item->getSong(), $this->items), fn ($song) => $song !== null, )); } } final class FakeAgendaItem { public function __construct( private readonly int $id, private readonly string $position, private readonly string $title, private readonly string $type, private readonly ?FakeSong $song = null, private readonly ?string $note = null, private readonly ?string $duration = null, private readonly ?string $start = null, private readonly bool $isBeforeEvent = false, private readonly ?array $responsible = null, ) {} public function getId(): string { return (string) $this->id; } public function getPosition(): string { return $this->position; } public function getTitle(): string { return $this->title; } public function getType(): string { return $this->type; } public function getSong(): ?FakeSong { return $this->song; } public function getNote(): ?string { return $this->note; } public function getDuration(): ?string { return $this->duration; } public function getStart(): ?string { return $this->start; } public function getIsBeforeEvent(): bool { return $this->isBeforeEvent; } public function getResponsible(): ?array { return $this->responsible; } } final class FakeSong { public function __construct( private readonly int $id, private readonly string $title, private readonly ?string $ccli, ) {} public function getId(): string { return (string) $this->id; } public function getName(): string { return $this->title; } public function getCcli(): ?string { return $this->ccli; } }