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' => '

Die Firma eröffnet einen neuen Standort in Hamburg und schafft zwanzig Arbeitsplätze.

', ]); $classification = app(CorrectionService::class)->classify($pressRelease, [ 'text' => '

Völlig neuer Inhalt über ein ganz anderes Produkt mit komplett anderem Schwerpunkt und neuen Aussagen zur Marktlage.

', ]); 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' => '

Gleicher Inhalt in einem Absatz.

', ]); $classification = app(CorrectionService::class)->classify($pressRelease, [ 'text' => '
Gleicher Inhalt in einem Absatz.
', ]); expect($classification['has_changes'])->toBeFalse(); }); test('editor normalization of entities and line breaks counts as no change', function () { /** @var TestCase $this */ $pressRelease = PressRelease::factory()->published()->create([ 'text' => '

Preis & Leistung stimmen.
Zeile zwei mit "Zitat".

', ]); // So liefert der Rich-Text-Editor denselben Inhalt zurück: Entities // dekodiert,
als eigener Absatz. $classification = app(CorrectionService::class)->classify($pressRelease, [ 'text' => "

Preis & Leistung\u{00A0}stimmen.

Zeile zwei mit \"Zitat\".

", ]); expect($classification['has_changes'])->toBeFalse(); }); test('one added letter in an entity-laden text stays near zero distance', function () { /** @var TestCase $this */ $words = implode(' ', array_fill(0, 60, 'Wort und & mehr')); $pressRelease = PressRelease::factory()->published()->create([ 'text' => '

'.$words.' Betrieb genomen.

', ]); $decoded = implode(' ', array_fill(0, 60, "Wort\u{00A0}und & mehr")); $classification = app(CorrectionService::class)->classify($pressRelease, [ 'text' => '

'.$decoded.' Betrieb genommen.

', ]); expect($classification['is_minor'])->toBeTrue() ->and($classification['fields']['text']['distance_percent'])->toBeLessThan(1.0); }); 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('classify flags changes above the paid distance limit', function () { /** @var TestCase $this */ $pressRelease = PressRelease::factory()->published()->create([ 'title' => 'Kurzer Titel der Mitteilung', ]); $classification = app(CorrectionService::class)->classify($pressRelease, [ 'title' => 'Ein voellig anderer, deutlich laengerer und inhaltlich komplett neuer Titel', ]); expect($classification['exceeds_paid_limit'])->toBeTrue() ->and($classification['is_minor'])->toBeFalse(); }); test('applyPaid executes a paid change with snapshot, lock and resolved request', function () { /** @var TestCase $this */ Queue::fake(); $pressRelease = PressRelease::factory()->published()->create([ 'title' => 'Solaranlage in Hamburg eroeffnet und in Betrieb', ]); $legalRequest = LegalRequest::factory() ->type(LegalRequestType::Change) ->create([ 'press_release_id' => $pressRelease->id, 'payload' => ['new_values' => ['title' => 'Solaranlage in Muenchen eroeffnet und in Vollbetrieb']], ]); $applied = app(CorrectionService::class)->applyPaid($pressRelease, $legalRequest); $pressRelease->refresh(); $legalRequest->refresh(); expect($applied)->toBeTrue() ->and($pressRelease->title)->toBe('Solaranlage in Muenchen eroeffnet und in Vollbetrieb') ->and($pressRelease->isSyncLocked())->toBeTrue() ->and($legalRequest->status)->toBe(LegalRequestStatus::Resolved) ->and($legalRequest->executed_at)->not->toBeNull() ->and($legalRequest->redactionSnapshots()->count())->toBe(1); }); test('applyPaid refuses a change above the paid distance limit', function () { /** @var TestCase $this */ $pressRelease = PressRelease::factory()->published()->create([ 'title' => 'Kurzer Titel der Mitteilung', ]); $legalRequest = LegalRequest::factory() ->type(LegalRequestType::Change) ->create([ 'press_release_id' => $pressRelease->id, 'payload' => ['new_values' => ['title' => 'Ein voellig anderer, deutlich laengerer und inhaltlich komplett neuer Titel']], ]); app(CorrectionService::class)->applyPaid($pressRelease, $legalRequest); })->throws(CorrectionTooLargeException::class); test('the precheck classifies the proposed content instead of the stored one', function () { /** @var TestCase $this */ config()->set('scoring.classification.provider', 'deterministic'); config()->set('blacklist.words', ['skandalwort']); app()->forgetInstance(BlacklistService::class); $pressRelease = PressRelease::factory()->published()->create([ 'title' => 'Harmloser Titel der Mitteilung', ]); $service = app(CorrectionService::class); $red = $service->precheckProposedContent($pressRelease, ['title' => 'Titel mit Skandalwort darin']); $green = $service->precheckProposedContent($pressRelease, ['title' => 'Harmlose neue Fassung des Titels']); expect($red->classification)->toBe(PressReleaseClassification::Red) ->and($green->classification)->toBe(PressReleaseClassification::Green) ->and($pressRelease->fresh()->title)->toBe('Harmloser Titel der Mitteilung'); }); 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); });