User Panel: Phase-8-Abschluss, Titelbild/Lizenzen/Zeitzonen und KI-Pruef-Pipeline
Phase 8 (Rest) + Umbauten vom 10./11.06.: - Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker, PressReleaseCoverImage-Resolver - Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen, Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise) - Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt), geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE) - Quota-Stub (users.press_release_quota) + monatlicher Reset-Command - Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout) KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans): - API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route durch denselben Funnel (Blacklist, Quota, Status-Log) - Klassifikation Rot/Gelb/Gruen asynchron (Queue classification, OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log - Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen -> Auto-Publish; Scheduler publiziert nur gruene faellige PMs - Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl. Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung - Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override Suite: 442 passed, 4 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0efabaf446
commit
a000238ca8
141 changed files with 5922 additions and 1001 deletions
101
tests/Feature/Admin/AdminKiCheckTest.php
Normal file
101
tests/Feature/Admin/AdminKiCheckTest.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Jobs\ClassifyPressRelease;
|
||||
use App\Jobs\ScorePressRelease;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\PressRelease\Classification\ClassificationManager;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
$this->actingAs($admin);
|
||||
});
|
||||
|
||||
test('admin editor exposes the KI check button and modal', function () {
|
||||
/** @var TestCase $this */
|
||||
$pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id])
|
||||
->assertSee('Prüfung')
|
||||
->assertSee('Prüfung im Hintergrund starten');
|
||||
});
|
||||
|
||||
test('runKiCheck dispatches a non-routing classification job with provider override', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
|
||||
$pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id])
|
||||
->set('kiProvider', 'deterministic')
|
||||
->call('runKiCheck')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Queue::assertPushedOn('classification', ClassifyPressRelease::class, function (ClassifyPressRelease $job) use ($pressRelease) {
|
||||
return $job->pressReleaseId === $pressRelease->id
|
||||
&& $job->route === false
|
||||
&& $job->providerOverride === 'deterministic';
|
||||
});
|
||||
});
|
||||
|
||||
test('runKiCheck dispatches a scoring job when content score is selected', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
|
||||
$pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id])
|
||||
->set('kiRunClassification', false)
|
||||
->set('kiRunContentScore', true)
|
||||
->call('runKiCheck')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Queue::assertPushed(ScorePressRelease::class, fn (ScorePressRelease $job) => $job->pressReleaseId === $pressRelease->id);
|
||||
Queue::assertNotPushed(ClassifyPressRelease::class);
|
||||
});
|
||||
|
||||
test('runKiCheck does nothing when no check is selected', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
|
||||
$pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id])
|
||||
->set('kiRunClassification', false)
|
||||
->call('runKiCheck')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
test('a non-routing classification job updates the verdict but leaves the status unchanged', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('scoring.classification.provider', 'deterministic');
|
||||
config()->set('blacklist.words', ['penis']);
|
||||
|
||||
// Rote Einstufung, aber als Re-Check ohne Routing: Status bleibt published.
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'title' => 'Titel penis',
|
||||
'text' => 'Inhalt',
|
||||
]);
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id, route: false))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
$fresh = $pressRelease->fresh();
|
||||
expect($fresh->classification)->toBe(PressReleaseClassification::Red);
|
||||
expect($fresh->status)->toBe(PressReleaseStatus::Published);
|
||||
});
|
||||
|
|
@ -22,7 +22,7 @@ function makeAdmin(): User
|
|||
return $admin;
|
||||
}
|
||||
|
||||
test('admin create form persistiert scheduled_at und embargo_at', function () {
|
||||
test('admin create form persistiert scheduled_at aus datum und uhrzeit ohne embargo', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
|
|
@ -38,7 +38,8 @@ test('admin create form persistiert scheduled_at und embargo_at', function () {
|
|||
->set('categoryId', $category->id)
|
||||
->set('portal', $company->portal->value)
|
||||
->set('publishMode', 'scheduled')
|
||||
->set('scheduledAt', '2026-06-05T14:30')
|
||||
->set('scheduledDate', '2026-06-05')
|
||||
->set('scheduledTime', '14:30')
|
||||
->set('useEmbargo', true)
|
||||
->set('embargoAt', '2026-06-10T08:00')
|
||||
->call('save')
|
||||
|
|
@ -46,8 +47,11 @@ test('admin create form persistiert scheduled_at und embargo_at', function () {
|
|||
|
||||
$pr = PressRelease::query()->latest('id')->firstOrFail();
|
||||
|
||||
expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-05 14:30:00');
|
||||
expect($pr->embargo_at?->toDateTimeString())->toBe('2026-06-10 08:00:00');
|
||||
// Eingabe 14:30 erfolgt in Europe/Berlin (CEST, +02:00) und wird als
|
||||
// 12:30 UTC gespeichert.
|
||||
expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-05 12:30:00');
|
||||
expect($pr->scheduled_at?->copy()->setTimezone('Europe/Berlin')->format('Y-m-d H:i'))->toBe('2026-06-05 14:30');
|
||||
expect($pr->embargo_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('admin create form lehnt scheduled_at in der Vergangenheit ab', function () {
|
||||
|
|
@ -71,7 +75,7 @@ test('admin create form lehnt scheduled_at in der Vergangenheit ab', function ()
|
|||
->assertHasErrors(['scheduledAt']);
|
||||
});
|
||||
|
||||
test('admin edit hydriert scheduled_at und embargo_at', function () {
|
||||
test('admin edit hydriert scheduled_at in datum und uhrzeit ohne embargo', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
|
|
@ -79,18 +83,22 @@ test('admin edit hydriert scheduled_at und embargo_at', function () {
|
|||
$this->actingAs($admin);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'scheduled_at' => '2026-06-05 14:30:00',
|
||||
// 12:30 UTC entspricht 14:30 in Europe/Berlin (CEST) – so wird der
|
||||
// Termin in den Eingabefeldern angezeigt.
|
||||
'scheduled_at' => '2026-06-05 12:30:00',
|
||||
'embargo_at' => '2026-06-10 08:00:00',
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
|
||||
->assertSet('publishMode', 'scheduled')
|
||||
->assertSet('scheduledAt', '2026-06-05T14:30')
|
||||
->assertSet('useEmbargo', true)
|
||||
->assertSet('embargoAt', '2026-06-10T08:00');
|
||||
->assertSet('scheduledDate', '2026-06-05')
|
||||
->assertSet('scheduledTime', '14:30')
|
||||
->assertSet('useEmbargo', false)
|
||||
->assertSet('embargoAt', null);
|
||||
});
|
||||
|
||||
test('admin edit persistiert scheduled_at und embargo_at', function () {
|
||||
test('admin edit persistiert scheduled_at aus datum und uhrzeit ohne embargo', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
|
|
@ -104,7 +112,8 @@ test('admin edit persistiert scheduled_at und embargo_at', function () {
|
|||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
|
||||
->set('publishMode', 'scheduled')
|
||||
->set('scheduledAt', '2026-06-08T09:00')
|
||||
->set('scheduledDate', '2026-06-08')
|
||||
->set('scheduledTime', '09:00')
|
||||
->set('useEmbargo', true)
|
||||
->set('embargoAt', '2026-06-12T12:00')
|
||||
->call('save')
|
||||
|
|
@ -112,8 +121,9 @@ test('admin edit persistiert scheduled_at und embargo_at', function () {
|
|||
|
||||
$pr->refresh();
|
||||
|
||||
expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-08 09:00:00');
|
||||
expect($pr->embargo_at?->toDateTimeString())->toBe('2026-06-12 12:00:00');
|
||||
// Eingabe 09:00 in Europe/Berlin (CEST, +02:00) wird als 07:00 UTC gespeichert.
|
||||
expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-08 07:00:00');
|
||||
expect($pr->embargo_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('admin publishMode now clears scheduled_at on save', function () {
|
||||
|
|
|
|||
|
|
@ -91,9 +91,10 @@ test('admin show zeigt Scheduling-Termin im Review-Workflow', function () {
|
|||
'scheduled_at' => '2026-06-15 10:00:00',
|
||||
]);
|
||||
|
||||
// 10:00 UTC wird in Europe/Berlin (CEST, +02:00) als 12:00 angezeigt.
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Geplante Veröffentlichung')
|
||||
->assertSee('15.06.2026 10:00');
|
||||
->assertSee('15.06.2026 12:00');
|
||||
});
|
||||
|
||||
test('admin show zeigt Embargo-Info im Published-Workflow', function () {
|
||||
|
|
|
|||
|
|
@ -27,12 +27,15 @@ test('api user can upload list and delete own press release images', function ()
|
|||
'is_preview' => true,
|
||||
]);
|
||||
|
||||
// Pressebilder werden auf das Hero-/Cover-Format normalisiert (harte
|
||||
// Obergrenze 1280x580, siehe ImageService::PRESS_RELEASE_IMAGE_VARIANTS).
|
||||
// Gespeichert werden daher die Cover-Maße, nicht die Originalgröße.
|
||||
$uploadResponse
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.title', 'Pressefoto')
|
||||
->assertJsonPath('data.is_preview', true)
|
||||
->assertJsonPath('data.width', 1200)
|
||||
->assertJsonPath('data.height', 800)
|
||||
->assertJsonPath('data.width', 1280)
|
||||
->assertJsonPath('data.height', 580)
|
||||
->assertJsonStructure(['data' => ['variants', 'urls']]);
|
||||
|
||||
$image = PressReleaseImage::query()->firstOrFail();
|
||||
|
|
|
|||
119
tests/Feature/Api/V1/PressReleaseSubmitApiTest.php
Normal file
119
tests/Feature/Api/V1/PressReleaseSubmitApiTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
test('api create always produces a draft and ignores any status input', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$category = Category::factory()->withTranslations()->create();
|
||||
$user->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:write']);
|
||||
|
||||
$this->postJson('/api/v1/press-releases', [
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'language' => 'de',
|
||||
'title' => 'API Entwurf',
|
||||
'text' => 'Inhalt',
|
||||
'status' => 'review', // soll ignoriert werden
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.status', PressReleaseStatus::Draft->value);
|
||||
});
|
||||
|
||||
test('api submit route raises a draft to review and counts quota and writes a log', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake(); // Klassifikations-Routing separat getestet; hier nur der Submit-Übergang.
|
||||
|
||||
$user = User::factory()->create(['press_release_quota_used_this_month' => 0]);
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
'title' => 'Saubere Pressemitteilung',
|
||||
'text' => 'Vollkommen unauffälliger Inhalt.',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:write']);
|
||||
|
||||
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', PressReleaseStatus::Review->value);
|
||||
|
||||
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review);
|
||||
expect($user->fresh()->press_release_quota_used_this_month)->toBe(1);
|
||||
expect(PressReleaseStatusLog::where('press_release_id', $pressRelease->id)
|
||||
->where('to_status', PressReleaseStatus::Review->value)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('api submit auto-rejects a press release containing a banned word', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('blacklist.words', ['penis']);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
'title' => 'Unzulässiger Titel penis',
|
||||
'text' => 'Inhalt',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:write']);
|
||||
|
||||
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
|
||||
->assertStatus(422);
|
||||
|
||||
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Rejected);
|
||||
});
|
||||
|
||||
test('api submit requires the write ability', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:read']);
|
||||
|
||||
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('api submit rejects a press release already in review', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
$pressRelease = PressRelease::factory()->inReview()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:write']);
|
||||
|
||||
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
|
||||
->assertStatus(409);
|
||||
});
|
||||
|
||||
test('api user cannot submit another users press release', function () {
|
||||
/** @var TestCase $this */
|
||||
$owner = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($otherUser, ['press-releases:write']);
|
||||
|
||||
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
|
||||
->assertForbidden();
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ use App\Models\Contact;
|
|||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Database\Eloquent\Factories\Sequence;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -62,6 +63,71 @@ test('changing the company resets the contactId to the new company default', fun
|
|||
->assertSet('contactId', $alphaContact->id);
|
||||
});
|
||||
|
||||
test('company options are limited and searchable', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$companies = Company::factory()
|
||||
->count(12)
|
||||
->presseecho()
|
||||
->sequence(fn (Sequence $sequence): array => [
|
||||
'name' => sprintf('Firma %02d', $sequence->index + 1),
|
||||
])
|
||||
->create();
|
||||
|
||||
$companies->each(fn (Company $company) => $customer->companies()->attach($company->id, ['role' => 'owner']));
|
||||
|
||||
$hiddenCompany = $companies->first();
|
||||
$hiddenCompany->update(['name' => 'Zielunternehmen Spezial']);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->assertViewHas('companyOptions', fn ($companyOptions): bool => $companyOptions->count() === 10
|
||||
&& ! $companyOptions->pluck('id')->contains($hiddenCompany->id))
|
||||
->set('companySearch', 'Zielunternehmen')
|
||||
->assertViewHas('companyOptions', fn ($companyOptions): bool => $companyOptions->count() === 1
|
||||
&& $companyOptions->first()->id === $hiddenCompany->id);
|
||||
});
|
||||
|
||||
test('company options show portal abbreviations for duplicate names', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$presseechoCompany = Company::factory()->presseecho()->create(['name' => 'Doppel GmbH']);
|
||||
$businessportalCompany = Company::factory()->businessportal24()->create(['name' => 'Doppel GmbH']);
|
||||
|
||||
$customer->companies()->attach($presseechoCompany->id, ['role' => 'owner']);
|
||||
$customer->companies()->attach($businessportalCompany->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('companySearch', 'Doppel')
|
||||
->assertSee('Doppel GmbH (PE)')
|
||||
->assertSee('Doppel GmbH (B24)');
|
||||
});
|
||||
|
||||
test('company search ignores trailing portal abbreviation from selected label', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$presseechoCompany = Company::factory()->presseecho()->create(['name' => 'Wein & Würze / N8Schicht GmbH']);
|
||||
$businessportalCompany = Company::factory()->businessportal24()->create(['name' => 'Wein & Würze / N8Schicht GmbH']);
|
||||
|
||||
$customer->companies()->attach($presseechoCompany->id, ['role' => 'owner']);
|
||||
$customer->companies()->attach($businessportalCompany->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('companySearch', 'Wein & Würze / N8Schicht GmbH (B24)')
|
||||
->assertViewHas('companyOptions', fn ($companyOptions): bool => $companyOptions->pluck('id')->contains($businessportalCompany->id));
|
||||
});
|
||||
|
||||
test('save with all required fields persists the press release and syncs contact', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
|
|
@ -163,6 +229,68 @@ test('boilerplate override is null when toggle is off even if text is filled', f
|
|||
expect($pr->boilerplate_override)->toBeNull();
|
||||
});
|
||||
|
||||
test('ensureDraft saves a draft with current data and redirects to the editor', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('title', 'Frühzeitiger Upload')
|
||||
->set('categoryId', $category->id)
|
||||
->call('ensureDraft')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr = PressRelease::query()->where('user_id', $customer->id)->latest('id')->firstOrFail();
|
||||
|
||||
expect($pr->status)->toBe(PressReleaseStatus::Draft);
|
||||
expect($pr->company_id)->toBe($company->id);
|
||||
expect($pr->category_id)->toBe($category->id);
|
||||
expect($pr->title)->toBe('Frühzeitiger Upload');
|
||||
});
|
||||
|
||||
test('ensureDraft uses a placeholder title when the title is still empty', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('categoryId', $category->id)
|
||||
->call('ensureDraft')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr = PressRelease::query()->where('user_id', $customer->id)->latest('id')->firstOrFail();
|
||||
|
||||
expect($pr->title)->not->toBe('');
|
||||
expect($pr->status)->toBe(PressReleaseStatus::Draft);
|
||||
});
|
||||
|
||||
test('ensureDraft requires a category before creating a draft', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('categoryId', null)
|
||||
->call('ensureDraft')
|
||||
->assertHasErrors(['categoryId']);
|
||||
|
||||
expect(PressRelease::query()->where('user_id', $customer->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('addTag appends to keywords and removeTag drops it', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ImageLicenseType;
|
||||
use App\Enums\PressReleaseContentTier;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Database\Eloquent\Factories\Sequence;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -40,6 +43,22 @@ function makeCustomerWithPressRelease(array $prAttributes = []): array
|
|||
return compact('customer', 'company', 'contact', 'category', 'pr');
|
||||
}
|
||||
|
||||
test('editor shows the content score panel when a score exists', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([
|
||||
'content_score' => 67,
|
||||
'content_tier' => PressReleaseContentTier::Geprueft->value,
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->assertSet('contentScore', 67)
|
||||
->assertSee('67')
|
||||
->assertSee('Geprüft')
|
||||
->assertSee('Noch 13 Punkte bis zur nächsten Stufe.');
|
||||
});
|
||||
|
||||
test('mount loads all Phase 7 fields and pivot contact', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr, 'contact' => $contact] = makeCustomerWithPressRelease([
|
||||
|
|
@ -71,6 +90,30 @@ test('mount falls back to first company contact when no pivot exists', function
|
|||
->assertSet('contactId', $contact->id);
|
||||
});
|
||||
|
||||
test('edit page shows uploaded title image instead of placeholder controls', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([
|
||||
'title' => 'PM mit eigenem Titelbild',
|
||||
]);
|
||||
|
||||
$pr->images()->create([
|
||||
'disk' => 'public',
|
||||
'path' => 'press-releases/'.$pr->id.'/images/title.jpg',
|
||||
'variants' => ['cover' => 'press-releases/'.$pr->id.'/images/title-cover.jpg'],
|
||||
'author' => 'Jane Doe',
|
||||
'license_type' => ImageLicenseType::Own->value,
|
||||
'rights_confirmed_at' => now(),
|
||||
'is_preview' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->assertDontSee('Dieser Platzhalter wird angezeigt, bis ein eigenes Titelbild hochgeladen ist.')
|
||||
->assertDontSee('Platzhalter wählen');
|
||||
});
|
||||
|
||||
test('save persists all new Phase 7 fields and syncs contact', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease();
|
||||
|
|
@ -150,6 +193,33 @@ test('changing the company resets the contact to the new company default', funct
|
|||
->assertSet('contactId', $secondContact->id);
|
||||
});
|
||||
|
||||
test('company options keep selected company and search without loading all companies', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr, 'company' => $selectedCompany] = makeCustomerWithPressRelease();
|
||||
|
||||
$companies = Company::factory()
|
||||
->count(12)
|
||||
->presseecho()
|
||||
->sequence(fn (Sequence $sequence): array => [
|
||||
'name' => sprintf('Weitere Firma %02d', $sequence->index + 1),
|
||||
])
|
||||
->create();
|
||||
|
||||
$companies->each(fn (Company $company) => $customer->companies()->attach($company->id, ['role' => 'owner']));
|
||||
|
||||
$targetCompany = $companies->first();
|
||||
$targetCompany->update(['name' => 'Suchtreffer Redaktion']);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->assertViewHas('companyOptions', fn ($companyOptions): bool => $companyOptions->count() === 10
|
||||
&& $companyOptions->pluck('id')->contains($selectedCompany->id))
|
||||
->set('companySearch', 'Suchtreffer')
|
||||
->assertViewHas('companyOptions', fn ($companyOptions): bool => $companyOptions->count() === 1
|
||||
&& $companyOptions->first()->id === $targetCompany->id);
|
||||
});
|
||||
|
||||
test('rejected press releases can be edited and re-submitted', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ function makeSchedulingCustomer(): array
|
|||
return compact('customer', 'company', 'contact', 'category');
|
||||
}
|
||||
|
||||
test('create form persistiert scheduled_at und embargo_at', function () {
|
||||
test('create form persistiert scheduled_at aus datum und uhrzeit ohne embargo', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
|
|
@ -43,7 +43,8 @@ test('create form persistiert scheduled_at und embargo_at', function () {
|
|||
->set('text', str_repeat('Inhalt eines Tests. ', 5))
|
||||
->set('categoryId', $category->id)
|
||||
->set('publishMode', 'scheduled')
|
||||
->set('scheduledAt', '2026-06-05T14:30')
|
||||
->set('scheduledDate', '2026-06-05')
|
||||
->set('scheduledTime', '14:30')
|
||||
->set('useEmbargo', true)
|
||||
->set('embargoAt', '2026-06-10T08:00')
|
||||
->call('save')
|
||||
|
|
@ -51,8 +52,11 @@ test('create form persistiert scheduled_at und embargo_at', function () {
|
|||
|
||||
$pr = PressRelease::query()->latest('id')->firstOrFail();
|
||||
|
||||
expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-05 14:30:00');
|
||||
expect($pr->embargo_at?->toDateTimeString())->toBe('2026-06-10 08:00:00');
|
||||
// Eingabe 14:30 erfolgt in Europe/Berlin (CEST, +02:00) und wird als
|
||||
// 12:30 UTC gespeichert.
|
||||
expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-05 12:30:00');
|
||||
expect($pr->scheduled_at?->copy()->setTimezone('Europe/Berlin')->format('Y-m-d H:i'))->toBe('2026-06-05 14:30');
|
||||
expect($pr->embargo_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('create form lehnt scheduled_at in der Vergangenheit ab', function () {
|
||||
|
|
@ -72,7 +76,7 @@ test('create form lehnt scheduled_at in der Vergangenheit ab', function () {
|
|||
->assertHasErrors(['scheduledAt']);
|
||||
});
|
||||
|
||||
test('create form lehnt embargo_at in der Vergangenheit ab', function () {
|
||||
test('create form lehnt geplanten termin aus datum und uhrzeit in der vergangenheit ab', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
|
|
@ -80,13 +84,11 @@ test('create form lehnt embargo_at in der Vergangenheit ab', function () {
|
|||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('title', 'Vergangenes Embargo')
|
||||
->set('text', str_repeat('Inhalt eines Tests. ', 5))
|
||||
->set('categoryId', $category->id)
|
||||
->set('useEmbargo', true)
|
||||
->set('embargoAt', '2026-05-30T10:00')
|
||||
->set('publishMode', 'scheduled')
|
||||
->set('scheduledDate', '2026-05-30')
|
||||
->set('scheduledTime', '10:00')
|
||||
->call('save')
|
||||
->assertHasErrors(['embargoAt']);
|
||||
->assertHasErrors(['scheduledAt']);
|
||||
});
|
||||
|
||||
test('publishMode now setzt scheduled_at auf null beim Save', function () {
|
||||
|
|
@ -108,7 +110,7 @@ test('publishMode now setzt scheduled_at auf null beim Save', function () {
|
|||
expect($pr->scheduled_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('edit form hydriert scheduled_at und embargo_at', function () {
|
||||
test('edit form hydriert scheduled_at in datum und uhrzeit ohne embargo', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
|
|
@ -120,7 +122,9 @@ test('edit form hydriert scheduled_at und embargo_at', function () {
|
|||
'category_id' => $category->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => 'draft',
|
||||
'scheduled_at' => '2026-06-05 14:30:00',
|
||||
// 12:30 UTC entspricht 14:30 in Europe/Berlin (CEST) – so wird der
|
||||
// Termin in den Eingabefeldern angezeigt.
|
||||
'scheduled_at' => '2026-06-05 12:30:00',
|
||||
'embargo_at' => '2026-06-10 08:00:00',
|
||||
]);
|
||||
$pr->contacts()->sync([$contact->id]);
|
||||
|
|
@ -130,6 +134,8 @@ test('edit form hydriert scheduled_at und embargo_at', function () {
|
|||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->assertSet('publishMode', 'scheduled')
|
||||
->assertSet('scheduledAt', '2026-06-05T14:30')
|
||||
->assertSet('useEmbargo', true)
|
||||
->assertSet('embargoAt', '2026-06-10T08:00');
|
||||
->assertSet('scheduledDate', '2026-06-05')
|
||||
->assertSet('scheduledTime', '14:30')
|
||||
->assertSet('useEmbargo', false)
|
||||
->assertSet('embargoAt', null);
|
||||
});
|
||||
|
|
|
|||
203
tests/Feature/PressReleaseClassificationJobTest.php
Normal file
203
tests/Feature/PressReleaseClassificationJobTest.php
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Jobs\ClassifyPressRelease;
|
||||
use App\Mail\PressReleasePublished;
|
||||
use App\Mail\PressReleaseRejected;
|
||||
use App\Models\KiAudit;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\PressRelease\Classification\ClassificationManager;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
function fakeOpenAiClassification(string $classification, array $reasons = []): void
|
||||
{
|
||||
config()->set('scoring.classification.provider', 'openai');
|
||||
config()->set('services.openai.api_key', 'test-key');
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'choices' => [
|
||||
['message' => ['content' => json_encode([
|
||||
'classification' => $classification,
|
||||
'reasons' => $reasons,
|
||||
])]],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
test('classify job stores the openai classification and writes an audit', function () {
|
||||
fakeOpenAiClassification('green');
|
||||
|
||||
$pressRelease = PressRelease::factory()->create(['title' => 'Sauber', 'text' => 'Inhalt']);
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
$fresh = $pressRelease->fresh();
|
||||
expect($fresh->classification)->toBe(PressReleaseClassification::Green);
|
||||
expect($fresh->classified_at)->not->toBeNull();
|
||||
|
||||
$audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail();
|
||||
expect($audit->type)->toBe(KiAudit::TYPE_CLASSIFICATION);
|
||||
expect($audit->provider)->toBe('openai');
|
||||
expect($audit->result)->toBe('green');
|
||||
});
|
||||
|
||||
test('classify job records a yellow classification with reasons', function () {
|
||||
fakeOpenAiClassification('yellow', ['grenzwertige Werbesprache']);
|
||||
|
||||
$pressRelease = PressRelease::factory()->create();
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
$audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail();
|
||||
expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Yellow);
|
||||
expect($audit->reason)->toContain('Werbesprache');
|
||||
});
|
||||
|
||||
test('classify job falls back to the deterministic driver when openai fails', function () {
|
||||
config()->set('scoring.classification.provider', 'openai');
|
||||
config()->set('services.openai.api_key', 'test-key');
|
||||
config()->set('blacklist.words', ['penis']);
|
||||
|
||||
Http::fake(['*' => Http::response('error', 500)]);
|
||||
|
||||
// Sauberer Text -> deterministischer Fallback liefert green.
|
||||
$pressRelease = PressRelease::factory()->create(['title' => 'Sauber', 'text' => 'Inhalt']);
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
$audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail();
|
||||
expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Green);
|
||||
expect($audit->provider)->toBe('deterministic');
|
||||
});
|
||||
|
||||
test('deterministic driver classifies a banned word as red', function () {
|
||||
config()->set('scoring.classification.provider', 'deterministic');
|
||||
config()->set('blacklist.words', ['penis']);
|
||||
|
||||
$pressRelease = PressRelease::factory()->create(['title' => 'Titel penis', 'text' => 'Inhalt']);
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
$audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail();
|
||||
expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Red);
|
||||
expect($audit->provider)->toBe('deterministic');
|
||||
});
|
||||
|
||||
test('classify job routes a red classification to rejected and notifies the author', function () {
|
||||
Mail::fake();
|
||||
config()->set('scoring.classification.provider', 'deterministic');
|
||||
config()->set('blacklist.words', ['penis']);
|
||||
|
||||
$author = User::factory()->create();
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'user_id' => $author->id,
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'title' => 'Titel penis',
|
||||
'text' => 'Inhalt',
|
||||
]);
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Rejected);
|
||||
Mail::assertQueued(PressReleaseRejected::class);
|
||||
});
|
||||
|
||||
test('classify job auto-publishes a green classification without a schedule', function () {
|
||||
Mail::fake();
|
||||
config()->set('scoring.classification.provider', 'deterministic');
|
||||
config()->set('blacklist.words', []);
|
||||
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => null,
|
||||
'title' => 'Sauber',
|
||||
'text' => 'Inhalt',
|
||||
]);
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Published);
|
||||
Mail::assertQueued(PressReleasePublished::class);
|
||||
});
|
||||
|
||||
test('classify job leaves a green scheduled press release in review for the scheduler', function () {
|
||||
config()->set('scoring.classification.provider', 'deterministic');
|
||||
config()->set('blacklist.words', []);
|
||||
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => now()->addWeek(),
|
||||
'title' => 'Sauber',
|
||||
'text' => 'Inhalt',
|
||||
]);
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
$fresh = $pressRelease->fresh();
|
||||
expect($fresh->classification)->toBe(PressReleaseClassification::Green);
|
||||
expect($fresh->status)->toBe(PressReleaseStatus::Review);
|
||||
});
|
||||
|
||||
test('classify job keeps a yellow classification in the manual review queue', function () {
|
||||
fakeOpenAiClassification('yellow', ['grenzwertig']);
|
||||
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'title' => 'Grenzfall',
|
||||
'text' => 'Inhalt',
|
||||
]);
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review);
|
||||
});
|
||||
|
||||
test('submitForReview dispatches the classification job onto the classification queue', function () {
|
||||
Queue::fake();
|
||||
config()->set('blacklist.words', []);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
'title' => 'Sauber',
|
||||
'text' => 'Inhalt',
|
||||
]);
|
||||
|
||||
app(PressReleaseService::class)->submitForReview($pressRelease);
|
||||
|
||||
Queue::assertPushedOn('classification', ClassifyPressRelease::class, function (ClassifyPressRelease $job) use ($pressRelease) {
|
||||
return $job->pressReleaseId === $pressRelease->id;
|
||||
});
|
||||
});
|
||||
65
tests/Feature/PressReleaseClassificationModelTest.php
Normal file
65
tests/Feature/PressReleaseClassificationModelTest.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Models\KiAudit;
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
test('press release casts classification to the enum and classified_at to a datetime', function () {
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'classification' => PressReleaseClassification::Yellow->value,
|
||||
'classified_at' => now(),
|
||||
]);
|
||||
|
||||
$fresh = $pressRelease->fresh();
|
||||
|
||||
expect($fresh->classification)->toBe(PressReleaseClassification::Yellow);
|
||||
expect($fresh->classified_at)->toBeInstanceOf(Carbon::class);
|
||||
});
|
||||
|
||||
test('classification defaults to null when not set', function () {
|
||||
$pressRelease = PressRelease::factory()->create();
|
||||
|
||||
expect($pressRelease->fresh()->classification)->toBeNull();
|
||||
expect($pressRelease->fresh()->classified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('press release has many ki audits ordered newest first', function () {
|
||||
$pressRelease = PressRelease::factory()->create();
|
||||
|
||||
$older = KiAudit::factory()->for($pressRelease)->create(['created_at' => now()->subDay()]);
|
||||
$newer = KiAudit::factory()->for($pressRelease)->create(['created_at' => now()]);
|
||||
|
||||
$audits = $pressRelease->kiAudits()->get();
|
||||
|
||||
expect($audits)->toHaveCount(2);
|
||||
expect($audits->first()->id)->toBe($newer->id);
|
||||
expect($audits->last()->id)->toBe($older->id);
|
||||
});
|
||||
|
||||
test('ki audit casts raw_response to an array and belongs to its press release', function () {
|
||||
$pressRelease = PressRelease::factory()->create();
|
||||
$audit = KiAudit::factory()
|
||||
->for($pressRelease)
|
||||
->classification(PressReleaseClassification::Red)
|
||||
->create([
|
||||
'reason' => 'Werbliche Sprache',
|
||||
'raw_response' => ['classification' => 'red', 'reasons' => ['advertising']],
|
||||
]);
|
||||
|
||||
$fresh = $audit->fresh();
|
||||
|
||||
expect($fresh->type)->toBe(KiAudit::TYPE_CLASSIFICATION);
|
||||
expect($fresh->result)->toBe(PressReleaseClassification::Red->value);
|
||||
expect($fresh->raw_response)->toBeArray()->toHaveKey('reasons');
|
||||
expect($fresh->pressRelease->is($pressRelease))->toBeTrue();
|
||||
});
|
||||
|
||||
test('deleting a press release cascades to its ki audits', function () {
|
||||
$pressRelease = PressRelease::factory()->create();
|
||||
KiAudit::factory()->for($pressRelease)->create();
|
||||
|
||||
$pressRelease->forceDelete();
|
||||
|
||||
expect(KiAudit::where('press_release_id', $pressRelease->id)->exists())->toBeFalse();
|
||||
});
|
||||
114
tests/Feature/PressReleaseContentScoreTest.php
Normal file
114
tests/Feature/PressReleaseContentScoreTest.php
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseContentTier;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Jobs\ScorePressRelease;
|
||||
use App\Models\KiAudit;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\PressRelease\ContentScore\ContentScoreManager;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
function fakeOpenAiScore(int $score): void
|
||||
{
|
||||
config()->set('scoring.content_score.provider', 'openai');
|
||||
config()->set('services.openai.api_key', 'test-key');
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'choices' => [
|
||||
['message' => ['content' => json_encode([
|
||||
'score' => $score,
|
||||
'breakdown' => ['pressestil' => 15],
|
||||
])]],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
test('tier mapping follows the configured thresholds', function () {
|
||||
config()->set('scoring.content_score.tiers', ['gepruft' => 60, 'hochwertig' => 80]);
|
||||
|
||||
expect(PressReleaseContentTier::fromScore(45))->toBe(PressReleaseContentTier::Standard);
|
||||
expect(PressReleaseContentTier::fromScore(60))->toBe(PressReleaseContentTier::Geprueft);
|
||||
expect(PressReleaseContentTier::fromScore(79))->toBe(PressReleaseContentTier::Geprueft);
|
||||
expect(PressReleaseContentTier::fromScore(80))->toBe(PressReleaseContentTier::Hochwertig);
|
||||
});
|
||||
|
||||
test('only gepruft and hochwertig are publicly badged', function () {
|
||||
expect(PressReleaseContentTier::Standard->isPubliclyBadged())->toBeFalse();
|
||||
expect(PressReleaseContentTier::Geprueft->isPubliclyBadged())->toBeTrue();
|
||||
expect(PressReleaseContentTier::Hochwertig->isPubliclyBadged())->toBeTrue();
|
||||
});
|
||||
|
||||
test('score job stores the openai score, derives the tier and writes an audit', function () {
|
||||
fakeOpenAiScore(72);
|
||||
|
||||
$pressRelease = PressRelease::factory()->create();
|
||||
|
||||
(new ScorePressRelease($pressRelease->id))->handle(app(ContentScoreManager::class));
|
||||
|
||||
$fresh = $pressRelease->fresh();
|
||||
expect($fresh->content_score)->toBe(72);
|
||||
expect($fresh->content_tier)->toBe(PressReleaseContentTier::Geprueft);
|
||||
expect($fresh->scored_at)->not->toBeNull();
|
||||
|
||||
$audit = KiAudit::where('press_release_id', $pressRelease->id)
|
||||
->where('type', KiAudit::TYPE_CONTENT_SCORE)
|
||||
->firstOrFail();
|
||||
expect($audit->provider)->toBe('openai');
|
||||
expect($audit->result)->toBe('72');
|
||||
});
|
||||
|
||||
test('score job falls back to the deterministic driver when openai fails', function () {
|
||||
config()->set('scoring.content_score.provider', 'openai');
|
||||
config()->set('services.openai.api_key', 'test-key');
|
||||
|
||||
Http::fake(['*' => Http::response('error', 500)]);
|
||||
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'title' => 'Eine ausreichend lange und klare Pressemitteilungs-Headline',
|
||||
'text' => str_repeat('Inhaltlicher Satz mit Substanz. ', 80),
|
||||
]);
|
||||
|
||||
(new ScorePressRelease($pressRelease->id))->handle(app(ContentScoreManager::class));
|
||||
|
||||
$audit = KiAudit::where('press_release_id', $pressRelease->id)
|
||||
->where('type', KiAudit::TYPE_CONTENT_SCORE)
|
||||
->firstOrFail();
|
||||
expect($audit->provider)->toBe('deterministic');
|
||||
expect($pressRelease->fresh()->content_score)->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('submitForReview dispatches the scoring job onto the classification queue', function () {
|
||||
Queue::fake();
|
||||
config()->set('blacklist.words', []);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
'title' => 'Sauber',
|
||||
'text' => 'Inhalt',
|
||||
]);
|
||||
|
||||
app(PressReleaseService::class)->submitForReview($pressRelease);
|
||||
|
||||
Queue::assertPushedOn('classification', ScorePressRelease::class, function (ScorePressRelease $job) use ($pressRelease) {
|
||||
return $job->pressReleaseId === $pressRelease->id;
|
||||
});
|
||||
});
|
||||
|
||||
test('rescoreIfScored dispatches only for an already scored press release', function () {
|
||||
Queue::fake();
|
||||
|
||||
$scored = PressRelease::factory()->create(['content_score' => 55]);
|
||||
$neverScored = PressRelease::factory()->create(['content_score' => null]);
|
||||
|
||||
app(PressReleaseService::class)->rescoreIfScored($scored);
|
||||
app(PressReleaseService::class)->rescoreIfScored($neverScored);
|
||||
|
||||
Queue::assertPushed(ScorePressRelease::class, 1);
|
||||
});
|
||||
300
tests/Feature/PressReleaseImageLicenseTest.php
Normal file
300
tests/Feature/PressReleaseImageLicenseTest.php
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ImageLicenseType;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Storage::fake('public');
|
||||
});
|
||||
|
||||
function makeImageDraftOwner(): array
|
||||
{
|
||||
$owner = User::factory()->create(['is_active' => true]);
|
||||
$owner->assignRole('customer');
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$owner->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => Category::factory()->create()->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
return compact('owner', 'pr');
|
||||
}
|
||||
|
||||
test('image upload requires author, license type and rights confirmation', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->call('saveImage')
|
||||
->assertHasErrors(['newAuthor', 'newLicenseType', 'newPeopleRightsStatus', 'newPropertyRightsStatus', 'newRightsConfirmed']);
|
||||
|
||||
expect($pr->images()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('license type starts with an explicit placeholder before own photo option', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->call('openUploadForm')
|
||||
->assertSet('newLicenseType', '')
|
||||
->assertSee('Bitte wählen')
|
||||
->assertSee('Eigene Aufnahme');
|
||||
});
|
||||
|
||||
test('title image upload form is collapsed until requested', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->assertSee('Hier fehlt ein Titelbild')
|
||||
->assertSee('Eigenes Titelbild hochladen')
|
||||
->assertDontSee('Bild hierher ziehen oder klicken')
|
||||
->call('openUploadForm')
|
||||
->assertSee('Titelbild hochladen')
|
||||
->assertSee('Bild hierher ziehen oder klicken')
|
||||
->assertDontSee('Als Vorschaubild verwenden')
|
||||
->assertDontSee('Unsicher');
|
||||
});
|
||||
|
||||
test('unclear rights selections are not accepted', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->set('newLicenseType', ImageLicenseType::Own->value)
|
||||
->set('newPeopleRightsStatus', 'unsure')
|
||||
->set('newPropertyRightsStatus', 'unsure')
|
||||
->set('newRightsConfirmed', true)
|
||||
->call('saveImage')
|
||||
->assertHasErrors(['newPeopleRightsStatus', 'newPropertyRightsStatus']);
|
||||
|
||||
expect($pr->images()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('cc license requires a license url', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->set('newLicenseType', ImageLicenseType::CreativeCommons->value)
|
||||
->set('newLicenseDetail', 'cc_by')
|
||||
->set('newPeopleRightsStatus', 'none')
|
||||
->set('newPropertyRightsStatus', 'none')
|
||||
->set('newRightsConfirmed', true)
|
||||
->call('saveImage')
|
||||
->assertHasErrors(['newLicenseUrl']);
|
||||
|
||||
expect($pr->images()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('cc license requires a concrete cc variant', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->set('newLicenseType', ImageLicenseType::CreativeCommons->value)
|
||||
->set('newLicenseUrl', 'https://creativecommons.org/licenses/by/4.0/')
|
||||
->set('newPeopleRightsStatus', 'none')
|
||||
->set('newPropertyRightsStatus', 'none')
|
||||
->set('newRightsConfirmed', true)
|
||||
->call('saveImage')
|
||||
->assertHasErrors(['newLicenseDetail']);
|
||||
|
||||
expect($pr->images()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('other license requires details', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->set('newLicenseType', ImageLicenseType::Other->value)
|
||||
->set('newPeopleRightsStatus', 'none')
|
||||
->set('newPropertyRightsStatus', 'none')
|
||||
->set('newRightsConfirmed', true)
|
||||
->call('saveImage')
|
||||
->assertHasErrors(['newLicenseDetail']);
|
||||
|
||||
expect($pr->images()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('non previewable image uploads fail validation without preview exception', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newImage', UploadedFile::fake()->create('scan.tif', 100, 'image/tiff'))
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->set('newLicenseType', ImageLicenseType::Own->value)
|
||||
->set('newPeopleRightsStatus', 'none')
|
||||
->set('newPropertyRightsStatus', 'none')
|
||||
->set('newRightsConfirmed', true)
|
||||
->call('saveImage')
|
||||
->assertHasErrors(['newImage']);
|
||||
|
||||
expect($pr->images()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('valid upload stores license metadata and confirms rights', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->set('newTitle', 'Maschine im Einsatz')
|
||||
->set('newCopyright', 'Foto: Jane Doe / Beispiel GmbH')
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->set('newLicenseType', ImageLicenseType::Own->value)
|
||||
->set('newSourceUrl', 'https://example.com/source')
|
||||
->set('newPeopleRightsStatus', 'consent')
|
||||
->set('newPropertyRightsStatus', 'cleared')
|
||||
->set('newRightsNotes', 'Freigabe liegt intern vor.')
|
||||
->set('newRightsConfirmed', true)
|
||||
->call('saveImage')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$image = $pr->images()->first();
|
||||
|
||||
expect($image)->not->toBeNull();
|
||||
expect($image->title)->toBe('Maschine im Einsatz');
|
||||
expect($image->copyright)->toBe('Foto: Jane Doe / Beispiel GmbH');
|
||||
expect($image->author)->toBe('Jane Doe');
|
||||
expect($image->license_type)->toBe(ImageLicenseType::Own);
|
||||
expect($image->source_url)->toBe('https://example.com/source');
|
||||
expect($image->people_rights_status)->toBe('consent');
|
||||
expect($image->property_rights_status)->toBe('cleared');
|
||||
expect($image->rights_notes)->toBe('Freigabe liegt intern vor.');
|
||||
expect($image->persons_consent)->toBeTrue();
|
||||
expect($image->rights_confirmed_at)->not->toBeNull();
|
||||
expect($image->is_preview)->toBeTrue();
|
||||
expect($image->path)->toBe($image->variants['cover']);
|
||||
expect($image->width)->toBe(1280);
|
||||
expect($image->height)->toBe(580);
|
||||
|
||||
Storage::disk('public')->assertExists($image->path);
|
||||
|
||||
$originalPath = preg_replace(
|
||||
'#/variants/([^/]+)-cover(\.[^.]+)$#',
|
||||
'/$1$2',
|
||||
$image->path,
|
||||
);
|
||||
|
||||
expect($originalPath)->toBeString()->not->toBe($image->path);
|
||||
Storage::disk('public')->assertMissing($originalPath);
|
||||
});
|
||||
|
||||
test('valid cc upload stores license detail and license url', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->set('newLicenseType', ImageLicenseType::CreativeCommons->value)
|
||||
->set('newLicenseDetail', 'cc_by')
|
||||
->set('newLicenseUrl', 'https://creativecommons.org/licenses/by/4.0/')
|
||||
->set('newPeopleRightsStatus', 'none')
|
||||
->set('newPropertyRightsStatus', 'none')
|
||||
->set('newRightsConfirmed', true)
|
||||
->call('saveImage')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$image = $pr->images()->first();
|
||||
|
||||
expect($image)->not->toBeNull();
|
||||
expect($image->license_type)->toBe(ImageLicenseType::CreativeCommons);
|
||||
expect($image->license_detail)->toBe('cc_by');
|
||||
expect($image->license_url)->toBe('https://creativecommons.org/licenses/by/4.0/');
|
||||
});
|
||||
|
||||
test('existing title image hides upload form and can be removed', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
$image = $pr->images()->create([
|
||||
'disk' => 'public',
|
||||
'path' => 'press-releases/'.$pr->id.'/images/title.jpg',
|
||||
'variants' => ['cover' => 'press-releases/'.$pr->id.'/images/title-cover.jpg'],
|
||||
'title' => 'Messefoto',
|
||||
'copyright' => 'Pressefoto GmbH',
|
||||
'author' => 'Jane Doe',
|
||||
'license_type' => ImageLicenseType::Own->value,
|
||||
'rights_confirmed_at' => now(),
|
||||
'is_preview' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->assertSee('Messefoto')
|
||||
->assertSee('Bildnachweis: Pressefoto GmbH')
|
||||
->assertSee('Titelbild löschen')
|
||||
->assertDontSee('Titelbild hochladen')
|
||||
->call('remove', $image->id)
|
||||
->assertSee('Eigenes Titelbild hochladen');
|
||||
|
||||
expect($pr->images()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('second title image upload is blocked while one exists', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
$pr->images()->create([
|
||||
'disk' => 'public',
|
||||
'path' => 'press-releases/'.$pr->id.'/images/title.jpg',
|
||||
'variants' => ['cover' => 'press-releases/'.$pr->id.'/images/title-cover.jpg'],
|
||||
'author' => 'Jane Doe',
|
||||
'license_type' => ImageLicenseType::Own->value,
|
||||
'rights_confirmed_at' => now(),
|
||||
'is_preview' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newImage', UploadedFile::fake()->image('zweites.jpg', 1200, 800))
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->set('newLicenseType', ImageLicenseType::Own->value)
|
||||
->set('newRightsConfirmed', true)
|
||||
->call('saveImage')
|
||||
->assertHasErrors(['newImage']);
|
||||
|
||||
expect($pr->images()->count())->toBe(1);
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseContentTier;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
|
|
@ -152,3 +154,54 @@ test('admin list zeigt KEINE Sub-Zeilen wenn weder scheduled_at noch embargo_at'
|
|||
->assertDontSee('geplant ·')
|
||||
->assertDontSee('Embargo bis');
|
||||
});
|
||||
|
||||
test('admin list zeigt Content-Score-Stufe', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForIndexPhase8b();
|
||||
$this->actingAs($admin);
|
||||
|
||||
PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
'content_score' => 84,
|
||||
'content_tier' => PressReleaseContentTier::Hochwertig->value,
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.index')
|
||||
->assertSee('84 · Hochwertig');
|
||||
});
|
||||
|
||||
test('admin list zeigt KI-Klassifikations-Badge', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForIndexPhase8b();
|
||||
$this->actingAs($admin);
|
||||
|
||||
PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Yellow->value,
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.index')
|
||||
->assertSee('KI: Gelb');
|
||||
});
|
||||
|
||||
test('admin list filtert nach KI-Klassifikation', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForIndexPhase8b();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$yellow = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Yellow->value,
|
||||
'title' => 'Gelber Grenzfall',
|
||||
]);
|
||||
$green = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
'title' => 'Grüne Mitteilung',
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.index')
|
||||
->set('classificationFilter', PressReleaseClassification::Yellow->value)
|
||||
->assertSee('Gelber Grenzfall')
|
||||
->assertDontSee('Grüne Mitteilung');
|
||||
});
|
||||
|
|
|
|||
110
tests/Feature/PressReleasePlaceholderTest.php
Normal file
110
tests/Feature/PressReleasePlaceholderTest.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleasePlaceholder;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\PressReleaseCoverImage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('placeholder set has eighteen variants and a stable default', function () {
|
||||
expect(PressReleasePlaceholder::cases())->toHaveCount(18);
|
||||
expect(PressReleasePlaceholder::default())->toBe(PressReleasePlaceholder::GridBlue);
|
||||
});
|
||||
|
||||
test('invalid placeholder values fall back to the default', function () {
|
||||
expect(PressReleasePlaceholder::fromValueOrDefault(null))->toBe(PressReleasePlaceholder::default());
|
||||
expect(PressReleasePlaceholder::fromValueOrDefault('does-not-exist'))->toBe(PressReleasePlaceholder::default());
|
||||
expect(PressReleasePlaceholder::fromValueOrDefault('05-lines-green'))->toBe(PressReleasePlaceholder::LinesGreen);
|
||||
expect(PressReleasePlaceholder::fromValueOrDefault('17-signal-green'))->toBe(PressReleasePlaceholder::SignalGreen);
|
||||
});
|
||||
|
||||
test('placeholder variant from a seed is deterministic', function () {
|
||||
$first = PressReleasePlaceholder::fromSeed(4242);
|
||||
$second = PressReleasePlaceholder::fromSeed(4242);
|
||||
|
||||
expect($first)->toBe($second);
|
||||
});
|
||||
|
||||
test('every placeholder svg asset exists on disk', function () {
|
||||
foreach (PressReleasePlaceholder::cases() as $variant) {
|
||||
expect(public_path($variant->path()))->toBeFile();
|
||||
}
|
||||
});
|
||||
|
||||
test('press releases get a deterministic placeholder variant on creation', function () {
|
||||
$pr = PressRelease::factory()->create(['placeholder_variant' => null]);
|
||||
|
||||
expect($pr->placeholder_variant)->toBeInstanceOf(PressReleasePlaceholder::class);
|
||||
});
|
||||
|
||||
test('cover resolver falls back to the placeholder svg when no image exists', function () {
|
||||
$pr = PressRelease::factory()->create(['placeholder_variant' => '05-lines-green']);
|
||||
|
||||
$cover = app(PressReleaseCoverImage::class);
|
||||
|
||||
expect($cover->coverIsPlaceholder($pr))->toBeTrue();
|
||||
expect($cover->coverUrl($pr))->toContain('images/press-release-placeholders/05-lines-green.svg');
|
||||
});
|
||||
|
||||
test('cover resolver prefers the real preview image over the placeholder', function () {
|
||||
$pr = PressRelease::factory()->create(['placeholder_variant' => '01-grid-blue']);
|
||||
|
||||
$pr->images()->create([
|
||||
'disk' => 'public',
|
||||
'path' => 'press/cover.jpg',
|
||||
'variants' => ['large' => 'press/cover-large.jpg'],
|
||||
'is_preview' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$cover = app(PressReleaseCoverImage::class);
|
||||
|
||||
expect($cover->coverIsPlaceholder($pr->fresh()))->toBeFalse();
|
||||
expect($cover->coverUrl($pr->fresh()))->toContain('storage/press/cover-large.jpg');
|
||||
});
|
||||
|
||||
test('cover resolver prefers the 1280x580 cover variant over large', function () {
|
||||
$pr = PressRelease::factory()->create(['placeholder_variant' => '01-grid-blue']);
|
||||
|
||||
$pr->images()->create([
|
||||
'disk' => 'public',
|
||||
'path' => 'press/cover.jpg',
|
||||
'variants' => [
|
||||
'large' => 'press/cover-large.jpg',
|
||||
'cover' => 'press/cover-cover.jpg',
|
||||
],
|
||||
'is_preview' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$cover = app(PressReleaseCoverImage::class);
|
||||
|
||||
expect($cover->coverUrl($pr->fresh()))->toContain('storage/press/cover-cover.jpg');
|
||||
});
|
||||
|
||||
test('cover resolver falls back from cover to large when cover variant is missing', function () {
|
||||
$pr = PressRelease::factory()->create(['placeholder_variant' => '01-grid-blue']);
|
||||
|
||||
$pr->images()->create([
|
||||
'disk' => 'public',
|
||||
'path' => 'press/cover.jpg',
|
||||
'variants' => ['large' => 'press/cover-large.jpg'],
|
||||
'is_preview' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$cover = app(PressReleaseCoverImage::class);
|
||||
|
||||
expect($cover->coverUrl($pr->fresh(), 'cover'))->toContain('storage/press/cover-large.jpg');
|
||||
});
|
||||
|
||||
test('placeholder picker mounts with the current variant and confirms a selection', function () {
|
||||
Volt::test('components.press-release-placeholder-picker', ['current' => '05-lines-green'])
|
||||
->assertSet('selected', '05-lines-green')
|
||||
->call('choose', '08-dots-green')
|
||||
->assertSet('selected', '08-dots-green')
|
||||
->call('confirm')
|
||||
->assertDispatched('placeholder-selected', variant: '08-dots-green');
|
||||
});
|
||||
43
tests/Feature/PressReleasePublishModalPhase8iTest.php
Normal file
43
tests/Feature/PressReleasePublishModalPhase8iTest.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
||||
// Klassifikations-Job nicht inline ausführen, damit der Submit hier nur den
|
||||
// Übergang nach „review" prüft (KI-Routing siehe ClassificationJobTest).
|
||||
Queue::fake();
|
||||
});
|
||||
|
||||
test('customer show renders the publish confirmation modal with legal note and quota', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a();
|
||||
$customer->update(['press_release_quota' => 3, 'press_release_quota_used_this_month' => 1]);
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Pressemitteilung zur Prüfung einreichen')
|
||||
->assertSee('Mit dem Einreichen versichern Sie:')
|
||||
->assertSee('PM-Kontingent diesen Monat')
|
||||
->assertSee('2 / 3');
|
||||
});
|
||||
|
||||
test('submitting from the show modal moves the draft into review and counts quota', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a();
|
||||
$customer->update(['press_release_quota' => 3, 'press_release_quota_used_this_month' => 0]);
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->call('submitForReview')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review);
|
||||
expect($customer->fresh()->press_release_quota_used_this_month)->toBe(1);
|
||||
});
|
||||
57
tests/Feature/PressReleaseQuotaTest.php
Normal file
57
tests/Feature/PressReleaseQuotaTest.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\ResetMonthlyPressReleaseQuota;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
test('remaining quota reflects the used counter', function () {
|
||||
$user = User::factory()->create([
|
||||
'press_release_quota' => 3,
|
||||
'press_release_quota_used_this_month' => 1,
|
||||
]);
|
||||
|
||||
expect($user->pressReleaseQuotaRemaining())->toBe(2);
|
||||
});
|
||||
|
||||
test('submitting a press release for review increments the monthly quota usage', function () {
|
||||
$user = User::factory()->create([
|
||||
'press_release_quota' => 3,
|
||||
'press_release_quota_used_this_month' => 0,
|
||||
]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$user->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => Category::factory()->create()->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
app(PressReleaseService::class)->submitForReview($pr);
|
||||
|
||||
expect($user->fresh()->press_release_quota_used_this_month)->toBe(1);
|
||||
});
|
||||
|
||||
test('monthly reset command zeroes the used counter', function () {
|
||||
User::factory()->count(2)->create(['press_release_quota_used_this_month' => 2]);
|
||||
$untouched = User::factory()->create(['press_release_quota_used_this_month' => 0]);
|
||||
|
||||
$this->artisan(ResetMonthlyPressReleaseQuota::class)->assertSuccessful();
|
||||
|
||||
expect(User::where('press_release_quota_used_this_month', '>', 0)->count())->toBe(0);
|
||||
expect($untouched->fresh()->press_release_quota_used_this_month)->toBe(0);
|
||||
});
|
||||
94
tests/Feature/PressReleaseReclassifyTest.php
Normal file
94
tests/Feature/PressReleaseReclassifyTest.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Jobs\ClassifyPressRelease;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
test('reclassifyIfClassified dispatches a non-routing job for an already classified press release', function () {
|
||||
Queue::fake();
|
||||
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
]);
|
||||
|
||||
app(PressReleaseService::class)->reclassifyIfClassified($pressRelease);
|
||||
|
||||
Queue::assertPushedOn('classification', ClassifyPressRelease::class, function (ClassifyPressRelease $job) use ($pressRelease) {
|
||||
return $job->pressReleaseId === $pressRelease->id && $job->route === false;
|
||||
});
|
||||
});
|
||||
|
||||
test('reclassifyIfClassified does nothing for a never-classified press release', function () {
|
||||
Queue::fake();
|
||||
|
||||
$pressRelease = PressRelease::factory()->create(['classification' => null]);
|
||||
|
||||
app(PressReleaseService::class)->reclassifyIfClassified($pressRelease);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
test('api update of a classified press release re-classifies when the content changes', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$category = Category::factory()->withTranslations()->create();
|
||||
$user->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:write']);
|
||||
|
||||
$this->patchJson("/api/v1/press-releases/{$pressRelease->id}", [
|
||||
'text' => 'Komplett neuer Inhalt, der erneut geprüft werden muss.',
|
||||
])->assertOk();
|
||||
|
||||
Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->route === false);
|
||||
});
|
||||
|
||||
test('api update does not re-classify when content is unchanged', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$category = Category::factory()->withTranslations()->create();
|
||||
$user->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
'keywords' => 'alt',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:write']);
|
||||
|
||||
// Nur Keywords ändern – kein Titel/Text → keine Neuklassifikation.
|
||||
$this->patchJson("/api/v1/press-releases/{$pressRelease->id}", [
|
||||
'keywords' => 'neu',
|
||||
])->assertOk();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\PublishScheduledPressReleases;
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
|
|
@ -118,6 +119,7 @@ test('Command publisht fällige Review-PMs mit scheduled_at <= now', function ()
|
|||
|
||||
$due = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
'scheduled_at' => '2026-06-01 11:55:00',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
|
@ -129,6 +131,22 @@ test('Command publisht fällige Review-PMs mit scheduled_at <= now', function ()
|
|||
expect($fresh->published_at?->toDateTimeString())->toBe('2026-06-01 11:55:00');
|
||||
});
|
||||
|
||||
test('Command ignoriert fällige gelbe PMs (manuelle Prüfung)', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 12:00:00');
|
||||
|
||||
$yellow = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Yellow->value,
|
||||
'scheduled_at' => '2026-06-01 11:55:00',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
Artisan::call(PublishScheduledPressReleases::class);
|
||||
|
||||
expect($yellow->fresh()->status)->toBe(PressReleaseStatus::Review);
|
||||
});
|
||||
|
||||
test('Command ignoriert PMs mit scheduled_at in der Zukunft', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 12:00:00');
|
||||
|
|
@ -164,6 +182,7 @@ test('Command läuft mit dry-run ohne Statusänderung', function () {
|
|||
|
||||
$due = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
'scheduled_at' => '2026-06-01 11:50:00',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
|
@ -179,6 +198,7 @@ test('Command publisht maximal --limit pro Lauf', function () {
|
|||
|
||||
PressRelease::factory()->count(3)->state([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
'scheduled_at' => '2026-06-01 11:50:00',
|
||||
'published_at' => null,
|
||||
])->create();
|
||||
|
|
|
|||
|
|
@ -73,9 +73,10 @@ test('customer show zeigt geplante Veröffentlichung wenn gesetzt', function ()
|
|||
]);
|
||||
$this->actingAs($customer);
|
||||
|
||||
// 09:30 UTC wird in Europe/Berlin (CEST, +02:00) als 11:30 angezeigt.
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Geplante Veröffentlichung')
|
||||
->assertSee('01.07.2026 09:30');
|
||||
->assertSee('01.07.2026 11:30');
|
||||
});
|
||||
|
||||
test('customer show zeigt Sperrfrist wenn embargo_at gesetzt', function () {
|
||||
|
|
@ -86,9 +87,10 @@ test('customer show zeigt Sperrfrist wenn embargo_at gesetzt', function () {
|
|||
]);
|
||||
$this->actingAs($customer);
|
||||
|
||||
// 12:00 UTC wird in Europe/Berlin (CEST, +02:00) als 14:00 angezeigt.
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Sperrfrist bis')
|
||||
->assertSee('15.08.2026 12:00');
|
||||
->assertSee('15.08.2026 14:00');
|
||||
});
|
||||
|
||||
test('customer show zeigt Kein-Export-Hinweis wenn no_export aktiv', function () {
|
||||
|
|
|
|||
|
|
@ -10,12 +10,18 @@ use App\Services\Admin\AdminPerformanceCache;
|
|||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
||||
// Klassifikations-Job nicht inline ausführen (sync-Queue im Test), damit
|
||||
// submitForReview hier nur den Übergang nach „review" prüft. Das KI-Routing
|
||||
// (Grün→publish etc.) wird separat in PressReleaseClassificationJobTest getestet.
|
||||
Queue::fake();
|
||||
});
|
||||
|
||||
test('submit for review logs a status change with customer source', function () {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue