pp-planer/tests/Feature/ChurchToolsSyncTest.php
Thorsten Bus 0e3c647cfc feat: probundle export with media, image upscaling, upload dimension warnings
- Fix probundle exports missing images (double slides/ prefix in storage path)
- Replace manual ZipArchive with PresentationBundle + ProBundleWriter from parser plugin
- Add per-agenda-item download route and buttons for songs and slide items
- Remove text layer from image-only slides in .pro generation
- Fix image conversion: upscale small images, black bars on 2 sides max (contain)
- Add upload warnings for non-16:9 and sub-1920x1080 images (German, non-blocking)
- Update SlideFactory and all tests to use slides/ prefix in stored_filename
- Add 11 new tests (agenda download, image conversion, upload warnings)
2026-03-30 10:29:37 +02:00

681 lines
24 KiB
PHP

<?php
use App\Services\ChurchToolsService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
beforeEach(function () {
ensureSyncTables();
});
test('cts:sync synchronisiert services, agenda songs und schreibt sync log', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
$localSongId = DB::table('songs')->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' => 'slides/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('event ohne agenda setzt has_agenda auf false und ueberspringt agenda sync', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 893, title: 'Service ohne Agenda', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn (int $eventId) => throw new CTRequestException(
"Agenda for event [{$eventId}] not found."
),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
$service = DB::table('services')->where('cts_event_id', '893')->first();
expect($service)->not->toBeNull();
expect($service->title)->toBe('Service ohne Agenda');
expect((bool) $service->has_agenda)->toBeFalse();
$agendaItems = DB::table('service_agenda_items')->where('service_id', $service->id)->get();
expect($agendaItems)->toHaveCount(0);
$serviceSongs = DB::table('service_songs')->where('service_id', $service->id)->get();
expect($serviceSongs)->toHaveCount(0);
$syncLog = DB::table('cts_sync_log')->latest('id')->first();
expect($syncLog)->not->toBeNull();
expect($syncLog->status)->toBe('success');
});
test('event mit agenda setzt has_agenda auf true', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 894, title: 'Service mit Agenda', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([
new FakeAgendaItem(id: 90, position: '1', title: 'Begrüssung', type: 'Default'),
]),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
$service = DB::table('services')->where('cts_event_id', '894')->first();
expect($service)->not->toBeNull();
expect((bool) $service->has_agenda)->toBeTrue();
$agendaItems = DB::table('service_agenda_items')->where('service_id', $service->id)->get();
expect($agendaItems)->toHaveCount(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->boolean('has_agenda')->default(false);
$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;
}
}