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>
291 lines
13 KiB
PHP
291 lines
13 KiB
PHP
<?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));
|
||
});
|