web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)
Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und Medienpfade sowie die PressRelease-Helfer schreiben jetzt withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein. Der Vorschau-Test prüfte bisher forceDelete statt delete. F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER. Neuer Index contacts_email_idx. F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt- Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache vorher nicht und traf den Fehler nie. F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst (ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung gewinnt die Erstquelle. F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren Vertrag. F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht als verlorenes Logo. Block 4 — KI-Prüfung und Scheduler F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen (ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum aktuellen Text passt, sondern plant eine Prüfung mit Routing ein. F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period). Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der Reset ist idempotent und lässt den laufenden Monat in Ruhe. F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen Fehler-Exit-Code, damit der Failure-Hook greift. F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt. F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
62202a4b13
commit
f235156de9
73 changed files with 1746 additions and 170 deletions
106
tests/Feature/Admin/CompanyLogoReplacementTest.php
Normal file
106
tests/Feature/Admin/CompanyLogoReplacementTest.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use App\Services\Image\ImageService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Logo-Tausch: erst die neue Datei speichern, dann die DB-Referenz wechseln,
|
||||
* dann erst das alte Logo löschen. Ein Storage-Fehler darf weder Erfolg
|
||||
* vortäuschen noch das bisherige Logo kosten (F23).
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function failingPublicDisk(): FilesystemAdapter
|
||||
{
|
||||
$disk = Storage::fake('public');
|
||||
|
||||
// Proxy auf den echten Fake: nur put() schlägt fehl, alles andere —
|
||||
// exists(), delete(), Livewires Temp-Upload — läuft echt.
|
||||
$failingDisk = Mockery::mock($disk);
|
||||
$failingDisk->shouldReceive('put')->andReturn(false);
|
||||
Storage::set('public', $failingDisk);
|
||||
|
||||
return $disk;
|
||||
}
|
||||
|
||||
test('the image service refuses to report a logo that was not written', function () {
|
||||
/** @var TestCase $this */
|
||||
failingPublicDisk();
|
||||
|
||||
expect(fn () => app(ImageService::class)->storeCompanyLogo(UploadedFile::fake()->image('logo.png', 400, 400), 'presseecho', 1))
|
||||
->toThrow(RuntimeException::class, 'Das Logo konnte nicht gespeichert werden.');
|
||||
});
|
||||
|
||||
test('the image service refuses to report a press release image that was not written', function () {
|
||||
/** @var TestCase $this */
|
||||
failingPublicDisk();
|
||||
|
||||
expect(fn () => app(ImageService::class)->storePressReleaseImage(UploadedFile::fake()->image('bild.jpg', 800, 600), 1, 'public'))
|
||||
->toThrow(RuntimeException::class, 'Das Bild konnte nicht gespeichert werden.');
|
||||
});
|
||||
|
||||
test('a failed logo write in the admin form keeps the previous logo and reports a validation error', function () {
|
||||
/** @var TestCase $this */
|
||||
$disk = failingPublicDisk();
|
||||
$disk->put('company-logos/presseecho/9/alt.png', 'alt');
|
||||
$disk->put('company-logos/presseecho/9/alt-sq.png', 'alt-sq');
|
||||
|
||||
$company = Company::factory()->presseecho()->create([
|
||||
'logo_path' => 'company-logos/presseecho/9/alt.png',
|
||||
'logo_variants' => ['sq' => 'company-logos/presseecho/9/alt-sq.png'],
|
||||
]);
|
||||
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
LivewireVolt::actingAs($admin)
|
||||
->test('admin.companies.edit', ['id' => $company->id])
|
||||
->set('logo', UploadedFile::fake()->image('neu.png', 400, 400))
|
||||
->call('update')
|
||||
->assertHasErrors(['logo']);
|
||||
|
||||
$company->refresh();
|
||||
|
||||
expect($company->logo_path)->toBe('company-logos/presseecho/9/alt.png')
|
||||
->and($company->logo_variants)->toBe(['sq' => 'company-logos/presseecho/9/alt-sq.png'])
|
||||
->and($disk->exists('company-logos/presseecho/9/alt.png'))->toBeTrue()
|
||||
->and($disk->exists('company-logos/presseecho/9/alt-sq.png'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a successful logo replacement in the admin form removes the previous files only afterwards', function () {
|
||||
/** @var TestCase $this */
|
||||
$disk = Storage::fake('public');
|
||||
$disk->put('company-logos/presseecho/9/alt.png', 'alt');
|
||||
$disk->put('company-logos/presseecho/9/alt-sq.png', 'alt-sq');
|
||||
|
||||
$company = Company::factory()->presseecho()->create([
|
||||
'logo_path' => 'company-logos/presseecho/9/alt.png',
|
||||
'logo_variants' => ['sq' => 'company-logos/presseecho/9/alt-sq.png'],
|
||||
]);
|
||||
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
LivewireVolt::actingAs($admin)
|
||||
->test('admin.companies.edit', ['id' => $company->id])
|
||||
->set('logo', UploadedFile::fake()->image('neu.png', 400, 400))
|
||||
->call('update')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$company->refresh();
|
||||
|
||||
expect($company->logo_path)->not->toBe('company-logos/presseecho/9/alt.png')
|
||||
->and($disk->exists((string) $company->logo_path))->toBeTrue()
|
||||
->and($disk->exists('company-logos/presseecho/9/alt.png'))->toBeFalse()
|
||||
->and($disk->exists('company-logos/presseecho/9/alt-sq.png'))->toBeFalse();
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
|
|
@ -109,3 +110,97 @@ test('api user cannot access another users press release', function () {
|
|||
$this->getJson("/api/v1/press-releases/{$pressRelease->id}")
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('a soft deleted press release disappears from the api (Audit F04)', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
$pressRelease = PressRelease::factory()->create(['user_id' => $user->id, 'title' => 'Bald geloescht']);
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
|
||||
|
||||
$this->deleteJson("/api/v1/press-releases/{$pressRelease->id}")->assertNoContent();
|
||||
|
||||
$this->getJson('/api/v1/press-releases')->assertOk()->assertJsonMissing(['title' => 'Bald geloescht']);
|
||||
$this->getJson("/api/v1/press-releases/{$pressRelease->id}")->assertForbidden();
|
||||
$this->patchJson("/api/v1/press-releases/{$pressRelease->id}", ['title' => 'Wiederbelebt'])->assertForbidden();
|
||||
|
||||
expect(PressRelease::withoutGlobalScopes()->withTrashed()->find($pressRelease->id)?->title)->toBe('Bald geloescht');
|
||||
});
|
||||
|
||||
test('a soft deleted company is neither listed nor usable for new press releases (Audit F04)', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
$company = Company::factory()->presseecho()->create(['name' => 'Geloeschte Firma']);
|
||||
$category = Category::factory()->withTranslations()->create();
|
||||
$user->companies()->attach($company->id, ['role' => 'owner']);
|
||||
$company->delete();
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']);
|
||||
|
||||
$this->getJson('/api/v1/companies')->assertOk()->assertJsonMissing(['name' => 'Geloeschte Firma']);
|
||||
$this->getJson("/api/v1/companies/{$company->id}")->assertForbidden();
|
||||
|
||||
$this->postJson('/api/v1/press-releases', [
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'language' => 'de',
|
||||
'title' => 'Fuer eine geloeschte Firma',
|
||||
'text' => 'Inhalt',
|
||||
])->assertForbidden();
|
||||
});
|
||||
|
||||
test('api list parameters are validated instead of passed through (Audit F11)', function (array $query) {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
PressRelease::factory()->count(3)->create(['user_id' => $user->id]);
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:read', 'companies:read']);
|
||||
|
||||
$this->getJson('/api/v1/press-releases?'.http_build_query($query))->assertUnprocessable();
|
||||
$this->getJson('/api/v1/companies?'.http_build_query(array_intersect_key($query, ['per_page' => true])) ?: '/api/v1/companies?per_page=-1')
|
||||
->assertUnprocessable();
|
||||
})->with([
|
||||
'negative per_page' => [['per_page' => -1]],
|
||||
'zero per_page' => [['per_page' => 0]],
|
||||
'per_page above the cap' => [['per_page' => 101]],
|
||||
'textual per_page' => [['per_page' => 'alle']],
|
||||
'status as array' => [['status' => ['draft'], 'per_page' => -1]],
|
||||
'unknown status' => [['status' => 'gibt-es-nicht', 'per_page' => -1]],
|
||||
]);
|
||||
|
||||
test('api list accepts the documented parameter range and always limits the query (Audit F11)', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
PressRelease::factory()->count(3)->create(['user_id' => $user->id]);
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:read']);
|
||||
|
||||
$this->getJson('/api/v1/press-releases?per_page=1')->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('meta.per_page', 1);
|
||||
$this->getJson('/api/v1/press-releases?per_page=100&status=draft')->assertOk()->assertJsonCount(3, 'data');
|
||||
});
|
||||
|
||||
test('api press release input rejects unsupported languages and inactive or foreign categories (Audit F11)', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$user->companies()->attach($company->id, ['role' => 'owner']);
|
||||
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Presseecho, 'is_active' => true]);
|
||||
$inactive = Category::factory()->withTranslations()->create(['portal' => Portal::Presseecho, 'is_active' => false]);
|
||||
$foreign = Category::factory()->withTranslations()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
|
||||
|
||||
$payload = fn (array $overrides): array => [
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'language' => 'de',
|
||||
'title' => 'Vertragspruefung',
|
||||
'text' => 'Inhalt',
|
||||
...$overrides,
|
||||
];
|
||||
|
||||
$this->postJson('/api/v1/press-releases', $payload(['language' => 'zz']))->assertUnprocessable()->assertJsonValidationErrors(['language']);
|
||||
$this->postJson('/api/v1/press-releases', $payload(['category_id' => $inactive->id]))->assertUnprocessable()->assertJsonValidationErrors(['category_id']);
|
||||
$this->postJson('/api/v1/press-releases', $payload(['category_id' => $foreign->id]))->assertUnprocessable()->assertJsonValidationErrors(['category_id']);
|
||||
$this->postJson('/api/v1/press-releases', $payload(['language' => 'en']))->assertCreated();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -392,3 +392,14 @@ test('the admin form keeps the auto origin of untouched rows and marks changed r
|
|||
$user->syncCompaniesWithRoles([$auto->id], [$auto->id => 'owner']);
|
||||
expect($user->companies()->withoutGlobalScopes()->whereKey($auto->id)->first()->pivot->granted_via)->toBe('manual');
|
||||
});
|
||||
|
||||
test('a company email stored with capitals still links the lowercase account on login (Audit F22)', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create(['email' => 'gross@example.test', 'is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
$company = Company::factory()->presseecho()->create(['email' => 'Gross@Example.test']);
|
||||
|
||||
Volt::test('auth.login')->set('email', 'gross@example.test')->set('password', 'password')->call('login')->assertHasNoErrors();
|
||||
|
||||
expect(User::query()->findOrFail($user->id)->accessibleCompanyIds())->toContain($company->id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -376,3 +380,105 @@ test('member customer cannot manage press kit company master data', function ()
|
|||
->call('startEditCompany')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('a deleted press kit contact disappears from the list and cannot be edited (Audit F04)', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
$company = Company::factory()->presseecho()->create(['owner_user_id' => $customer->id]);
|
||||
$contact = Contact::factory()->for($company)->create([
|
||||
'first_name' => 'Gelöschte',
|
||||
'last_name' => 'Kontaktperson',
|
||||
'portal' => $company->portal->value,
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
|
||||
->assertSee('Kontaktperson')
|
||||
->call('deleteContact', $contact->id)
|
||||
->assertDontSee('Kontaktperson');
|
||||
|
||||
expect(fn () => LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
|
||||
->assertDontSee('Kontaktperson')
|
||||
->call('editContact', $contact->id))
|
||||
->toThrow(ModelNotFoundException::class);
|
||||
});
|
||||
|
||||
test('saving or deleting a press kit contact drops the cached newsroom page', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$company = Company::factory()->presseecho()->create(['is_active' => true]);
|
||||
$customer->companies()->attach($company->id, ['role' => 'responsible']);
|
||||
PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Presseecho)
|
||||
->for($company)
|
||||
->create(['language' => 'de', 'published_at' => now()->subDay()]);
|
||||
|
||||
$url = 'https://presseecho.test/de/newsroom/'.$company->slug;
|
||||
$cacheKey = fn (): string => webPageCacheKey('web.newsroom.presseecho-both.de.'.$company->slug.'.page.1');
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
// Aufwärmen — die Kontakte liegen mit im gecachten Seitenpaket.
|
||||
$this->get($url)->assertOk();
|
||||
expect(Cache::has($cacheKey()))->toBeTrue();
|
||||
|
||||
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
|
||||
->call('startCreateContact')
|
||||
->set('contactLastName', 'Neu')
|
||||
->set('contactEmail', 'neu@example.test')
|
||||
->call('saveContact')
|
||||
->assertHasNoErrors();
|
||||
|
||||
// Der Key trägt die Generation: nach einem Flush existiert der alte
|
||||
// Eintrag unter dem neuen Key nicht mehr.
|
||||
expect(Cache::has($cacheKey()))->toBeFalse();
|
||||
|
||||
$contact = Contact::query()->where('company_id', $company->id)->firstOrFail();
|
||||
|
||||
$this->get($url)->assertOk();
|
||||
expect(Cache::has($cacheKey()))->toBeTrue();
|
||||
|
||||
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
|
||||
->call('deleteContact', $contact->id);
|
||||
|
||||
expect(Cache::has($cacheKey()))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a failed logo write keeps the previous press kit logo and reports a validation error', function () {
|
||||
/** @var TestCase $this */
|
||||
$disk = Storage::fake('public');
|
||||
$disk->put('company-logos/presseecho/7/alt.png', 'alt');
|
||||
$disk->put('company-logos/presseecho/7/alt-sq.png', 'alt-sq');
|
||||
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$company = Company::factory()->presseecho()->create([
|
||||
'logo_path' => 'company-logos/presseecho/7/alt.png',
|
||||
'logo_variants' => ['sq' => 'company-logos/presseecho/7/alt-sq.png'],
|
||||
]);
|
||||
$customer->companies()->attach($company->id, ['role' => 'responsible']);
|
||||
|
||||
// Proxy auf den echten Fake: nur put() schlägt fehl (voller Datenträger,
|
||||
// Rechteproblem), alles andere — auch Livewires Temp-Upload — läuft echt.
|
||||
$failingDisk = Mockery::mock($disk);
|
||||
$failingDisk->shouldReceive('put')->andReturn(false);
|
||||
Storage::set('public', $failingDisk);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
|
||||
->call('startEditCompany')
|
||||
->set('companyLogo', UploadedFile::fake()->image('neu.png', 400, 400))
|
||||
->call('saveCompany')
|
||||
->assertHasErrors(['companyLogo']);
|
||||
|
||||
$company->refresh();
|
||||
|
||||
expect($company->logo_path)->toBe('company-logos/presseecho/7/alt.png')
|
||||
->and($company->logo_variants)->toBe(['sq' => 'company-logos/presseecho/7/alt-sq.png'])
|
||||
->and($disk->exists('company-logos/presseecho/7/alt.png'))->toBeTrue()
|
||||
->and($disk->exists('company-logos/presseecho/7/alt-sq.png'))->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
71
tests/Feature/GlobalScopeGuardTest.php
Normal file
71
tests/Feature/GlobalScopeGuardTest.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* `withoutGlobalScopes()` ohne Argument entfernt neben dem PortalScope auch
|
||||
* den Soft-Delete-Filter (Audit F04): gelöschte Meldungen, Firmen und
|
||||
* Kontakte tauchten in API, Vorschau, Medien, Kundenpanel und auf der
|
||||
* öffentlichen Meldungsseite wieder auf. Öffentliche, Kunden- und API-Pfade
|
||||
* schreiben deshalb `withoutGlobalScope(PortalScope::class)`.
|
||||
*
|
||||
* Die Liste unten sind die bewusst verbliebenen Stellen: Admin-Verwaltung
|
||||
* (darf Gelöschtes sehen), Importer und Befehle, Slug-Eindeutigkeit (muss
|
||||
* gelöschte Zeilen einschließen) und die Newsletter-Anmeldung. Wer eine
|
||||
* neue Stelle braucht, trägt sie hier ein und begründet das im Commit.
|
||||
*/
|
||||
test('withoutGlobalScopes() without arguments stays confined to admin, import and slug code (Audit F04)', function () {
|
||||
$allowed = [
|
||||
'app/Console/Commands/CheckMailDomains.php',
|
||||
'app/Console/Commands/ImportNewsletterBounces.php',
|
||||
'app/Console/Commands/ListCompanyMerges.php',
|
||||
'app/Console/Commands/MigrateLegacyMedia.php',
|
||||
'app/Console/Commands/PurgeExpiredPressReleaseDrafts.php',
|
||||
'app/Console/Commands/RetireAgencyCompanyType.php',
|
||||
'app/Console/Commands/SeedLegacyApiTestdata.php',
|
||||
'app/Console/Commands/SyncCompanyLogos.php',
|
||||
'app/Console/Commands/SyncPressReleaseImages.php',
|
||||
'app/Http/Controllers/Admin/DashboardController.php',
|
||||
'app/Http/Controllers/Api/V1/NewsletterSubscriptionController.php',
|
||||
'app/Http/Controllers/Web/LegacyMediaRedirectController.php',
|
||||
'app/Livewire/Concerns/HandlesAdminPressReleaseActions.php',
|
||||
'app/Models/Company.php',
|
||||
'app/Models/Concerns/HasUniqueSlug.php',
|
||||
'app/Services/Admin/AdminPerformanceCache.php',
|
||||
'app/Services/Company/CompanyMergeService.php',
|
||||
'app/Services/Import/CompanyImporter.php',
|
||||
'app/Services/Import/ContactImporter.php',
|
||||
'app/Services/Import/PressReleaseImporter.php',
|
||||
'app/Services/Mail/SubmitterContactReport.php',
|
||||
'app/Services/PressRelease/CanonicalAssigner.php',
|
||||
'app/Services/PressRelease/CorrectionService.php',
|
||||
'app/Services/PressRelease/ProofPdfRenderer.php',
|
||||
'app/Services/SearchConsole/IndexStatusReport.php',
|
||||
'resources/views/livewire/admin/categories/edit.blade.php',
|
||||
'resources/views/livewire/admin/categories/index.blade.php',
|
||||
'resources/views/livewire/admin/companies/index.blade.php',
|
||||
'resources/views/livewire/admin/companies/show.blade.php',
|
||||
'resources/views/livewire/admin/contacts/create.blade.php',
|
||||
'resources/views/livewire/admin/contacts/edit.blade.php',
|
||||
'resources/views/livewire/admin/contacts/index.blade.php',
|
||||
'resources/views/livewire/admin/press-releases/create.blade.php',
|
||||
'resources/views/livewire/admin/press-releases/edit.blade.php',
|
||||
'resources/views/livewire/admin/press-releases/index.blade.php',
|
||||
'resources/views/livewire/admin/press-releases/show.blade.php',
|
||||
'resources/views/livewire/admin/reports/submitter-contacts.blade.php',
|
||||
'resources/views/livewire/admin/users.blade.php',
|
||||
'resources/views/livewire/admin/users/create.blade.php',
|
||||
'resources/views/livewire/admin/users/edit.blade.php',
|
||||
];
|
||||
|
||||
$hits = collect([app_path(), resource_path('views'), base_path('routes')])
|
||||
->flatMap(fn (string $dir) => File::allFiles($dir))
|
||||
->filter(fn ($file) => str_ends_with($file->getFilename(), '.php'))
|
||||
->filter(fn ($file) => str_contains($file->getContents(), 'withoutGlobalScopes()'))
|
||||
->map(fn ($file) => str_replace(base_path().'/', '', $file->getPathname()))
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect(array_values(array_diff($hits, $allowed)))->toBe([], 'Neue Stelle mit withoutGlobalScopes() ohne Argument – bitte withoutGlobalScope(PortalScope::class) verwenden oder die Ausnahme hier begründen.');
|
||||
});
|
||||
|
|
@ -31,7 +31,11 @@ function publishedReleaseWithContact(string $contactEmail = 'presse@firma.exampl
|
|||
|
||||
function detailUrlFor(PressRelease $pressRelease): string
|
||||
{
|
||||
return 'https://presseecho.test/de/press-release/'.$pressRelease->slug;
|
||||
// Die Zuordnung prüft den Host: Die URL muss zum Portal der PM gehören
|
||||
// (F12) — die Factory würfelt das Portal.
|
||||
$base = (string) config('domains.domains.'.$pressRelease->portal->value.'.url');
|
||||
|
||||
return $base.'/de/press-release/'.$pressRelease->slug;
|
||||
}
|
||||
|
||||
test('the intake sends a magic link to the stored press contact, never to a form address', function () {
|
||||
|
|
|
|||
|
|
@ -256,3 +256,42 @@ test('attachments cannot be uploaded when PR is published', function () {
|
|||
|
||||
expect($pr->attachments()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a failed disk write is reported instead of returning file metadata', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr] = makeDraftWithOwner();
|
||||
|
||||
// Proxy auf den echten Fake: nur put() schlägt fehl (voller Datenträger,
|
||||
// Rechteproblem — die Disks sind mit throw=false konfiguriert, F23).
|
||||
$failingDisk = Mockery::mock(privateAttachmentDisk());
|
||||
$failingDisk->shouldReceive('put')->andReturn(false);
|
||||
Storage::set('local', $failingDisk);
|
||||
|
||||
$file = UploadedFile::fake()->create('factsheet.pdf', 200, 'application/pdf');
|
||||
|
||||
expect(fn () => app(PressReleaseAttachmentStorage::class)->store($file, $pr->id))
|
||||
->toThrow(RuntimeException::class, 'Der Anhang konnte nicht gespeichert werden.');
|
||||
});
|
||||
|
||||
test('a failed disk write creates no attachment row', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
|
||||
|
||||
$this->actingAs($owner);
|
||||
|
||||
$failingDisk = Mockery::mock(privateAttachmentDisk());
|
||||
$failingDisk->shouldReceive('put')->andReturn(false);
|
||||
Storage::set('local', $failingDisk);
|
||||
|
||||
$file = UploadedFile::fake()->create('factsheet.pdf', 200, 'application/pdf');
|
||||
|
||||
expect(fn () => LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newFile', $file)
|
||||
->set('newTitle', 'Factsheet Q1')
|
||||
->call('upload'))
|
||||
->toThrow(RuntimeException::class);
|
||||
|
||||
// Kein Datensatz ohne Datei: Sonst stünde im Pressefach ein Anhang, der
|
||||
// beim Abruf ins Leere läuft.
|
||||
expect(PressReleaseAttachment::query()->where('press_release_id', $pr->id)->count())->toBe(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -282,3 +282,53 @@ test('submitForReview dispatches the classification job onto the classification
|
|||
return $job->pressReleaseId === $pressRelease->id;
|
||||
});
|
||||
});
|
||||
|
||||
test('a classification for content that changed during the analysis is discarded and re-queued (Audit F07)', function () {
|
||||
Queue::fake();
|
||||
config()->set('scoring.classification.provider', 'openai');
|
||||
config()->set('services.openai.api_key', 'test-key');
|
||||
|
||||
$pressRelease = PressRelease::factory()->create(['title' => 'Version eins', 'text' => 'Inhalt eins', 'status' => PressReleaseStatus::Review->value]);
|
||||
$hashAtDispatch = $pressRelease->content_hash;
|
||||
|
||||
// Während der KI-Aufruf läuft, speichert ein Admin Version zwei.
|
||||
Http::fake(function () use ($pressRelease) {
|
||||
PressRelease::withoutGlobalScopes()->whereKey($pressRelease->id)->first()->update(['text' => 'Inhalt zwei']);
|
||||
|
||||
return Http::response(['choices' => [['message' => ['content' => json_encode(['classification' => 'green', 'reasons' => []])]]]], 200);
|
||||
});
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id, route: true, contentHash: $hashAtDispatch))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
$fresh = $pressRelease->fresh();
|
||||
// Das alte Ergebnis darf Version zwei weder bewerten noch freigeben.
|
||||
expect($fresh->classification)->toBeNull()
|
||||
->and($fresh->status)->toBe(PressReleaseStatus::Review);
|
||||
|
||||
$audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail();
|
||||
expect($audit->discarded_at)->not->toBeNull()
|
||||
->and($audit->content_hash)->toBe($hashAtDispatch);
|
||||
|
||||
Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $pressRelease->id
|
||||
&& $job->route === true
|
||||
&& $job->contentHash === $fresh->content_hash);
|
||||
});
|
||||
|
||||
test('a classification for unchanged content is stored with its content hash', function () {
|
||||
fakeOpenAiClassification('green');
|
||||
|
||||
$pressRelease = PressRelease::factory()->create(['title' => 'Stabil', 'text' => 'Inhalt']);
|
||||
|
||||
(new ClassifyPressRelease($pressRelease->id, route: false, contentHash: $pressRelease->content_hash))->handle(
|
||||
app(ClassificationManager::class),
|
||||
app(PressReleaseService::class),
|
||||
);
|
||||
|
||||
$audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail();
|
||||
expect($audit->content_hash)->toBe($pressRelease->content_hash)
|
||||
->and($audit->discarded_at)->toBeNull()
|
||||
->and($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Green);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -149,3 +149,38 @@ test('rescoreIfScored dispatches only for an already scored press release', func
|
|||
|
||||
Queue::assertPushed(ScorePressRelease::class, 1);
|
||||
});
|
||||
|
||||
test('a score for content that changed during the analysis is discarded and re-queued (Audit F07)', function () {
|
||||
Queue::fake();
|
||||
config()->set('scoring.content_score.provider', 'openai');
|
||||
config()->set('services.openai.api_key', 'test-key');
|
||||
|
||||
$pressRelease = PressRelease::factory()->create(['title' => 'Version eins', 'text' => 'Inhalt eins']);
|
||||
$hashAtDispatch = $pressRelease->content_hash;
|
||||
|
||||
Http::fake(function () use ($pressRelease) {
|
||||
PressRelease::withoutGlobalScopes()->whereKey($pressRelease->id)->first()->update(['text' => 'Inhalt zwei']);
|
||||
|
||||
return Http::response(['choices' => [['message' => ['content' => json_encode(['score' => 90, 'breakdown' => ['pressestil' => 20]])]]]], 200);
|
||||
});
|
||||
|
||||
(new ScorePressRelease($pressRelease->id, contentHash: $hashAtDispatch))->handle(app(ContentScoreManager::class));
|
||||
|
||||
$fresh = $pressRelease->fresh();
|
||||
expect($fresh->content_score)->toBeNull();
|
||||
|
||||
$audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail();
|
||||
expect($audit->discarded_at)->not->toBeNull();
|
||||
|
||||
Queue::assertPushed(ScorePressRelease::class, fn (ScorePressRelease $job) => $job->pressReleaseId === $pressRelease->id
|
||||
&& $job->contentHash === $fresh->content_hash);
|
||||
});
|
||||
|
||||
test('the score job has a timeout that fits the http budget and retry_after (Audit F17)', function () {
|
||||
$job = new ScorePressRelease(1);
|
||||
|
||||
// Drei Versuche à 30 s plus Verbindungsaufbau passen nicht in die 60 s des
|
||||
// Workers; der Job trägt sein eigenes Budget, und retry_after liegt darüber.
|
||||
expect($job->timeout)->toBeGreaterThanOrEqual(120)
|
||||
->and((int) config('queue.connections.database.retry_after'))->toBeGreaterThan($job->timeout);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ test('invalid token returns 404', function () {
|
|||
->assertSee('ungültig');
|
||||
});
|
||||
|
||||
test('preview link for soft deleted press release returns 404', function () {
|
||||
test('preview link for permanently deleted press release returns 404', function () {
|
||||
/** @var TestCase $this */
|
||||
$owner = User::factory()->create();
|
||||
$pr = PressRelease::factory()->create(['user_id' => $owner->id]);
|
||||
|
|
@ -247,6 +247,22 @@ test('preview link for soft deleted press release returns 404', function () {
|
|||
->assertStatus(404);
|
||||
});
|
||||
|
||||
test('preview link for soft deleted press release returns 404 (Audit F04)', function () {
|
||||
/** @var TestCase $this */
|
||||
$owner = User::factory()->create();
|
||||
$pr = PressRelease::factory()->create(['user_id' => $owner->id, 'title' => 'Geloeschte Vorschau-Meldung']);
|
||||
|
||||
$share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner);
|
||||
|
||||
// Normale Löschung (Soft Delete) – der Vorschau-Token darf die Meldung
|
||||
// nicht mehr laden, auch wenn die Zeile noch existiert.
|
||||
$pr->delete();
|
||||
|
||||
$this->get(route('press-releases.preview', ['token' => $share['plain_token']]))
|
||||
->assertStatus(404)
|
||||
->assertDontSee('Geloeschte Vorschau-Meldung');
|
||||
});
|
||||
|
||||
test('share panel offers a direct open button for single portal releases', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer, $pr] = makePreviewCustomerWithPressRelease(Portal::Presseecho);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use App\Models\UserPaymentOption;
|
|||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\PressRelease\QuotaExceededException;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -236,3 +237,38 @@ test('monthly reset command zeroes the used counter', function () {
|
|||
expect(User::where('press_release_quota_used_this_month', '>', 0)->count())->toBe(0);
|
||||
expect($untouched->fresh()->press_release_quota_used_this_month)->toBe(0);
|
||||
});
|
||||
|
||||
test('quota consumption resets lazily when the calendar month changes, without the cron (Audit F18)', function () {
|
||||
Carbon::setTestNow('2026-09-18 10:00:00');
|
||||
|
||||
$user = quotaTestSubscriber(planQuota: 3, used: 3);
|
||||
// Verbrauch aus dem Vormonat – der Reset-Cron ist ausgefallen.
|
||||
$user->forceFill(['press_release_quota_period' => '2026-08'])->save();
|
||||
|
||||
expect($user->fresh()->pressReleaseQuotaRemaining())->toBe(3);
|
||||
|
||||
$pr = quotaTestPressRelease($user, 'review');
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
|
||||
$fresh = $user->fresh();
|
||||
expect($fresh->press_release_quota_used_this_month)->toBe(1)
|
||||
->and($fresh->press_release_quota_period)->toBe('2026-09')
|
||||
->and($fresh->pressReleaseQuotaRemaining())->toBe(2);
|
||||
});
|
||||
|
||||
test('the monthly reset is idempotent and leaves this month\'s consumption alone (Audit F18)', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-09-01 00:05:00');
|
||||
|
||||
$thisMonth = User::factory()->create(['press_release_quota_used_this_month' => 2, 'press_release_quota_period' => '2026-09']);
|
||||
$lastMonth = User::factory()->create(['press_release_quota_used_this_month' => 2, 'press_release_quota_period' => '2026-08']);
|
||||
$unknown = User::factory()->create(['press_release_quota_used_this_month' => 2, 'press_release_quota_period' => null]);
|
||||
|
||||
$this->artisan(ResetMonthlyPressReleaseQuota::class)->assertSuccessful();
|
||||
$this->artisan(ResetMonthlyPressReleaseQuota::class)->assertSuccessful();
|
||||
|
||||
expect($thisMonth->fresh()->press_release_quota_used_this_month)->toBe(2)
|
||||
->and($lastMonth->fresh()->press_release_quota_used_this_month)->toBe(0)
|
||||
->and($lastMonth->fresh()->press_release_quota_period)->toBe('2026-09')
|
||||
->and($unknown->fresh()->press_release_quota_used_this_month)->toBe(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
use App\Console\Commands\PublishScheduledPressReleases;
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Jobs\ClassifyPressRelease;
|
||||
use App\Models\KiAudit;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
|
|
@ -10,6 +12,7 @@ use Database\Seeders\RolesAndPermissionsSeeder;
|
|||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
|
|
@ -244,3 +247,72 @@ test('Command publisht maximal --limit pro Lauf', function () {
|
|||
|
||||
expect($publishedCount)->toBe(2);
|
||||
});
|
||||
|
||||
test('the scheduler reports failure when a due release cannot be published (Audit F20)', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 12:00:00');
|
||||
|
||||
PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
'scheduled_at' => '2026-06-01 11:55:00',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
$this->mock(PressReleaseService::class)
|
||||
->shouldReceive('publish')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Datenbank nicht erreichbar'));
|
||||
|
||||
$this->artisan(PublishScheduledPressReleases::class)->assertFailed();
|
||||
});
|
||||
|
||||
test('the scheduler re-checks a due release whose classification predates the last edit instead of publishing it (Audit F07)', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 12:00:00');
|
||||
Queue::fake();
|
||||
|
||||
$stale = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
'scheduled_at' => '2026-06-01 11:55:00',
|
||||
'published_at' => null,
|
||||
]);
|
||||
KiAudit::factory()->create([
|
||||
'press_release_id' => $stale->id,
|
||||
'type' => KiAudit::TYPE_CLASSIFICATION,
|
||||
'result' => 'green',
|
||||
'content_hash' => str_repeat('0', 64),
|
||||
]);
|
||||
|
||||
$current = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
'scheduled_at' => '2026-06-01 11:55:00',
|
||||
'published_at' => null,
|
||||
]);
|
||||
KiAudit::factory()->create([
|
||||
'press_release_id' => $current->id,
|
||||
'type' => KiAudit::TYPE_CLASSIFICATION,
|
||||
'result' => 'green',
|
||||
'content_hash' => $current->content_hash,
|
||||
]);
|
||||
|
||||
// Ohne Audit (Altbestand vor der Hash-Bindung) wird wie bisher veröffentlicht.
|
||||
$legacy = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
'scheduled_at' => '2026-06-01 11:55:00',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
Artisan::call(PublishScheduledPressReleases::class);
|
||||
|
||||
expect($stale->fresh()->status)->toBe(PressReleaseStatus::Review)
|
||||
->and($current->fresh()->status)->toBe(PressReleaseStatus::Published)
|
||||
->and($legacy->fresh()->status)->toBe(PressReleaseStatus::Published);
|
||||
|
||||
Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $stale->id
|
||||
&& $job->route === true
|
||||
&& $job->contentHash === $stale->fresh()->content_hash);
|
||||
});
|
||||
|
|
|
|||
58
tests/Feature/Web/CompanyNewsroomCacheObserverTest.php
Normal file
58
tests/Feature/Web/CompanyNewsroomCacheObserverTest.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Support\WebPageCache;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Öffentliche Seiten-Caches müssen jeden Sichtbarkeitswechsel einer Firma
|
||||
* mitbekommen — nicht nur den 410-Weg über den CompanyRemovalService (F13).
|
||||
* Der Flush hängt deshalb am Model, nicht am Admin-Formular.
|
||||
*/
|
||||
test('soft-deleting a company flushes the public page caches', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = Company::factory()->businessportal24()->create();
|
||||
$before = WebPageCache::generation();
|
||||
|
||||
$company->delete();
|
||||
|
||||
expect(WebPageCache::generation())->toBe($before + 1);
|
||||
});
|
||||
|
||||
test('restoring a soft-deleted company flushes the public page caches', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = Company::factory()->businessportal24()->create();
|
||||
$company->delete();
|
||||
$before = WebPageCache::generation();
|
||||
|
||||
$company->restore();
|
||||
|
||||
expect(WebPageCache::generation())->toBe($before + 1);
|
||||
});
|
||||
|
||||
test('changing is_active flushes the public page caches', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = Company::factory()->businessportal24()->create(['is_active' => true]);
|
||||
$before = WebPageCache::generation();
|
||||
|
||||
$company->update(['is_active' => false]);
|
||||
|
||||
expect(WebPageCache::generation())->toBe($before + 1);
|
||||
|
||||
$company->update(['is_active' => true]);
|
||||
|
||||
expect(WebPageCache::generation())->toBe($before + 2);
|
||||
});
|
||||
|
||||
test('a change without public effect does not flush the page caches', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = Company::factory()->businessportal24()->create();
|
||||
$before = WebPageCache::generation();
|
||||
|
||||
// Telefonnummer und ein Save ohne Änderung: kein Grund, die gesamte
|
||||
// öffentliche Seite in den Neuaufbau zu zwingen.
|
||||
$company->update(['phone' => '+49 30 555 0']);
|
||||
$company->touch();
|
||||
|
||||
expect(WebPageCache::generation())->toBe($before);
|
||||
});
|
||||
|
|
@ -165,9 +165,16 @@ test('a fully deleted newsroom still answers 404', function () {
|
|||
goneTestRelease($company);
|
||||
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
|
||||
|
||||
// Erst aufwärmen: Die normale Löschung muss auch einen bereits
|
||||
// gecachten Newsroom sofort aus der Auslieferung nehmen — als 404,
|
||||
// nicht als Tombstone (F13).
|
||||
$this->get($url)->assertOk();
|
||||
|
||||
$company->delete();
|
||||
|
||||
$this->get($url)->assertNotFound();
|
||||
$this->get($url)
|
||||
->assertNotFound()
|
||||
->assertDontSeeText('Dieser Newsroom wurde entfernt');
|
||||
});
|
||||
|
||||
test('removing a newsroom leaves its press releases alone unless asked to cascade', function () {
|
||||
|
|
|
|||
|
|
@ -179,3 +179,60 @@ test('legacy newsroom html urls redirect to the clean edition path', function ()
|
|||
$this->get('https://businessportal24.test/de/newsroom/acme.html')
|
||||
->assertRedirect('https://businessportal24.test/de/newsroom/acme');
|
||||
});
|
||||
|
||||
test('soft-deleting a company takes a cached newsroom page down immediately', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = makeNewsroom('Aufgelöst GmbH');
|
||||
publishForCompany($company, 'Letzte Meldung vor der Löschung');
|
||||
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
|
||||
|
||||
// Erst aufwärmen: ohne warmen Seiten-Cache prüft der Test nur den
|
||||
// Neuaufbau, nicht das Verhalten beim Cache-Treffer (F13).
|
||||
$this->get($url)->assertOk();
|
||||
|
||||
$company->delete();
|
||||
|
||||
$this->get($url)->assertNotFound();
|
||||
});
|
||||
|
||||
test('deactivating a company takes a cached newsroom page down immediately', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = makeNewsroom('Ruhend AG');
|
||||
publishForCompany($company, 'Letzte Meldung vor der Deaktivierung');
|
||||
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
|
||||
|
||||
$this->get($url)->assertOk();
|
||||
|
||||
$company->update(['is_active' => false]);
|
||||
|
||||
$this->get($url)->assertNotFound();
|
||||
});
|
||||
|
||||
test('a deactivated company beats a warm newsroom page cache even without a flush', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = makeNewsroom('Still deaktiviert GmbH');
|
||||
publishForCompany($company, 'Meldung im Cache');
|
||||
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
|
||||
|
||||
$this->get($url)->assertOk();
|
||||
|
||||
// Ohne Model-Events, also ohne Observer-Flush: So prüft der Test die
|
||||
// Sichtbarkeitsprüfung VOR dem Cache-Treffer in NewsroomDetailPageData.
|
||||
$company->forceFill(['is_active' => false])->saveQuietly();
|
||||
|
||||
$this->get($url)->assertNotFound();
|
||||
});
|
||||
|
||||
test('a soft-deleted company beats a warm newsroom page cache even without a flush', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = makeNewsroom('Still gelöscht GmbH');
|
||||
publishForCompany($company, 'Meldung im Cache');
|
||||
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
|
||||
|
||||
$this->get($url)->assertOk();
|
||||
|
||||
// Query-Builder statt delete(): keine Events, kein Flush.
|
||||
Company::query()->withoutGlobalScopes()->whereKey($company->id)->update(['deleted_at' => now()]);
|
||||
|
||||
$this->get($url)->assertNotFound();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -245,3 +245,23 @@ test('deleting a press release removes its tracked events', function () {
|
|||
|
||||
expect(PressReleaseEvent::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a second aggregation run is refused while another one holds the lock (Audit F19)', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = trackingRelease(['hits' => 0]);
|
||||
$url = 'https://businessportal24.test/de/press-release/'.$release->slug;
|
||||
|
||||
$this->get($url)->assertSuccessful();
|
||||
|
||||
$lock = Cache::lock(AggregatePressReleaseViews::LockKey, 60);
|
||||
expect($lock->get())->toBeTrue();
|
||||
|
||||
// Manueller Lauf parallel zum Scheduler: nichts verbuchen, nicht abstürzen.
|
||||
$this->artisan('press-releases:aggregate-views')->assertSuccessful();
|
||||
expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(0);
|
||||
|
||||
$lock->release();
|
||||
|
||||
$this->artisan('press-releases:aggregate-views')->assertSuccessful();
|
||||
expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -481,3 +481,30 @@ test('a single recommendation list spans the full layout width', function () {
|
|||
->assertSee('grid grid-cols-1 gap-10"', false)
|
||||
->assertDontSee('grid grid-cols-1 gap-10 md:grid-cols-2', false);
|
||||
});
|
||||
|
||||
test('a deleted company contact is not shown as fallback press contact (Audit F04)', function () {
|
||||
/** @var TestCase $this */
|
||||
[$category, $company] = detailCategoryAndCompany(Portal::Businessportal24);
|
||||
|
||||
$release = PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Businessportal24)
|
||||
->for($category)
|
||||
->for($company)
|
||||
->create(['slug' => 'ohne-eigenen-kontakt', 'published_at' => now()->subDay()]);
|
||||
|
||||
// Kein PM-Kontakt → die Seite fällt auf den Firmen-Kontakt zurück. Ein
|
||||
// gelöschter Kontakt darf dabei nicht öffentlich erscheinen.
|
||||
Contact::factory()->create([
|
||||
'portal' => Portal::Businessportal24,
|
||||
'company_id' => $company->id,
|
||||
'first_name' => 'Ehemalige',
|
||||
'last_name' => 'Sprecherin',
|
||||
'email' => 'ehemalig@muster-energie.de',
|
||||
])->delete();
|
||||
|
||||
$this->get('https://businessportal24.test/de/press-release/'.$release->slug)
|
||||
->assertSuccessful()
|
||||
->assertDontSeeText('Ehemalige Sprecherin')
|
||||
->assertDontSee('ehemalig@muster-energie.de');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ test('a reported url matching a published release is linked to it', function ()
|
|||
->create(['published_at' => now()->subDay()]);
|
||||
|
||||
$this->post('https://businessportal24.test/de/melden/rechtswidrig', [
|
||||
'reported_url' => 'https://businessportal24.com/de/press-release/'.$release->slug,
|
||||
'reported_url' => 'https://businessportal24.test/de/press-release/'.$release->slug,
|
||||
'requester_name' => 'Erika Musterfrau',
|
||||
'requester_email' => 'erika@example.com',
|
||||
'message' => 'Diese Mitteilung verletzt meine Markenrechte an mehreren Stellen.',
|
||||
|
|
@ -72,6 +72,33 @@ test('a reported url matching a published release is linked to it', function ()
|
|||
expect(LegalRequest::query()->sole()->press_release_id)->toBe($release->id);
|
||||
});
|
||||
|
||||
test('a reported url on a foreign host or the other portal is not linked to a release', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$release = PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Businessportal24)
|
||||
->create(['published_at' => now()->subDay()]);
|
||||
|
||||
// Fremde Domain mit passendem Pfad und die URL des anderen Portals: beide
|
||||
// gehören nicht zu dieser PM — die Meldung bleibt ohne PM-Bezug (F12).
|
||||
foreach (['https://example.com', 'https://presseecho.test'] as $base) {
|
||||
$this->post('https://businessportal24.test/de/melden/rechtswidrig', [
|
||||
'reported_url' => $base.'/de/press-release/'.$release->slug,
|
||||
'requester_name' => 'Erika Musterfrau',
|
||||
'requester_email' => 'erika@example.com',
|
||||
'message' => 'Diese Mitteilung verletzt meine Markenrechte an mehreren Stellen.',
|
||||
'truth_affirmed' => '1',
|
||||
])->assertSessionHas('report-status');
|
||||
}
|
||||
|
||||
expect(LegalRequest::query()->count())->toBe(2)
|
||||
->and(LegalRequest::query()->whereNotNull('press_release_id')->count())->toBe(0)
|
||||
// Ohne PM-Bezug entscheidet der Host des Formulars über das Portal.
|
||||
->and(LegalRequest::query()->pluck('portal')->unique()->all())->toBe(['businessportal24']);
|
||||
});
|
||||
|
||||
test('the illegal content report requires the truthfulness affirmation and a valid url', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
|
|
|||
88
tests/Feature/Web/ReportedUrlResolverTest.php
Normal file
88
tests/Feature/Web/ReportedUrlResolverTest.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\Web\ReportedUrlResolver;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Eine gemeldete URL gehört genau zu einer Pressemitteilung: Der Slug ist nur
|
||||
* innerhalb von Portal UND Sprache eindeutig (F12). Host → Portal, erstes
|
||||
* Pfadsegment → Ausgabe, dann erst der Slug.
|
||||
*/
|
||||
function resolverRelease(Portal $portal, string $slug, string $language = 'de', array $attributes = []): PressRelease
|
||||
{
|
||||
return PressRelease::factory()
|
||||
->published()
|
||||
->forPortal($portal)
|
||||
->for(Company::factory()->state(['portal' => $portal->value]))
|
||||
->create(array_merge([
|
||||
'slug' => $slug,
|
||||
'language' => $language,
|
||||
'published_at' => now()->subDay(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
test('the same slug on both portals resolves to the release of the reported host', function () {
|
||||
/** @var TestCase $this */
|
||||
$presseecho = resolverRelease(Portal::Presseecho, 'gleicher-slug');
|
||||
$businessportal = resolverRelease(Portal::Businessportal24, 'gleicher-slug');
|
||||
$resolver = app(ReportedUrlResolver::class);
|
||||
|
||||
expect($resolver->resolve('https://presseecho.test/de/press-release/gleicher-slug')?->id)->toBe($presseecho->id)
|
||||
->and($resolver->resolve('https://businessportal24.test/de/press-release/gleicher-slug')?->id)->toBe($businessportal->id);
|
||||
});
|
||||
|
||||
test('the same slug in both editions resolves to the release of the reported edition', function () {
|
||||
/** @var TestCase $this */
|
||||
$german = resolverRelease(Portal::Presseecho, 'zwei-sprachen', 'de');
|
||||
$english = resolverRelease(Portal::Presseecho, 'zwei-sprachen', 'en');
|
||||
$resolver = app(ReportedUrlResolver::class);
|
||||
|
||||
expect($resolver->resolve('https://presseecho.test/en/press-release/zwei-sprachen')?->id)->toBe($english->id)
|
||||
->and($resolver->resolve('https://presseecho.test/de/press-release/zwei-sprachen')?->id)->toBe($german->id)
|
||||
// Ohne Ausgabe im Pfad gilt die Standard-Ausgabe (SetEdition).
|
||||
->and($resolver->resolve('https://presseecho.test/press-release/zwei-sprachen')?->id)->toBe($german->id);
|
||||
});
|
||||
|
||||
test('a foreign host with a matching path resolves to nothing', function () {
|
||||
/** @var TestCase $this */
|
||||
resolverRelease(Portal::Presseecho, 'fremder-host');
|
||||
$resolver = app(ReportedUrlResolver::class);
|
||||
|
||||
expect($resolver->resolve('https://example.com/de/press-release/fremder-host'))->toBeNull()
|
||||
->and($resolver->resolve('https://presseecho.test.evil.example/de/press-release/fremder-host'))->toBeNull()
|
||||
// Der Hub liefert keine Meldungen aus, er leitet auf das Portal um.
|
||||
->and($resolver->resolve('https://pressekonto.test/de/press-release/fremder-host'))->toBeNull()
|
||||
->and($resolver->resolve('kein-url'))->toBeNull();
|
||||
});
|
||||
|
||||
test('a www host and an url-encoded slug still resolve', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = resolverRelease(Portal::Businessportal24, 'kaffee-kuchen');
|
||||
$resolver = app(ReportedUrlResolver::class);
|
||||
|
||||
expect($resolver->resolve('https://www.businessportal24.test/de/press-release/kaffee%2Dkuchen?utm_source=x#top')?->id)->toBe($release->id);
|
||||
});
|
||||
|
||||
test('deleted, scheduled and unpublished releases resolve to nothing', function () {
|
||||
/** @var TestCase $this */
|
||||
resolverRelease(Portal::Presseecho, 'geloescht')->delete();
|
||||
resolverRelease(Portal::Presseecho, 'terminiert', 'de', ['published_at' => now()->addDay()]);
|
||||
PressRelease::factory()->forPortal(Portal::Presseecho)->create(['slug' => 'entwurf', 'language' => 'de']);
|
||||
$resolver = app(ReportedUrlResolver::class);
|
||||
|
||||
expect($resolver->resolve('https://presseecho.test/de/press-release/geloescht'))->toBeNull()
|
||||
->and($resolver->resolve('https://presseecho.test/de/press-release/terminiert'))->toBeNull()
|
||||
->and($resolver->resolve('https://presseecho.test/de/press-release/entwurf'))->toBeNull();
|
||||
});
|
||||
|
||||
test('a portal-wide copy loses against the canonical release with the same slug', function () {
|
||||
/** @var TestCase $this */
|
||||
$canonical = resolverRelease(Portal::Presseecho, 'doppelt');
|
||||
$copy = resolverRelease(Portal::Both, 'doppelt', 'de', ['canonical_press_release_id' => $canonical->id]);
|
||||
|
||||
expect($copy->id)->toBeGreaterThan($canonical->id)
|
||||
->and(app(ReportedUrlResolver::class)->resolve('https://presseecho.test/de/press-release/doppelt')?->id)->toBe($canonical->id);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue