presseportale/tests/Feature/Legal/CorrectionServiceTest.php
Kevin Adametz b4b2e013d6 recht: Meldeverfahren v2 Phasen 1-4 — Re-Sync-Sperre, Anonymisierung, Magic-Link-Intake, Self-Service-Korrektur
Phase 1: sync_locked_at auf press_releases (Importer überspringt gesperrte
PMs auch bei --force), LegalRequest um Verifikation/Ausführung erweitert,
verschlüsselte Vorher-Snapshots mit Löschfrist (snapshots:purge, täglich).

Phase 2: AnonymizationService (Unicode-Wortgrenzen-Schwärzung, Pflicht-Diff,
transaktionale Ausführung mit Snapshot, Kontakt-Detach, Bild-Löschung,
Sitemap-Invalidierung, Verlauf ohne Klartext-Altwerte); Admin-Queue kann
DSGVO-Anfragen anonymisieren oder depublizieren, inkl. Snapshot-Restore.

Phase 3: Deflektions-Trichter „PM melden/ändern" (melden/pm) mit Kosten-
Tabelle vorab; Magic-Link geht nur an die hinterlegte Pressekontaktadresse
(neuer purpose press_release_report), atomare Verifikation, Anliegen-Auswahl
erzeugt verifizierte Queue-Anfragen; toter-Kanal-Fallback; Owner-Einstieg.

Phase 4: Panel-Abschnitt „Änderung & Korrektur" — Levenshtein-Gate (5 %,
Mini-Edit-Ausnahme ≤ 2 Zeichen) stuft klein/groß ein; kleine Korrektur
kostenlos sofort live (Snapshot, Sperre, Verlauf, Re-Score), größere als
Change-Anfrage (Interim bis 10-€-Checkout, Phase 7).

Entwicklungsplan mit Entscheidungen E1-E8 und offenen Punkten in
docs/Decision-Update; 41 Pest-Tests (tests/Feature/Legal) grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:56:34 +00:00

149 lines
5.5 KiB
PHP

<?php
use App\Enums\ActivityType;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\LegalRequestVerificationMethod;
use App\Models\ActivityEvent;
use App\Models\CreditTransaction;
use App\Models\PressRelease;
use App\Services\PressRelease\CorrectionRateLimitException;
use App\Services\PressRelease\CorrectionService;
use App\Services\PressRelease\CorrectionTooLargeException;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
test('a typo fix in a short title classifies as minor', function () {
/** @var TestCase $this */
$pressRelease = PressRelease::factory()->published()->create([
'title' => 'Neue Photovoltaikanlage in Betrieb genomen',
]);
$classification = app(CorrectionService::class)->classify($pressRelease, [
'title' => 'Neue Photovoltaikanlage in Betrieb genommen',
]);
expect($classification['is_minor'])->toBeTrue()
->and($classification['fields']['title']['distance_percent'])->toBeLessThan(15);
});
test('a rewritten text classifies as major', function () {
/** @var TestCase $this */
$pressRelease = PressRelease::factory()->published()->create([
'text' => '<p>Die Firma eröffnet einen neuen Standort in Hamburg und schafft zwanzig Arbeitsplätze.</p>',
]);
$classification = app(CorrectionService::class)->classify($pressRelease, [
'text' => '<p>Völlig neuer Inhalt über ein ganz anderes Produkt mit komplett anderem Schwerpunkt und neuen Aussagen zur Marktlage.</p>',
]);
expect($classification['is_minor'])->toBeFalse()
->and($classification['fields']['text']['changed'])->toBeTrue();
});
test('markup-only changes count as no change', function () {
/** @var TestCase $this */
$pressRelease = PressRelease::factory()->published()->create([
'text' => '<p>Gleicher Inhalt in einem Absatz.</p>',
]);
$classification = app(CorrectionService::class)->classify($pressRelease, [
'text' => '<div>Gleicher Inhalt in einem Absatz.</div>',
]);
expect($classification['has_changes'])->toBeFalse();
});
test('apply saves a minor correction immediately with snapshot, lock and log', function () {
/** @var TestCase $this */
Queue::fake();
$pressRelease = PressRelease::factory()->published()->create([
'title' => 'Betrieb genomen',
]);
$legalRequest = app(CorrectionService::class)->apply(
$pressRelease,
['title' => 'Betrieb genommen'],
'kunde@firma.example',
LegalRequestVerificationMethod::LoggedIn,
);
$pressRelease->refresh();
expect($pressRelease->title)->toBe('Betrieb genommen')
->and($pressRelease->isSyncLocked())->toBeTrue()
->and($legalRequest->type)->toBe(LegalRequestType::Correction)
->and($legalRequest->status)->toBe(LegalRequestStatus::Resolved)
->and($legalRequest->executed_at)->not->toBeNull()
->and($legalRequest->redactionSnapshots()->count())->toBe(1)
->and($legalRequest->redactionSnapshots()->first()->encrypted_before['fields']['title'])->toBe('Betrieb genomen');
ActivityEvent::query()->where('type', ActivityType::Correction->value)->sole();
});
test('apply refuses a major change', function () {
/** @var TestCase $this */
$pressRelease = PressRelease::factory()->published()->create([
'title' => 'Kurzer Titel der Mitteilung',
]);
app(CorrectionService::class)->apply(
$pressRelease,
['title' => 'Ein komplett anderer, deutlich längerer und inhaltlich neuer Titel'],
'kunde@firma.example',
LegalRequestVerificationMethod::LoggedIn,
);
})->throws(CorrectionTooLargeException::class);
test('the weekly correction brake blocks the third correction', function () {
/** @var TestCase $this */
Queue::fake();
config()->set('legal.corrections_per_week', 2);
$pressRelease = PressRelease::factory()->published()->create(['title' => 'Titel A1']);
$service = app(CorrectionService::class);
$service->apply($pressRelease->refresh(), ['title' => 'Titel A2'], 'k@f.example', LegalRequestVerificationMethod::LoggedIn);
$service->apply($pressRelease->refresh(), ['title' => 'Titel A3'], 'k@f.example', LegalRequestVerificationMethod::LoggedIn);
$service->apply($pressRelease->refresh(), ['title' => 'Titel A4'], 'k@f.example', LegalRequestVerificationMethod::LoggedIn);
})->throws(CorrectionRateLimitException::class);
test('restoreSnapshot brings back the previous field values and keeps the lock', function () {
/** @var TestCase $this */
Queue::fake();
$pressRelease = PressRelease::factory()->published()->create(['title' => 'Original-Titel hier']);
$service = app(CorrectionService::class);
$legalRequest = $service->apply(
$pressRelease,
['title' => 'Original-Titel hie'],
'kunde@firma.example',
LegalRequestVerificationMethod::LoggedIn,
);
$service->restoreSnapshot($legalRequest->redactionSnapshots()->first());
$pressRelease->refresh();
expect($pressRelease->title)->toBe('Original-Titel hier')
->and($pressRelease->isSyncLocked())->toBeTrue();
});
test('a correction never touches credits', function () {
/** @var TestCase $this */
Queue::fake();
$pressRelease = PressRelease::factory()->published()->create(['title' => 'Titel mit Tippfehlerr']);
app(CorrectionService::class)->apply(
$pressRelease,
['title' => 'Titel mit Tippfehler'],
'kunde@firma.example',
LegalRequestVerificationMethod::LoggedIn,
);
expect(CreditTransaction::query()->count())->toBe(0);
});