$messages */ function changelogRepository(array $messages): string { $path = sys_get_temp_dir().'/changelog-test-'.uniqid(); File::ensureDirectoryExists($path); $git = fn (array $command) => Process::path($path)->run(['git', '-c', 'user.name=Test', '-c', 'user.email=test@example.org', ...$command])->throw(); $git(['init', '--quiet']); $git(['commit', '--allow-empty', '--quiet', '-m', 'start']); $git(['tag', 'v0.1.0']); foreach ($messages as $message) { $git(['commit', '--allow-empty', '--quiet', '-m', $message]); } $git(['tag', 'v0.2.0']); return $path; } // ---------------------------------------------------------- Stufe 1 und 2 test('the commit lines are read from anywhere in the message, not only from the trailer block', function () { /** @var TestCase $this */ $parsed = ChangelogCollector::parse("web: Kasten\n\nText.\n\nChangelog: Verbessert\nPublic: Auf jeder Newsroom-Seite\nführt ein Kasten zum Zugang.\n\nCo-Authored-By: X "); expect($parsed['category'])->toBe(ChangelogCategory::Improved) ->and($parsed['public'])->toBe('Auf jeder Newsroom-Seite führt ein Kasten zum Zugang.') ->and(ChangelogCollector::parse("dev: intern\n\nNur Doku."))->toBeNull() ->and(ChangelogCollector::parse("x: y\n\nChangelog: Irgendwas"))->toBeNull() ->and(ChangelogCollector::suggestionFrom('web(newsroom): Newsroom-Seite mit Kasten'))->toBe('Newsroom-Seite mit Kasten.'); }); test('a deploy collects drafts: internal commits stay out, security becomes one line, missing public sentences are marked', function () { /** @var TestCase $this */ $repository = changelogRepository([ "web: Newsroom-Kasten\n\nChangelog: Neu\nPublic: Jede Newsroom-Seite hat einen Kasten zum Verwalten.", 'dev: Roadmap gepflegt', "web: Firmenseite verlinkt den Newsroom\n\nChangelog: Verbessert", "fix(auth): Session nach Passwortwechsel beenden\n\nChangelog: Behoben\nPublic: Nach dem Passwortwechsel ist man abgemeldet.", "security: Header gesetzt\n\nChangelog: Sicherheit", ]); $collector = new ChangelogCollector($repository); $result = $collector->collect('v0.1.0', 'v0.2.0'); expect($result['created'])->toBe(3) ->and($result['internal'])->toBe(1); $entries = ChangelogEntry::query()->orderBy('id')->get(); expect($entries->pluck('status')->unique()->all())->toBe([ChangelogStatus::Draft]) ->and($entries[0]->text)->toBe('Jede Newsroom-Seite hat einen Kasten zum Verwalten.') ->and($entries[1]->text)->toBe('Firmenseite verlinkt den Newsroom.') ->and($entries[1]->review_note)->toContain('Kein Public-Satz') // „Session“ im Titel, Rubrik Behoben → Sicherheit, ohne Beschreibung. ->and($entries->where('category', ChangelogCategory::Security))->toHaveCount(1) ->and($entries[2]->text)->toBe(ChangelogCategory::SECURITY_LINE) ->and($entries[2]->release_tag)->toBe('v0.2.0'); // Ein zweiter Lauf (Deploy wiederholt) legt nichts doppelt an. expect($collector->collect('v0.1.0', 'v0.2.0')['created'])->toBe(0) ->and(ChangelogEntry::query()->count())->toBe(3); File::deleteDirectory($repository); }); test('an unknown tag fails loudly instead of collecting nothing', function () { /** @var TestCase $this */ $repository = changelogRepository([]); expect(fn () => (new ChangelogCollector($repository))->collect('v9.9.9', 'v0.2.0'))->toThrow(RuntimeException::class); File::deleteDirectory($repository); }); test('the backfill adds the entries since the relaunch as drafts, once', function () { /** @var TestCase $this */ $this->artisan('changelog:backfill')->assertSuccessful(); $count = ChangelogEntry::query()->count(); $this->artisan('changelog:backfill')->assertSuccessful(); expect($count)->toBeGreaterThan(10) ->and(ChangelogEntry::query()->count())->toBe($count) ->and(ChangelogEntry::query()->where('status', '!=', ChangelogStatus::Draft->value)->exists())->toBeFalse(); }); // -------------------------------------------------------- Öffentliche Seite test('the public changelog shows only published entries and security once per day', function () { /** @var TestCase $this */ ChangelogEntry::factory()->published()->create(['released_on' => '2026-10-13', 'text' => 'Gebuchte Platzierungen sind gekennzeichnet.']); ChangelogEntry::factory()->create(['released_on' => '2026-10-13', 'text' => 'Noch nicht freigegeben.']); ChangelogEntry::factory()->published()->category(ChangelogCategory::Security)->count(2)->create(['released_on' => '2026-10-08', 'text' => 'Details zur Lücke']); $response = $this->get('https://pressekonto.test/changelog') ->assertOk() ->assertSeeText('Was ist neu') ->assertSeeText('Gebuchte Platzierungen sind gekennzeichnet.') ->assertDontSeeText('Noch nicht freigegeben.') ->assertDontSeeText('Details zur Lücke') ->assertSee('/changelog/feed', false); expect(substr_count($response->getContent(), ChangelogCategory::SECURITY_LINE))->toBe(1); }); test('the changelog belongs to the hub and the feed is rss', function () { /** @var TestCase $this */ ChangelogEntry::factory()->published()->create(['text' => 'Rechnungen kommen per E-Mail.']); $this->get('https://presseecho.test/changelog')->assertRedirect('https://pressekonto.test/changelog'); $this->get('https://pressekonto.test/changelog/feed') ->assertOk() ->assertHeader('Content-Type', 'application/rss+xml; charset=UTF-8') ->assertSee('assertSee('Rechnungen kommen per E-Mail.', false); }); test('the new hint shows only with a recent published entry', function () { /** @var TestCase $this */ $timeline = app(ChangelogTimeline::class); ChangelogEntry::factory()->published()->create(['released_on' => now()->subDays(30)->toDateString()]); expect($timeline->hasRecent())->toBeFalse(); ChangelogEntry::factory()->published()->create(['released_on' => now()->subDays(2)->toDateString()]); expect($timeline->hasRecent())->toBeTrue(); }); // ------------------------------------------------------------- Admin test('admins review, edit and publish drafts', function () { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); $draft = ChangelogEntry::factory()->create(['released_on' => '2026-10-14', 'text' => 'Erster Entwurf des Satzes.']); $other = ChangelogEntry::factory()->create(['released_on' => '2026-10-14']); $this->actingAs($admin)->get(route('admin.changelog.index'))->assertOk()->assertSeeText('Erster Entwurf des Satzes.'); Livewire::test('admin.changelog.index') ->call('edit', $draft->id) ->set('editText', 'Kurz') ->call('saveEdit') ->assertHasErrors('editText') ->set('editText', 'Die Firmenseite verlinkt den Newsroom.') ->set('editCategory', ChangelogCategory::New->value) ->call('saveEdit') ->assertHasNoErrors() ->call('publish', $draft->id) ->call('discard', $other->id); $draft->refresh(); expect($draft->text)->toBe('Die Firmenseite verlinkt den Newsroom.') ->and($draft->category)->toBe(ChangelogCategory::New) ->and($draft->status)->toBe(ChangelogStatus::Published) ->and($draft->published_at)->not->toBeNull() ->and($draft->reviewed_by_user_id)->toBe($admin->id) ->and($other->fresh()->status)->toBe(ChangelogStatus::Discarded); Livewire::test('admin.changelog.index')->call('restore', $other->id)->call('publishDay', '2026-10-14'); expect($other->fresh()->status)->toBe(ChangelogStatus::Published); }); test('editors without settings permission cannot open the changelog admin', function () { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class); $editor = User::factory()->create(['is_active' => true]); $editor->assignRole('editor'); $this->actingAs($editor)->get(route('admin.changelog.index'))->assertForbidden(); });