presseportale/tests/Feature/ConversionBaselineReportsTest.php
Kevin Adametz 2a2215b3c3 audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand
PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute
(Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum
Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den
Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende
Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush
nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an
veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden.

Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit
erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit
FAILURE; record() über .tmp + move; Marker archive_pending_at mit
archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder
über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie
veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem
Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder;
archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks.

KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in
einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview();
Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte
Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht.

Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten;
Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht;
Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der
Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run
zählt alle Tabellen.

Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber.
Zwei Migrationen (nullable Spalten, INSTANT). N09 offen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 14:26:49 +00:00

291 lines
13 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\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Services\PressRelease\RejectionRateReport;
use App\Services\PressRelease\ViewDistributionReport;
use Illuminate\Support\Carbon;
use Tests\TestCase;
/**
* Basiswerte aus Block 0 der Roadmap. Beide müssen gemessen sein, bevor die
* erste Maßnahme aus Block 1 live geht — ohne Vergleichspunkt wäre jede
* spätere Bewertung eine Vermutung.
*/
function baselineRelease(Portal $portal, int $hits, array $attributes = []): PressRelease
{
return PressRelease::factory()
->published()
->forPortal($portal)
->create(array_merge([
'hits' => $hits,
'published_at' => now()->subDays(10),
], $attributes));
}
function baselineLog(PressRelease $release, string $toStatus, string $source, ?User $user, string $at): void
{
PressReleaseStatusLog::query()->create([
'press_release_id' => $release->getKey(),
'to_status' => $toStatus,
'source' => $source,
'changed_by_user_id' => $user?->getKey(),
'created_at' => $at,
]);
}
// ---------------------------------------------------------------- 0.1 Aufrufe
test('the view distribution reports median and quartiles per portal', function () {
/** @var TestCase $this */
foreach ([10, 20, 30, 40, 50] as $hits) {
baselineRelease(Portal::Presseecho, $hits);
}
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
$presseecho = collect($data['portals'])->firstWhere('portal', 'presseecho');
$stats = $presseecho['segments'][0]['stats'];
expect($stats['anzahl'])->toBe(5)
->and($stats['min'])->toBe(10)
->and($stats['median'])->toBe(30)
->and($stats['max'])->toBe(50)
->and($stats['mittelwert'])->toBe(30.0);
});
test('the view distribution counts a both-portal release on both portals', function () {
/** @var TestCase $this */
baselineRelease(Portal::Both, 77);
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
// Der Zähler ist einer, die Sichtbarkeit doppelt — für die Einordnung
// eines Absenders zählt die Meldung auf beiden Portalen mit.
foreach ($data['portals'] as $portal) {
expect($portal['segments'][0]['stats']['median'])->toBe(77);
}
});
test('the view distribution leaves out removed releases', function () {
/** @var TestCase $this */
baselineRelease(Portal::Presseecho, 100);
baselineRelease(Portal::Presseecho, 999, ['gone_at' => now()]);
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
$stats = collect($data['portals'])->firstWhere('portal', 'presseecho')['segments'][0]['stats'];
// Eine mit 410 entfernte Meldung ist nicht mehr öffentlich und darf den
// Portalschnitt nicht mitbestimmen.
expect($stats['anzahl'])->toBe(1)
->and($stats['max'])->toBe(100);
});
test('the view distribution separates legacy stock from post-relaunch releases', function () {
/** @var TestCase $this */
baselineRelease(Portal::Presseecho, 20, ['legacy_portal' => Portal::Presseecho->value, 'legacy_id' => 4711]);
baselineRelease(Portal::Presseecho, 200);
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
$segments = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['segments'])
->keyBy('segment');
// Beide Zähler haben verschiedene Vorgeschichten; eine Einordnung, die sie
// vermischt, vergleicht Äpfel mit Birnen.
expect($segments['seit Relaunch']['stats']['median'])->toBe(200)
->and($segments['Altbestand']['stats']['median'])->toBe(20);
});
test('the view distribution ignores releases published outside the window', function () {
/** @var TestCase $this */
baselineRelease(Portal::Presseecho, 50);
baselineRelease(Portal::Presseecho, 5000, ['published_at' => now()->subYear()]);
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
expect(collect($data['portals'])->firstWhere('portal', 'presseecho')['segments'][0]['stats']['anzahl'])->toBe(1);
});
test('the view distribution command renders without data', function () {
/** @var TestCase $this */
$this->artisan('press-releases:view-distribution')->assertSuccessful();
});
test('the view distribution groups releases into age cohorts', function () {
/** @var TestCase $this */
baselineRelease(Portal::Presseecho, 10, ['published_at' => now()->subDays(2)]);
baselineRelease(Portal::Presseecho, 90, ['published_at' => now()->subDays(10)]);
baselineRelease(Portal::Presseecho, 300, ['published_at' => now()->subDays(40)]);
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
$cohorts = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['cohorts'])
->keyBy('alter');
// `hits` wächst nach der Veröffentlichung über Wochen weiter. Nur innerhalb
// einer Alterskohorte ist der Wert zwischen zwei Messzeitpunkten
// vergleichbar — der Gesamtmedian hängt an der Altersmischung des Fensters.
expect($cohorts['0–6 Tage']['median'])->toBe(10)
->and($cohorts['7–13 Tage']['median'])->toBe(90)
->and($cohorts['28–55 Tage']['median'])->toBe(300);
});
test('the age cohorts count from today even when the window ends in the past', function () {
/** @var TestCase $this */
baselineRelease(Portal::Presseecho, 10, ['published_at' => now()->subDays(2)]);
baselineRelease(Portal::Presseecho, 300, ['published_at' => now()->subDays(40)]);
baselineRelease(Portal::Presseecho, 700, ['published_at' => now()->subDays(100)]);
// `hits` steht auf dem Stand von heute — eine vor 40 Tagen veröffentlichte
// Meldung ist 40 Tage alt, auch wenn das Fenster vor 20 Tagen endet. Die
// Meldung von vorgestern liegt außerhalb des Fensters; die junge Kohorte
// fällt leer aus, statt 20 Tage alte Meldungen als „0–6 Tage" zu führen.
$data = app(ViewDistributionReport::class)->build(now()->subDays(120), now()->subDays(20));
$cohorts = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['cohorts'])
->keyBy('alter');
expect($data['stichtag'])->toBe(now()->toDateString())
->and($cohorts->has('0–6 Tage'))->toBeFalse()
->and($cohorts['28–55 Tage']['anzahl'])->toBe(1)
->and($cohorts['28–55 Tage']['median'])->toBe(300)
->and($cohorts['91+ Tage']['median'])->toBe(700);
});
test('the age cohorts leave out legacy stock', function () {
/** @var TestCase $this */
baselineRelease(Portal::Presseecho, 50, ['published_at' => now()->subDays(3)]);
baselineRelease(Portal::Presseecho, 5000, [
'published_at' => now()->subDays(3),
'legacy_portal' => Portal::Presseecho->value,
'legacy_id' => 4712,
]);
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
$cohorts = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['cohorts'])
->keyBy('alter');
// Die Zähler des Altbestands stammen aus dem Altsystem und haben keine
// hier nachvollziehbare Vorgeschichte — sie würden die Kurve verfälschen.
expect($cohorts['0–6 Tage']['anzahl'])->toBe(1)
->and($cohorts['0–6 Tage']['median'])->toBe(50);
});
// ------------------------------------------------------- 0.2 Ablehnungsquote
test('the rejection rate is reported per submitting source', function () {
/** @var TestCase $this */
$api = User::factory()->create(['agency_name' => 'connektar.de Presseverteiler']);
$customer = User::factory()->create();
foreach (range(1, 3) as $i) {
$release = baselineRelease(Portal::Presseecho, 0);
baselineLog($release, PressReleaseStatus::Review->value, 'api', $api, now()->subDays(5)->toDateTimeString());
baselineLog($release, $i === 1 ? PressReleaseStatus::Rejected->value : PressReleaseStatus::Published->value, 'ki', null, now()->subDays(4)->toDateTimeString());
}
$release = baselineRelease(Portal::Presseecho, 0);
baselineLog($release, PressReleaseStatus::Review->value, 'customer', $customer, now()->subDays(5)->toDateTimeString());
baselineLog($release, PressReleaseStatus::Published->value, 'ki', null, now()->subDays(4)->toDateTimeString());
$data = app(RejectionRateReport::class)->build(now()->subMonths(3));
$sources = collect($data['sources'])->keyBy('key');
// Gruppiert wird nach dem EINREICHENDEN, nicht nach dem Entscheider — die
// Entscheidung trifft immer die KI-Prüfung und wäre als Gruppierung wertlos.
expect($sources['api:'.$api->id]['label'])->toContain('connektar.de Presseverteiler')
->and($sources['api:'.$api->id]['eingereicht'])->toBe(3)
->and($sources['api:'.$api->id]['abgelehnt'])->toBe(1)
->and($sources['api:'.$api->id]['quote'])->toBe(33.3)
->and($sources['customer']['label'])->toBe('direkt (Kundenbereich)')
->and($sources['customer']['quote'])->toBe(0.0)
->and($data['totals']['quote'])->toBe(25.0);
});
test('a resubmission after rejection counts as its own submission', function () {
/** @var TestCase $this */
$api = User::factory()->create(['agency_name' => 'connektar.de Presseverteiler']);
$release = baselineRelease(Portal::Presseecho, 0);
baselineLog($release, PressReleaseStatus::Review->value, 'api', $api, now()->subDays(9)->toDateTimeString());
baselineLog($release, PressReleaseStatus::Rejected->value, 'ki', null, now()->subDays(8)->toDateTimeString());
baselineLog($release, PressReleaseStatus::Review->value, 'api', $api, now()->subDays(3)->toDateTimeString());
baselineLog($release, PressReleaseStatus::Published->value, 'ki', null, now()->subDays(2)->toDateTimeString());
$data = app(RejectionRateReport::class)->build(now()->subMonths(3));
// Genau diese Größe soll die Infoseite (1d) verändern: dieselbe Meldung,
// überarbeitet, mit eigenem Ausgang.
expect($data['totals']['eingereicht'])->toBe(2)
->and($data['totals']['abgelehnt'])->toBe(1)
->and($data['totals']['veroeffentlicht'])->toBe(1)
->and($data['totals']['quote'])->toBe(50.0);
});
test('an undecided submission is counted as open, not as published', function () {
/** @var TestCase $this */
$api = User::factory()->create();
$release = baselineRelease(Portal::Presseecho, 0);
baselineLog($release, PressReleaseStatus::Review->value, 'api', $api, now()->subDay()->toDateTimeString());
$data = app(RejectionRateReport::class)->build(now()->subMonths(3));
// Offene Prüfungen dürfen nicht in den Nenner — sonst sänke die Quote
// allein dadurch, dass der Zeitraum bis heute reicht.
expect($data['totals']['offen'])->toBe(1)
->and($data['totals']['quote'])->toBeNull();
});
test('a decision without a submission in the window is reported, not swallowed', function () {
/** @var TestCase $this */
$release = baselineRelease(Portal::Presseecho, 0);
baselineLog($release, PressReleaseStatus::Review->value, 'api', null, now()->subMonths(6)->toDateTimeString());
baselineLog($release, PressReleaseStatus::Rejected->value, 'ki', null, now()->subDay()->toDateTimeString());
$data = app(RejectionRateReport::class)->build(now()->subMonths(3));
// Ein zu kurz gewählter Zeitraum sähe sonst wie eine niedrige Quote aus.
expect($data['entscheidungen_ohne_einreichung'])->toBe(1)
->and($data['totals']['eingereicht'])->toBe(0);
});
test('the rejection rate command reports an empty window plainly', function () {
/** @var TestCase $this */
$this->artisan('press-releases:rejection-rate')
->expectsOutputToContain('Keine Einreichungen im Zeitraum.')
->assertSuccessful();
});
test('the age cohorts join seamlessly and every release belongs to exactly one', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-09-21 12:00:00');
// Alter in Stunden: unmittelbar vor, genau auf und mitten in jeder Grenze.
// Alter [0,7) → „0–6", [7,14) → „7–13" usw.; genau 7 Tage alt gehört zu „7–13".
$ages = [167, 168, 335, 336, 671, 672, 1343, 1344, 2183, 2184];
foreach ($ages as $hours) {
baselineRelease(Portal::Presseecho, $hours, ['published_at' => now()->subHours($hours)]);
}
$data = app(ViewDistributionReport::class)->build(now()->subDays(120));
$portal = collect($data['portals'])->firstWhere('portal', 'presseecho');
$cohorts = collect($portal['cohorts'])->keyBy('alter');
$sinceRelaunch = collect($portal['segments'])->firstWhere('segment', 'seit Relaunch')['stats']['anzahl'];
expect($cohorts->pluck('anzahl', 'alter')->all())->toBe([
'0–6 Tage' => 1,
'7–13 Tage' => 2,
'14–20 Tage' => 1,
'21–27 Tage' => 1,
'28–55 Tage' => 2,
'56–90 Tage' => 2,
'91+ Tage' => 1,
])
->and($cohorts['7–13 Tage']['median'])->toBe(168)
->and($cohorts['28–55 Tage']['median'])->toBe(672)
->and($cohorts->sum('anzahl'))->toBe($sinceRelaunch)
->and($sinceRelaunch)->toBe(count($ages));
});