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>
This commit is contained in:
parent
34fa342c7f
commit
2a2215b3c3
66 changed files with 3092 additions and 368 deletions
|
|
@ -546,3 +546,142 @@ test('a green re-check without routing leaves the status untouched (Audit U03)',
|
|||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
test('a classification is only stored together with its audit — a failed audit write rolls the result back (Audit N03)', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
config()->set('scoring.classification.provider', 'deterministic');
|
||||
config()->set('blacklist.words', []);
|
||||
|
||||
// Fällige Termin-PM: genau die Kandidatin, die der Scheduler zieht.
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'title' => 'Sauber',
|
||||
'text' => 'Inhalt',
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
// Zwischen Ergebnis-UPDATE und Audit-INSERT darf kein Zustand sichtbar
|
||||
// werden, in dem eine grüne PM ohne Nachweis dasteht — der Scheduler
|
||||
// behandelte sie sonst wie auditlosen Altbestand.
|
||||
$auditAttempted = false;
|
||||
DB::beforeExecuting(function (string $query) use (&$auditAttempted): void {
|
||||
if (str_starts_with($query, 'insert') && str_contains($query, 'ki_audits')) {
|
||||
$auditAttempted = true;
|
||||
|
||||
throw new RuntimeException('Audit-Tabelle nicht erreichbar');
|
||||
}
|
||||
});
|
||||
|
||||
expect(fn () => (new ClassifyPressRelease($pressRelease->id, route: true, contentHash: $pressRelease->content_hash))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
))->toThrow(RuntimeException::class);
|
||||
|
||||
expect($auditAttempted)->toBeTrue();
|
||||
|
||||
$fresh = $pressRelease->fresh();
|
||||
expect($fresh->classification)->toBeNull()
|
||||
->and($fresh->classified_at)->toBeNull()
|
||||
->and($fresh->status)->toBe(PressReleaseStatus::Review)
|
||||
->and(KiAudit::where('press_release_id', $pressRelease->id)->count())->toBe(0);
|
||||
|
||||
$this->artisan('press-releases:publish-scheduled')->assertSuccessful();
|
||||
|
||||
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review);
|
||||
});
|
||||
|
||||
test('a red re-check whose content was corrected before the reject lock does not reject and re-checks the current version (Audit N04)', function () {
|
||||
Mail::fake();
|
||||
Queue::fake();
|
||||
config()->set('scoring.classification.provider', 'deterministic');
|
||||
config()->set('blacklist.words', ['gesperrt']);
|
||||
|
||||
$author = User::factory()->create();
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'user_id' => $author->id,
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
'classified_at' => now()->subHour(),
|
||||
'scheduled_at' => now()->addWeek(),
|
||||
'title' => 'Sauber',
|
||||
'text' => 'Dieser Inhalt ist jetzt gesperrt.',
|
||||
]);
|
||||
$redHash = $pressRelease->content_hash;
|
||||
$correctedHash = str_repeat('e', 64);
|
||||
|
||||
// Der Editor korrigiert exakt zwischen dem Status-Refresh des Jobs (erster
|
||||
// SELECT nach dem Audit) und der Zeilensperre in reject().
|
||||
$auditWritten = false;
|
||||
$raced = false;
|
||||
DB::listen(function ($query) use (&$auditWritten, &$raced, $pressRelease, $correctedHash): void {
|
||||
if (str_starts_with($query->sql, 'insert') && str_contains($query->sql, 'ki_audits')) {
|
||||
$auditWritten = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($auditWritten && ! $raced && str_starts_with($query->sql, 'select') && str_contains($query->sql, 'press_releases')) {
|
||||
$raced = true;
|
||||
DB::table('press_releases')->where('id', $pressRelease->id)->update(['text' => 'Sauberer Inhalt.', 'content_hash' => $correctedHash]);
|
||||
}
|
||||
});
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id, route: false, contentHash: $redHash))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
expect($raced)->toBeTrue();
|
||||
|
||||
$fresh = $pressRelease->fresh();
|
||||
expect($fresh->status)->toBe(PressReleaseStatus::Review)
|
||||
->and($fresh->content_hash)->toBe($correctedHash);
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
|
||||
Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $pressRelease->id
|
||||
&& $job->route === true
|
||||
&& $job->contentHash === $correctedHash);
|
||||
});
|
||||
|
||||
test('a red re-check whose content changed right after the result was stored skips the rejection (Audit N04)', function () {
|
||||
Mail::fake();
|
||||
Queue::fake();
|
||||
config()->set('scoring.classification.provider', 'deterministic');
|
||||
config()->set('blacklist.words', ['gesperrt']);
|
||||
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
'classified_at' => now()->subHour(),
|
||||
'scheduled_at' => now()->addWeek(),
|
||||
'title' => 'Sauber',
|
||||
'text' => 'Dieser Inhalt ist jetzt gesperrt.',
|
||||
]);
|
||||
$redHash = $pressRelease->content_hash;
|
||||
$correctedHash = str_repeat('f', 64);
|
||||
|
||||
// Korrektur nach dem bedingten UPDATE, aber vor dem Audit — das Rot gilt
|
||||
// für die alte Fassung; die Bearbeitung selbst plant den neuen Re-Check.
|
||||
$raced = false;
|
||||
DB::beforeExecuting(function (string $query) use (&$raced, $pressRelease, $correctedHash): void {
|
||||
if ($raced || ! str_starts_with($query, 'insert') || ! str_contains($query, 'ki_audits')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$raced = true;
|
||||
DB::table('press_releases')->where('id', $pressRelease->id)->update(['text' => 'Sauberer Inhalt.', 'content_hash' => $correctedHash]);
|
||||
});
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id, route: false, contentHash: $redHash))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
expect($raced)->toBeTrue()
|
||||
->and($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review);
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
Queue::assertNotPushed(ClassifyPressRelease::class);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue