presseportale/tests/Feature/ChangelogTest.php
Kevin Adametz 2c590fa9df changelog: Entwürfe aus den Commits, Freigabe im Admin, öffentliche Seite mit RSS
Decision-Update Changelog, Stufen 1 bis 3:
- changelog:collect liest die Commits zwischen zwei Tags und legt aus den
  Zeilen Changelog:/Public: Entwürfe an (ganze Nachricht, nicht nur der
  Trailer-Block); ohne Changelog-Zeile intern, Sicherheit als eine
  Sammelzeile je Release, Sicherheitsbegriffe im Titel stufen hoch, ohne
  Public-Satz ein markierter Vorschlag aus dem Titel; idempotent.
- changelog:backfill trägt die Neuerungen seit dem Relaunch als Entwurf nach.
- Admin ▸ Changelog (settings:manage): gegenlesen, bearbeiten, Rubrik,
  artikelwürdig, freigeben/verwerfen, Tag gesammelt freigeben, Eintrag von Hand.
- pressekonto.com/changelog und /changelog/feed (RSS), auf den Portalen
  301 zum Hub; Kundenmenü „Was ist neu“ mit Neu-Kennzeichen (14 Tage).

Changelog: Neu
Public: Unter pressekonto.com/changelog steht, was sich auf pressekonto, presseecho und businessportal24 ändert – auch als RSS-Feed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:30:46 +00:00

196 lines
8.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\ChangelogCategory;
use App\Enums\ChangelogStatus;
use App\Models\ChangelogEntry;
use App\Models\User;
use App\Services\Changelog\ChangelogCollector;
use App\Services\Changelog\ChangelogTimeline;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Changelog (Decision-Update Changelog, Stufen 1–3): Entwürfe aus den
* Commit-Zeilen beim Deploy, Freigabe im Admin, öffentliche Seite mit RSS.
*/
/**
* Kleines Git-Repository mit einem Tag am Anfang und einem am Ende.
*
* @param list<string> $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 <x@example.org>");
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('<rss version="2.0"', false)
->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();
});