presseportale/tests/Feature/Api/LegacyServiceParityTest.php
Kevin Adametz d6e49c11c3 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue
  Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt()
  für Sitemap-lastmod, dateModified und article:modified_time
- R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen
- R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt —
  hart wie weich — nur ein gültig signierter Rückläufer, unsignierte
  standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy)
- R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück
  (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und
  Unmerge, assignAll setzt veraltete Verweise zurück
- R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und
  verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach
  395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert,
  Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst
- N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap
- OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID

Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und
wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt
committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

321 lines
12 KiB
PHP

<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Api\Legacy\LegacyPressReleaseController;
use App\Jobs\ClassifyPressRelease;
use App\Mail\PressReleasePublished;
use App\Models\AppSetting;
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\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
* Paritäts-Tests gegen den Alt-Vertrag (dev/release/api-legacy-paritaet.md §1):
* der reale connektar-Flow über die 5 /service-Endpoints.
*/
function parityApiUser(bool $immediatePublication = true): User
{
test()->seed(RolesAndPermissionsSeeder::class);
config()->set('billing.enforce_booking', false);
$user = User::factory()->create([
'is_active' => true,
'portal' => Portal::Businessportal24,
]);
$user->forceFill(['registration_type' => 'apiuser'])->save();
if ($immediatePublication) {
$user->givePermissionTo('api:immediate-publication');
}
Sanctum::actingAs($user, [
'press-releases:read',
'press-releases:write',
'press-release-images:write',
'companies:read',
]);
return $user;
}
test('the full connektar flow works like the old api with immediate publishing', function () {
/** @var TestCase $this */
Queue::fake();
Storage::fake('local');
$user = parityApiUser();
// 1. Firma anlegen — verschachtelte Alt-Params, 201 + Location + {}.
$response = $this->postJson('/service/users/me/companies', [
'company' => [
'name' => 'Muster Energie GmbH',
'address' => "Musterstraße 1\n33602 Bielefeld",
'country_id' => 177,
'email' => 'info@muster-energie.de',
'website' => 'https://muster-energie.de',
],
])->assertCreated();
expect($response->headers->get('Location'))->toContain('/service/users/me/companies/')
->and($response->json())->toBe([]);
$company = Company::withoutGlobalScopes()->latest('id')->first();
expect($company->country_code)->toBe('DE')
->and($company->is_active)->toBeTrue()
->and($company->owner_user_id)->toBe($user->id);
// 2. Pressekontakt anlegen — salutation_id 1 → mr.
$this->postJson("/service/users/me/companies/{$company->id}/contacts", [
'contact' => [
'salutation_id' => 1,
'first_name' => 'Max',
'last_name' => 'Mustermann',
'email' => 'presse@muster-energie.de',
],
])->assertCreated();
$contact = $company->contacts()->first();
expect($contact->salutation_key)->toBe('mr');
// 3. PM anlegen — Anlegen IST Einreichen; mit Sofort-Publish-Recht
// und Schalter aus geht sie sofort live (Alt-Verhalten, Log-Beleg).
$category = Category::factory()->create(['portal' => Portal::Businessportal24, 'legacy_id' => 4242]);
$response = $this->postJson('/service/pressreleases', [
'press_release' => [
'company_id' => $company->id,
'category_id' => 4242,
'language' => 'de',
'title' => 'Muster Energie eröffnet neuen Standort',
'text' => 'Vollkommen unauffälliger Inhalt aus einer Alt-Integration.',
'contacts_list' => [$contact->id],
],
])->assertCreated();
expect($response->json())->toBe([]);
$pressRelease = PressRelease::withoutGlobalScopes()->latest('id')->first();
expect($pressRelease->status)->toBe(PressReleaseStatus::Published)
->and($pressRelease->published_at)->not->toBeNull()
->and($pressRelease->category_id)->toBe($category->id)
->and($pressRelease->ai_text_declaration?->value)->toBe('none')
->and($pressRelease->ai_declaration_confirmed_at)->not->toBeNull();
// 4. Bild hochladen — verschachtelt, KI-Default Originalfoto.
$this->postJson("/service/pressreleases/{$pressRelease->id}/images", [
'press_release_image' => [
'image' => UploadedFile::fake()->image('pressefoto.jpg', 1200, 800),
'copyright' => 'Muster Energie GmbH',
],
])->assertCreated();
expect($pressRelease->images()->first()->ai_declaration?->value)->toBe('original');
// 5. GET als Veröffentlichungs-Kontrolle: flaches Alt-Objekt, Hits zählen.
$this->getJson("/service/pressreleases/{$pressRelease->id}")
->assertOk()
->assertJsonStructure(['id', 'title', 'text', 'teaser_end', 'language', 'category_id', 'view_url', 'company' => ['viewUrl', 'name']])
->assertJsonPath('company.name', 'Muster Energie GmbH');
// Aufruf-Zähler: der Request protokolliert nur das Ereignis, verbucht
// wird es gebündelt durch den Scheduler.
$this->artisan('press-releases:aggregate-views')->assertSuccessful();
$trackedRelease = PressRelease::withoutGlobalScopes()->findOrFail($pressRelease->id);
expect($trackedRelease->hits)->toBe(1)
->and($trackedRelease->events()->where('type', 'view')->count())->toBe(1);
});
test('the admin switch routes legacy submissions through the ai review instead', function () {
/** @var TestCase $this */
Queue::fake();
$user = parityApiUser();
AppSetting::setValue(LegacyPressReleaseController::SETTING_AI_REVIEW, true);
$company = Company::factory()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
$user->companies()->attach($company->id, ['role' => 'owner']);
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
$this->postJson('/service/pressreleases', [
'press_release' => [
'company_id' => $company->id,
'category_id' => $category->id,
'title' => 'Wird erst KI-geprüft',
'text' => 'Inhalt, der durch die KI-Prüfung läuft.',
],
])->assertCreated();
$pressRelease = PressRelease::withoutGlobalScopes()->latest('id')->first();
expect($pressRelease->status)->toBe(PressReleaseStatus::Review);
Queue::assertPushed(ClassifyPressRelease::class);
// Polling-Kontrolle des Alt-Clients: unveröffentlicht = 404.
$this->getJson("/service/pressreleases/{$pressRelease->id}")->assertNotFound();
});
test('without the immediate publication permission legacy posts stay in review', function () {
/** @var TestCase $this */
Queue::fake();
$user = parityApiUser(immediatePublication: false);
$company = Company::factory()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
$user->companies()->attach($company->id, ['role' => 'owner']);
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
$this->postJson('/service/pressreleases', [
'press_release' => [
'company_id' => $company->id,
'category_id' => $category->id,
'title' => 'Ohne Sonderrecht',
'text' => 'Inhalt ohne Sofort-Publish-Privileg.',
],
])->assertCreated();
expect(PressRelease::withoutGlobalScopes()->latest('id')->first()->status)->toBe(PressReleaseStatus::Review);
});
test('legacy api html is sanitized before persistence', function () {
/** @var TestCase $this */
Queue::fake();
$user = parityApiUser(immediatePublication: false);
$company = Company::factory()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
$user->companies()->attach($company->id, ['role' => 'owner']);
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
$this->postJson('/service/pressreleases', [
'press_release' => [
'company_id' => $company->id,
'category_id' => $category->id,
'title' => 'Bereinigter Legacy-Inhalt',
'text' => '<p>Erlaubter Text</p><script>alert(1)</script><a href="javascript:alert(2)">Link</a>',
],
])->assertCreated();
$text = PressRelease::withoutGlobalScopes()->latest('id')->value('text');
expect($text)
->toContain('<p>Erlaubter Text</p>')
->not->toContain('<script')
->not->toContain('javascript:');
});
test('legacy errors use the old global and field error format', function () {
/** @var TestCase $this */
parityApiUser();
$this->postJson('/service/pressreleases', [
'press_release' => ['title' => 'Ohne Pflichtfelder'],
])->assertUnprocessable()
->assertJsonStructure(['global_errors', 'field_errors']);
// Fremde Firma → Alt-Wortlaut als global_error.
$foreign = Company::factory()->create(['portal' => Portal::Businessportal24]);
$this->postJson("/service/users/me/companies/{$foreign->id}/contacts", [
'contact' => ['first_name' => 'Max', 'last_name' => 'Muster'],
])->assertForbidden()
->assertJsonPath('global_errors.0', 'You may only access companies which are related to your user-account');
});
test('api submissions notify the press contact instead of the api submitter', function () {
/** @var TestCase $this */
Mail::fake();
Queue::fake();
$user = parityApiUser();
$user->forceFill(['name' => 'Sebastian Einbock'])->save();
$company = Company::factory()->create([
'portal' => Portal::Businessportal24,
'is_active' => true,
'email' => 'firma@muster.de',
]);
$user->companies()->attach($company->id, ['role' => 'owner']);
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
$contact = $company->contacts()->create([
'portal' => Portal::Businessportal24->value,
'first_name' => 'Max',
'last_name' => 'Mustermann',
'email' => 'presse@muster.de',
]);
$this->postJson('/service/pressreleases', [
'press_release' => [
'company_id' => $company->id,
'category_id' => $category->id,
'title' => 'Mail geht an den Pressekontakt',
'text' => 'Inhalt für den Mail-Routing-Test.',
'contacts_list' => [$contact->id],
],
])->assertCreated();
// Anrede folgt dem Empfänger (Pressekontakt), nicht dem API-Konto:
// gegen das gerenderte HTML geprüft, weil dort der Fehler auftrat.
Mail::assertQueued(PressReleasePublished::class, function ($mail) {
$html = $mail->render();
return $mail->hasTo('presse@muster.de')
&& str_contains($html, 'Hallo Max Mustermann,')
&& ! str_contains($html, 'Sebastian Einbock');
});
});
test('api submissions without press contact address the company by name', function () {
/** @var TestCase $this */
Mail::fake();
Queue::fake();
$user = parityApiUser();
$user->forceFill(['name' => 'Sebastian Einbock'])->save();
$company = Company::factory()->create([
'portal' => Portal::Businessportal24,
'is_active' => true,
'name' => 'Muster GmbH',
'email' => 'firma@muster.de',
]);
$user->companies()->attach($company->id, ['role' => 'owner']);
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
$this->postJson('/service/pressreleases', [
'press_release' => [
'company_id' => $company->id,
'category_id' => $category->id,
'title' => 'Mail geht an die Firma',
'text' => 'Inhalt für den Firmen-Fallback-Test.',
],
])->assertCreated();
Mail::assertQueued(PressReleasePublished::class, function ($mail) {
$html = $mail->render();
return $mail->hasTo('firma@muster.de')
&& str_contains($html, 'Hallo Muster GmbH,')
&& ! str_contains($html, 'Sebastian Einbock');
});
});
test('the legacy view url follows the language of the press release', function () {
/** @var TestCase $this */
$user = parityApiUser();
$company = Company::factory()->create(['portal' => Portal::Businessportal24]);
$pressRelease = PressRelease::factory()->create([
'user_id' => $user->id,
'company_id' => $company->id,
'portal' => Portal::Businessportal24,
'language' => 'en',
'status' => PressReleaseStatus::Published,
'published_at' => now()->subDay(),
]);
// Englische Meldungen liegen unter /en/ — /de/ antwortet für sie 404.
expect($this->getJson("/service/pressreleases/{$pressRelease->id}")->assertOk()->json('view_url'))
->toEndWith("/en/press-release/{$pressRelease->slug}");
});