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>
196 lines
8.7 KiB
PHP
196 lines
8.7 KiB
PHP
<?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();
|
||
});
|