From 982de2aba2cfa97fede0f716ac1cf822c083e4f8 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 1 Sep 2026 11:06:58 +0000 Subject: [PATCH] =?UTF-8?q?api:=20Ziel-Portal=20einer=20Einreichung=20expl?= =?UTF-8?q?izit=20aufl=C3=B6sen=20statt=20vom=20Konto=20erben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy-API-Kunden mit Keys beider Altportale — vor allem connektar — senden jede Meldung an presseecho.de UND businessportal24.de. Weil die Firma ihr Portal vom User und die Meldung es von der Firma erbte, landeten beide Kopien im selben Portal, die zweite mit Slug-Suffix "-2". Verifiziert wurde das über die Token-Hashes gegen die Legacy-Datenbanken. Das Portal wird jetzt in fester Reihenfolge aufgelöst: expliziter portal-Parameter, sonst die aufgerufene Domain, sonst das Portal des Firmenprofils, zuletzt Portal::Both — genau der Default, den beide Spalten ohnehin tragen. Die Auflösung ist damit nicht-nullbar; vorher konnte sie null liefern, was als SQL-Fehler und damit als 500er beim Alt-Client gelandet wäre. Ein Update über die v1-API verschiebt eine bestehende Meldung ausschließlich über den expliziten Parameter, nie über die aufgerufene Domain. press-releases:fix-api-portal-duplicates repariert die Altdaten: Es findet Paare (gleicher User, Titel, Sprache und Portal, beide per API eingereicht, kurz nacheinander) und verschiebt die zweite Kopie samt ihrer exklusiven Firma ins jeweils andere Portal, mit neu vergebenem Slug. Web-Doppel-Submits ohne Status-Log source=api und Legacy-Sync-Daten mit gesetzter legacy_id werden geschont. Auf Live ausgeführt und verifiziert. Co-Authored-By: Claude Opus 5 (1M context) --- .../Commands/FixApiPortalDuplicates.php | 208 +++++++++++++++++ .../Api/Concerns/ResolvesRequestedPortal.php | 34 +++ .../Api/Legacy/LegacyCompanyController.php | 14 +- .../Legacy/LegacyPressReleaseController.php | 11 +- .../Api/V1/PressReleaseController.php | 20 +- .../Api/V1/StorePressReleaseRequest.php | 3 + .../Api/V1/UpdatePressReleaseRequest.php | 3 + app/Models/PressRelease.php | 1 - lang/de/static.php | 3 +- lang/en/static.php | 3 +- public/api/v1.yml | 13 +- resources/views/web/api.blade.php | 1 + tests/Feature/Api/ApiPortalResolutionTest.php | 216 ++++++++++++++++++ tests/Feature/Api/LegacyServiceParityTest.php | 49 +++- tests/Feature/FixApiPortalDuplicatesTest.php | 154 +++++++++++++ 15 files changed, 720 insertions(+), 13 deletions(-) create mode 100644 app/Console/Commands/FixApiPortalDuplicates.php create mode 100644 app/Http/Controllers/Api/Concerns/ResolvesRequestedPortal.php create mode 100644 tests/Feature/Api/ApiPortalResolutionTest.php create mode 100644 tests/Feature/FixApiPortalDuplicatesTest.php diff --git a/app/Console/Commands/FixApiPortalDuplicates.php b/app/Console/Commands/FixApiPortalDuplicates.php new file mode 100644 index 00000000..8c7f88a9 --- /dev/null +++ b/app/Console/Commands/FixApiPortalDuplicates.php @@ -0,0 +1,208 @@ +option('since'); + $windowMinutes = max(1, (int) $this->option('window')); + $userId = $this->option('user') !== null ? (int) $this->option('user') : null; + $isDryRun = (bool) $this->option('dry-run'); + + if ($isDryRun) { + $this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.'); + } + + $pairs = $this->findDuplicatePairs($since, $windowMinutes, $userId); + + if ($pairs->isEmpty()) { + $this->info('Keine API-Duplikat-Paare gefunden.'); + + return self::SUCCESS; + } + + $movedPressReleases = 0; + $movedCompanies = 0; + $rows = []; + + foreach ($pairs as [$keep, $move]) { + $target = $move->portal === Portal::Businessportal24 + ? Portal::Presseecho + : Portal::Businessportal24; + + $newSlug = $move->generateUniqueSlug($move->title, [ + 'portal' => $target->value, + 'language' => $move->language, + ]); + + $company = $this->exclusiveCompanyOf($move); + + $rows[] = [ + $move->id, + mb_strimwidth($move->title, 0, 50, '…'), + "{$move->portal->value} → {$target->value}", + "{$move->slug} → {$newSlug}", + $company ? "#{$company->id} {$company->slug}" : '–', + $keep->id, + ]; + + if (! $isDryRun) { + DB::transaction(function () use ($move, $company, $target, $newSlug, &$movedCompanies): void { + $move->portal = $target; + $move->slug = $newSlug; + $move->saveWithUniqueSlugRetry($move->title, [ + 'portal' => $target->value, + 'language' => $move->language, + ]); + + if ($company) { + $company->portal = $target; + $company->slug = $company->generateUniqueSlug($company->name, [ + 'portal' => $target->value, + ]); + $company->saveWithUniqueSlugRetry($company->name, [ + 'portal' => $target->value, + ]); + $movedCompanies++; + } + }); + } elseif ($company) { + $movedCompanies++; + } + + $movedPressReleases++; + } + + $this->table( + ['PM', 'Titel', 'Portal', 'Slug', 'Firma zieht mit', 'Original bleibt'], + $rows, + ); + + $verb = $isDryRun ? 'würden verschoben werden' : 'verschoben'; + $this->info("{$movedPressReleases} Pressemitteilungen und {$movedCompanies} Firmen {$verb}."); + + return self::SUCCESS; + } + + /** + * Findet Duplikat-Paare: gleicher User, Titel, Sprache und gleiches + * Portal, beide per API eingereicht (Status-Log source=api), innerhalb + * des Zeitfensters nacheinander angelegt. Bei mehr als zwei Treffern + * pro Gruppe (erneute Einreichung derselben PM) werden die Einträge + * chronologisch zu Paaren zusammengefasst. + * + * @return Collection + */ + private function findDuplicatePairs(string $since, int $windowMinutes, ?int $userId): Collection + { + $candidates = PressRelease::query() + ->withoutGlobalScopes([PortalScope::class]) + ->whereNull('legacy_id') + ->whereIn('portal', [Portal::Presseecho->value, Portal::Businessportal24->value]) + ->where('created_at', '>=', $since) + ->when($userId !== null, fn (Builder $query) => $query->where('user_id', $userId)) + ->whereExists(function ($query): void { + $query->select(DB::raw(1)) + ->from('press_release_status_logs') + ->whereColumn('press_release_status_logs.press_release_id', 'press_releases.id') + ->where('press_release_status_logs.source', 'api'); + }) + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + return $candidates + ->groupBy(fn (PressRelease $pressRelease): string => implode('|', [ + $pressRelease->user_id, + $pressRelease->portal->value, + $pressRelease->language, + $pressRelease->title, + ])) + ->filter(fn (Collection $group): bool => $group->count() >= 2) + ->flatMap(function (Collection $group) use ($windowMinutes): array { + $pairs = []; + $items = $group->values(); + + for ($i = 0; $i < $items->count() - 1; $i++) { + $first = $items[$i]; + $second = $items[$i + 1]; + + if ($first->created_at->diffInMinutes($second->created_at) <= $windowMinutes) { + $pairs[] = [$first, $second]; + $i++; + } + } + + return $pairs; + }) + ->values(); + } + + /** + * Firma der verschobenen PM, sofern sie exklusiv zu dieser PM gehört + * (Legacy-API-Muster: pro Einreichung wird eine frische Firma angelegt). + * Firmen mit weiteren PMs oder aus dem Legacy-Sync bleiben unangetastet. + */ + private function exclusiveCompanyOf(PressRelease $pressRelease): ?Company + { + if ($pressRelease->company_id === null) { + return null; + } + + $company = Company::query() + ->withoutGlobalScopes([PortalScope::class]) + ->find($pressRelease->company_id); + + if (! $company || $company->legacy_id !== null) { + return null; + } + + $hasOtherPressReleases = PressRelease::query() + ->withoutGlobalScopes([PortalScope::class]) + ->where('company_id', $company->id) + ->whereKeyNot($pressRelease->id) + ->exists(); + + return $hasOtherPressReleases ? null : $company; + } +} diff --git a/app/Http/Controllers/Api/Concerns/ResolvesRequestedPortal.php b/app/Http/Controllers/Api/Concerns/ResolvesRequestedPortal.php new file mode 100644 index 00000000..02881850 --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/ResolvesRequestedPortal.php @@ -0,0 +1,34 @@ +value` weiterreichen, wären ein Fatal Error. + */ +trait ResolvesRequestedPortal +{ + private function resolveRequestedPortal(?string $requestedPortal, ?Portal $fallback): Portal + { + return Portal::tryFrom((string) $requestedPortal) + ?? CurrentPortalContext::get() + ?? $fallback + ?? Portal::Both; + } +} diff --git a/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php b/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php index 996e9cfb..d06f9394 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php @@ -3,10 +3,13 @@ namespace App\Http\Controllers\Api\Legacy; use App\Enums\CompanyType; +use App\Enums\Portal; +use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal; use App\Http\Controllers\Controller; use App\Models\Company; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; /** @@ -16,7 +19,7 @@ use Illuminate\Validation\ValidationException; */ class LegacyCompanyController extends Controller { - use RespondsLikeLegacyApi; + use ResolvesRequestedPortal, RespondsLikeLegacyApi; public function store(Request $request): JsonResponse { @@ -26,6 +29,7 @@ class LegacyCompanyController extends Controller $validated = $request->validate([ 'company' => ['required', 'array'], 'company.name' => ['required', 'string', 'max:255'], + 'company.portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])], 'company.address' => ['nullable', 'string', 'max:1000'], 'company.country_id' => ['nullable', 'integer'], 'company.phone' => ['nullable', 'string', 'max:100'], @@ -39,8 +43,14 @@ class LegacyCompanyController extends Controller $user = $request->user(); + // Portal aus Parameter/Domain, sonst wie bisher vom User — der + // User-Fallback allein ließ alle Firmen (und damit deren PMs) von + // Kunden mit Keys beider Altportale im selben Portal landen. $company = new Company([ - 'portal' => $user->portal?->value, + 'portal' => $this->resolveRequestedPortal( + $validated['portal'] ?? null, + $user->portal, + )->value, 'type' => CompanyType::Company->value, 'name' => $validated['name'], 'address' => $validated['address'] ?? null, diff --git a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php index 43b7ad88..6d55cfa8 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php @@ -3,7 +3,9 @@ namespace App\Http\Controllers\Api\Legacy; use App\Enums\AiTextDeclaration; +use App\Enums\Portal; use App\Enums\PressReleaseStatus; +use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal; use App\Http\Controllers\Controller; use App\Models\AppSetting; use App\Models\Category; @@ -18,6 +20,7 @@ use App\Services\PressRelease\QuotaExceededException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; +use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use function Illuminate\Support\defer; @@ -36,7 +39,7 @@ use function Illuminate\Support\defer; */ class LegacyPressReleaseController extends Controller { - use RespondsLikeLegacyApi; + use ResolvesRequestedPortal, RespondsLikeLegacyApi; public const SETTING_AI_REVIEW = 'legacy_api_ai_review'; @@ -53,6 +56,7 @@ class LegacyPressReleaseController extends Controller 'press_release.company_id' => ['required', 'integer'], 'press_release.category_id' => ['required', 'integer'], 'press_release.language' => ['nullable', 'string', 'max:5'], + 'press_release.portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])], 'press_release.title' => ['required', 'string', 'max:255'], 'press_release.text' => ['required', 'string'], 'press_release.backlink_url' => ['nullable', 'url', 'max:255'], @@ -104,7 +108,10 @@ class LegacyPressReleaseController extends Controller ]); $pressRelease->uuid = (string) Str::uuid(); $pressRelease->user_id = $user->id; - $pressRelease->portal = $company->portal?->value; + $pressRelease->portal = $this->resolveRequestedPortal( + $validated['portal'] ?? null, + $company->portal, + ); $pressRelease->ai_declaration_confirmed_at = now(); $pressRelease->slug = $pressRelease->generateUniqueSlug($pressRelease->title, [ 'portal' => $pressRelease->portal, diff --git a/app/Http/Controllers/Api/V1/PressReleaseController.php b/app/Http/Controllers/Api/V1/PressReleaseController.php index ad995efd..8aab5be3 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseController.php @@ -3,7 +3,9 @@ namespace App\Http\Controllers\Api\V1; use App\Enums\AiTextDeclaration; +use App\Enums\Portal; use App\Enums\PressReleaseStatus; +use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\StorePressReleaseRequest; use App\Http\Requests\Api\V1\UpdatePressReleaseRequest; @@ -25,6 +27,8 @@ use Illuminate\Validation\Rule; class PressReleaseController extends Controller { + use ResolvesRequestedPortal; + public function index(Request $request): AnonymousResourceCollection { abort_unless($request->user()->tokenCan('press-releases:read'), 403); @@ -45,11 +49,14 @@ class PressReleaseController extends Controller $company = $this->findOwnedCompany((int) $validated['company_id'], $request); abort_unless($company !== null, 403); + // Ziel-Portal: expliziter Parameter > Request-Domain > Firmen-Portal. + $portal = $this->resolveRequestedPortal($validated['portal'] ?? null, $company->portal)->value; + $pressRelease = new PressRelease([ ...$validated, 'uuid' => (string) Str::uuid(), 'user_id' => $request->user()->id, - 'portal' => $company->portal->value, + 'portal' => $portal, // Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach // `review` erfolgt ausschließlich über die explizite submit-Route, // damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden. @@ -65,11 +72,11 @@ class PressReleaseController extends Controller ] : []), ]); $pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [ - 'portal' => $company->portal->value, + 'portal' => $portal, 'language' => $validated['language'], ]); $pressRelease->saveWithUniqueSlugRetry($validated['title'], [ - 'portal' => $company->portal->value, + 'portal' => $portal, 'language' => $validated['language'], ]); @@ -108,7 +115,12 @@ class PressReleaseController extends Controller $language = $validated['language'] ?? $pressRelease->language; $title = $validated['title'] ?? $pressRelease->title; - $portal = $company->portal->value; + // Beim Update verschiebt nur ein expliziter portal-Parameter die PM — + // weder Request-Domain noch Firmen-Portal ändern eine bestehende + // Zuordnung stillschweigend. + $portal = (Portal::tryFrom((string) ($validated['portal'] ?? '')) + ?? $pressRelease->portal + ?? $company->portal)->value; $pressRelease->fill([ ...$validated, diff --git a/app/Http/Requests/Api/V1/StorePressReleaseRequest.php b/app/Http/Requests/Api/V1/StorePressReleaseRequest.php index 9f8e56f0..169c14c4 100644 --- a/app/Http/Requests/Api/V1/StorePressReleaseRequest.php +++ b/app/Http/Requests/Api/V1/StorePressReleaseRequest.php @@ -2,8 +2,10 @@ namespace App\Http\Requests\Api\V1; +use App\Enums\Portal; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class StorePressReleaseRequest extends FormRequest { @@ -23,6 +25,7 @@ class StorePressReleaseRequest extends FormRequest public function rules(): array { return [ + 'portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])], 'company_id' => ['required', 'integer', 'exists:companies,id'], 'category_id' => ['required', 'integer', 'exists:categories,id'], 'language' => ['required', 'string', 'size:2'], diff --git a/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php b/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php index d70bd90e..6b1f4658 100644 --- a/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php +++ b/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php @@ -2,8 +2,10 @@ namespace App\Http\Requests\Api\V1; +use App\Enums\Portal; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class UpdatePressReleaseRequest extends FormRequest { @@ -23,6 +25,7 @@ class UpdatePressReleaseRequest extends FormRequest public function rules(): array { return [ + 'portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])], 'company_id' => ['sometimes', 'required', 'integer', 'exists:companies,id'], 'category_id' => ['sometimes', 'required', 'integer', 'exists:categories,id'], 'language' => ['sometimes', 'required', 'string', 'size:2'], diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index f707900c..70de3bef 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -414,7 +414,6 @@ class PressRelease extends Model $portals = match ($this->portal) { Portal::Both => [Portal::Presseecho, Portal::Businessportal24], - null => [], default => [$this->portal], }; diff --git a/lang/de/static.php b/lang/de/static.php index 6bafe4a5..69c8949b 100644 --- a/lang/de/static.php +++ b/lang/de/static.php @@ -280,7 +280,8 @@ return [ 'th_description' => 'Beschreibung', 'required_yes' => 'ja', - 'field_company_id' => 'ID eines eigenen Firmenprofils — bestimmt Newsroom und Portal.', + 'field_portal' => 'Ziel-Portal (presseecho oder businessportal24). Ohne Angabe entscheidet die aufgerufene Domain, sonst das Portal des Firmenprofils; ein gesetzter Wert hat Vorrang.', + 'field_company_id' => 'ID eines eigenen Firmenprofils — bestimmt den Newsroom.', 'field_category_id' => 'Rubrik (siehe GET /categories).', 'field_language' => 'Sprachcode, zwei Zeichen: de oder en.', 'field_title' => 'Überschrift, max. 255 Zeichen.', diff --git a/lang/en/static.php b/lang/en/static.php index 91db08b2..01aa3c24 100644 --- a/lang/en/static.php +++ b/lang/en/static.php @@ -279,7 +279,8 @@ return [ 'th_description' => 'Description', 'required_yes' => 'yes', - 'field_company_id' => 'ID of one of your company profiles — determines newsroom and portal.', + 'field_portal' => 'Target portal (presseecho or businessportal24). If omitted, the requested domain decides, otherwise the company profile\'s portal; an explicit value takes precedence.', + 'field_company_id' => 'ID of one of your company profiles — determines the newsroom.', 'field_category_id' => 'Category (see GET /categories).', 'field_language' => 'Language code, two characters: de or en.', 'field_title' => 'Headline, max. 255 characters.', diff --git a/public/api/v1.yml b/public/api/v1.yml index 2ce1b9f2..195e6c42 100644 --- a/public/api/v1.yml +++ b/public/api/v1.yml @@ -281,9 +281,20 @@ components: type: object required: [company_id, category_id, language, title, text] properties: + portal: + type: string + enum: [presseecho, businessportal24] + description: > + Ziel-Portal der Veröffentlichung. Optional: Ohne Angabe wird das + Portal aus der aufgerufenen Domain bestimmt (presseecho.de bzw. + businessportal24.com); ein explizit gesetzter Wert hat Vorrang + vor der Domain. Fehlen beide (z. B. Aufruf über pressekonto.de), + gilt das Portal des Firmenprofils. Beim PATCH wechselt das Portal + ausschließlich über diesen Parameter — die aufgerufene Domain + verschiebt eine bestehende Mitteilung nicht. company_id: type: integer - description: ID eines eigenen Firmenprofils — bestimmt Newsroom und Portal + description: ID eines eigenen Firmenprofils — bestimmt den Newsroom category_id: { type: integer } language: type: string diff --git a/resources/views/web/api.blade.php b/resources/views/web/api.blade.php index d4cf7e92..7841d1cf 100644 --- a/resources/views/web/api.blade.php +++ b/resources/views/web/api.blade.php @@ -44,6 +44,7 @@ ]; $pressReleaseFields = [ + ['name' => 'portal', 'type' => 'string', 'required' => false, 'desc' => __('static.api.field_portal')], ['name' => 'company_id', 'type' => 'integer', 'required' => true, 'desc' => __('static.api.field_company_id')], ['name' => 'category_id', 'type' => 'integer', 'required' => true, 'desc' => __('static.api.field_category_id')], ['name' => 'language', 'type' => 'string', 'required' => true, 'desc' => __('static.api.field_language')], diff --git a/tests/Feature/Api/ApiPortalResolutionTest.php b/tests/Feature/Api/ApiPortalResolutionTest.php new file mode 100644 index 00000000..ba4ad412 --- /dev/null +++ b/tests/Feature/Api/ApiPortalResolutionTest.php @@ -0,0 +1,216 @@ + Request-Domain > Fallback. + */ +function portalResolutionApiUser(Portal $portal = Portal::Businessportal24): User +{ + test()->seed(RolesAndPermissionsSeeder::class); + config()->set('billing.enforce_booking', false); + + $user = User::factory()->create([ + 'is_active' => true, + 'portal' => $portal, + ]); + $user->forceFill(['registration_type' => 'apiuser'])->save(); + + Sanctum::actingAs($user, [ + 'press-releases:read', + 'press-releases:write', + 'companies:read', + ]); + + return $user; +} + +function legacyPressReleasePayload(Company $company, Category $category, array $overrides = []): array +{ + return [ + 'press_release' => array_merge([ + 'company_id' => $company->id, + 'category_id' => $category->legacy_id ?? $category->id, + 'language' => 'de', + 'title' => 'Portal-Auflösung Test-PM', + 'text' => 'Inhalt der Test-Pressemitteilung.', + ], $overrides), + ]; +} + +test('legacy company creation uses the request domain portal over the user portal', function () { + /** @var TestCase $this */ + portalResolutionApiUser(Portal::Businessportal24); + config()->set('app.theme', 'presseecho'); + + $this->postJson('/service/users/me/companies', [ + 'company' => ['name' => 'Domain GmbH'], + ])->assertCreated(); + + $company = Company::withoutGlobalScopes()->where('name', 'Domain GmbH')->firstOrFail(); + expect($company->portal)->toBe(Portal::Presseecho); +}); + +test('legacy company creation falls back to the user portal without domain context', function () { + /** @var TestCase $this */ + portalResolutionApiUser(Portal::Businessportal24); + config()->set('app.theme', 'portal'); + + $this->postJson('/service/users/me/companies', [ + 'company' => ['name' => 'Fallback GmbH'], + ])->assertCreated(); + + $company = Company::withoutGlobalScopes()->where('name', 'Fallback GmbH')->firstOrFail(); + expect($company->portal)->toBe(Portal::Businessportal24); +}); + +test('legacy company creation accepts an explicit portal parameter over the domain', function () { + /** @var TestCase $this */ + portalResolutionApiUser(Portal::Businessportal24); + config()->set('app.theme', 'businessportal24'); + + $this->postJson('/service/users/me/companies', [ + 'company' => ['name' => 'Parameter GmbH', 'portal' => 'presseecho'], + ])->assertCreated(); + + $company = Company::withoutGlobalScopes()->where('name', 'Parameter GmbH')->firstOrFail(); + expect($company->portal)->toBe(Portal::Presseecho); +}); + +test('legacy press release creation uses the request domain portal over the company portal', function () { + /** @var TestCase $this */ + Queue::fake(); + $user = portalResolutionApiUser(Portal::Businessportal24); + $company = Company::factory()->businessportal24()->create(['owner_user_id' => $user->id]); + $user->companies()->attach($company->id, ['role' => 'owner']); + $category = Category::factory()->create(['legacy_id' => 4711]); + + config()->set('app.theme', 'presseecho'); + + $this->postJson('/service/pressreleases', legacyPressReleasePayload($company, $category)) + ->assertCreated(); + + $pressRelease = PressRelease::withoutGlobalScopes()->latest('id')->firstOrFail(); + expect($pressRelease->portal)->toBe(Portal::Presseecho) + ->and($pressRelease->slug)->toBe('portal-auflosung-test-pm'); +}); + +test('legacy press release creation accepts an explicit portal parameter over the domain', function () { + /** @var TestCase $this */ + Queue::fake(); + $user = portalResolutionApiUser(Portal::Businessportal24); + $company = Company::factory()->businessportal24()->create(['owner_user_id' => $user->id]); + $user->companies()->attach($company->id, ['role' => 'owner']); + $category = Category::factory()->create(['legacy_id' => 4712]); + + config()->set('app.theme', 'businessportal24'); + + $this->postJson( + '/service/pressreleases', + legacyPressReleasePayload($company, $category, ['portal' => 'presseecho']), + )->assertCreated(); + + $pressRelease = PressRelease::withoutGlobalScopes()->latest('id')->firstOrFail(); + expect($pressRelease->portal)->toBe(Portal::Presseecho); +}); + +test('legacy press release creation falls back to the company portal without domain context', function () { + /** @var TestCase $this */ + Queue::fake(); + $user = portalResolutionApiUser(Portal::Businessportal24); + $company = Company::factory()->businessportal24()->create(['owner_user_id' => $user->id]); + $user->companies()->attach($company->id, ['role' => 'owner']); + $category = Category::factory()->create(['legacy_id' => 4713]); + + config()->set('app.theme', 'portal'); + + $this->postJson('/service/pressreleases', legacyPressReleasePayload($company, $category)) + ->assertCreated(); + + $pressRelease = PressRelease::withoutGlobalScopes()->latest('id')->firstOrFail(); + expect($pressRelease->portal)->toBe(Portal::Businessportal24); +}); + +test('v1 press release creation resolves the portal from parameter and domain', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $company = Company::factory()->businessportal24()->create(); + $category = Category::factory()->withTranslations()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + + Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + + config()->set('app.theme', 'presseecho'); + + $this->postJson('/api/v1/press-releases', [ + 'company_id' => $company->id, + 'category_id' => $category->id, + 'language' => 'de', + 'title' => 'Domain bestimmt das Portal', + 'text' => 'Inhalt', + ])->assertCreated()->assertJsonPath('data.portal', 'presseecho'); + + $this->postJson('/api/v1/press-releases', [ + 'portal' => 'businessportal24', + 'company_id' => $company->id, + 'category_id' => $category->id, + 'language' => 'de', + 'title' => 'Parameter schlägt Domain', + 'text' => 'Inhalt', + ])->assertCreated()->assertJsonPath('data.portal', 'businessportal24'); +}); + +test('v1 press release creation rejects the portal value both', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $company = Company::factory()->businessportal24()->create(); + $category = Category::factory()->withTranslations()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + + Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + + $this->postJson('/api/v1/press-releases', [ + 'portal' => 'both', + 'company_id' => $company->id, + 'category_id' => $category->id, + 'language' => 'de', + 'title' => 'Beide Portale sind nicht erlaubt', + 'text' => 'Inhalt', + ])->assertUnprocessable()->assertJsonValidationErrors(['portal']); +}); + +test('v1 update keeps the portal unless an explicit portal parameter is sent', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $company = Company::factory()->businessportal24()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + + $pressRelease = PressRelease::factory() + ->forPortal(Portal::Businessportal24) + ->create(['user_id' => $user->id, 'company_id' => $company->id]); + + Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + + // Update über die "falsche" Domain verschiebt die PM nicht … + config()->set('app.theme', 'presseecho'); + $this->patchJson("/api/v1/press-releases/{$pressRelease->id}", [ + 'title' => 'Titel bleibt im Portal', + ])->assertOk()->assertJsonPath('data.portal', 'businessportal24'); + + // … ein expliziter Parameter schon. + $this->patchJson("/api/v1/press-releases/{$pressRelease->id}", [ + 'portal' => 'presseecho', + ])->assertOk()->assertJsonPath('data.portal', 'presseecho'); +}); diff --git a/tests/Feature/Api/LegacyServiceParityTest.php b/tests/Feature/Api/LegacyServiceParityTest.php index 0ebd6e95..e75587c9 100644 --- a/tests/Feature/Api/LegacyServiceParityTest.php +++ b/tests/Feature/Api/LegacyServiceParityTest.php @@ -125,6 +125,10 @@ test('the full connektar flow works like the old api with immediate publishing', ->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) @@ -227,6 +231,7 @@ test('api submissions notify the press contact instead of the api submitter', fu Mail::fake(); Queue::fake(); $user = parityApiUser(); + $user->forceFill(['name' => 'Sebastian Einbock'])->save(); $company = Company::factory()->create([ 'portal' => Portal::Businessportal24, @@ -252,5 +257,47 @@ test('api submissions notify the press contact instead of the api submitter', fu ], ])->assertCreated(); - Mail::assertQueued(PressReleasePublished::class, fn ($mail) => $mail->hasTo('presse@muster.de')); + // 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'); + }); }); diff --git a/tests/Feature/FixApiPortalDuplicatesTest.php b/tests/Feature/FixApiPortalDuplicatesTest.php new file mode 100644 index 00000000..0a90cc6c --- /dev/null +++ b/tests/Feature/FixApiPortalDuplicatesTest.php @@ -0,0 +1,154 @@ +subHour(); + + $pair = []; + + foreach ([0, 1] as $index) { + $company = Company::factory()->businessportal24()->create([ + 'owner_user_id' => $user->id, + 'name' => 'Firma '.$title, + 'slug' => $index === 0 ? 'firma-'.$slugBase : 'firma-'.$slugBase.'-2', + ]); + + $pressRelease = PressRelease::factory() + ->forPortal(Portal::Businessportal24) + ->published() + ->create([ + 'user_id' => $user->id, + 'company_id' => $company->id, + 'title' => $title, + 'slug' => $index === 0 ? $slugBase : $slugBase.'-2', + 'created_at' => $createdAt->copy()->addSeconds($index * 20), + ]); + + $pressRelease->statusLogs()->create([ + 'from_status' => 'draft', + 'to_status' => 'review', + 'source' => 'api', + 'created_at' => $pressRelease->created_at, + ]); + + $pair[] = $pressRelease; + } + + return $pair; +} + +test('moves the second api duplicate with its company to the other portal', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['portal' => Portal::Businessportal24]); + [$original, $duplicate] = apiDuplicatePair($user, 'Doppelt gesendete Meldung', 'doppelt-gesendete-meldung'); + + $this->artisan('press-releases:fix-api-portal-duplicates') + ->assertSuccessful(); + + $original->refresh(); + $duplicate->refresh(); + + expect($original->portal)->toBe(Portal::Businessportal24) + ->and($original->slug)->toBe('doppelt-gesendete-meldung') + ->and($duplicate->portal)->toBe(Portal::Presseecho) + ->and($duplicate->slug)->toBe('doppelt-gesendete-meldung'); + + expect($original->company()->withoutGlobalScopes()->first()->portal)->toBe(Portal::Businessportal24) + ->and($duplicate->company()->withoutGlobalScopes()->first()->portal)->toBe(Portal::Presseecho) + ->and($duplicate->company()->withoutGlobalScopes()->first()->slug)->toBe('firma-doppelt-gesendete-meldung'); +}); + +test('dry run reports but changes nothing', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['portal' => Portal::Businessportal24]); + [, $duplicate] = apiDuplicatePair($user, 'Trockenlauf Meldung', 'trockenlauf-meldung'); + + $this->artisan('press-releases:fix-api-portal-duplicates', ['--dry-run' => true]) + ->assertSuccessful(); + + expect($duplicate->refresh()->portal)->toBe(Portal::Businessportal24) + ->and($duplicate->slug)->toBe('trockenlauf-meldung-2'); +}); + +test('ignores web double submits without api status log', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['portal' => Portal::Presseecho]); + $company = Company::factory()->presseecho()->create(['owner_user_id' => $user->id]); + + $pressReleases = collect([0, 1])->map(fn (int $index) => PressRelease::factory() + ->forPortal(Portal::Presseecho) + ->create([ + 'user_id' => $user->id, + 'company_id' => $company->id, + 'title' => 'Doppelklick im Formular', + 'slug' => $index === 0 ? 'doppelklick-im-formular' : 'doppelklick-im-formular-2', + 'created_at' => now()->subMinutes(10)->addSeconds($index * 3), + ])); + + $this->artisan('press-releases:fix-api-portal-duplicates') + ->assertSuccessful(); + + expect($pressReleases[1]->refresh()->portal)->toBe(Portal::Presseecho) + ->and($pressReleases[1]->slug)->toBe('doppelklick-im-formular-2'); +}); + +test('ignores legacy synced press releases and pairs outside the time window', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['portal' => Portal::Businessportal24]); + [, $duplicate] = apiDuplicatePair($user, 'Legacy Sync Meldung', 'legacy-sync-meldung'); + $duplicate->forceFill(['legacy_id' => 987654, 'legacy_portal' => 'businessportal24'])->save(); + + [, $lateDuplicate] = apiDuplicatePair($user, 'Späte zweite Meldung', 'spate-zweite-meldung'); + $lateDuplicate->forceFill(['created_at' => now()->subHour()->addHours(3)])->save(); + + $this->artisan('press-releases:fix-api-portal-duplicates') + ->assertSuccessful(); + + expect($duplicate->refresh()->portal)->toBe(Portal::Businessportal24) + ->and($lateDuplicate->refresh()->portal)->toBe(Portal::Businessportal24); +}); + +test('shared companies stay untouched when the duplicate moves', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['portal' => Portal::Businessportal24]); + $sharedCompany = Company::factory()->businessportal24()->create(['owner_user_id' => $user->id]); + + $pressReleases = collect([0, 1])->map(function (int $index) use ($user, $sharedCompany) { + $pressRelease = PressRelease::factory() + ->forPortal(Portal::Businessportal24) + ->create([ + 'user_id' => $user->id, + 'company_id' => $sharedCompany->id, + 'title' => 'Meldung mit Stammfirma', + 'slug' => $index === 0 ? 'meldung-mit-stammfirma' : 'meldung-mit-stammfirma-2', + 'created_at' => now()->subHour()->addSeconds($index * 20), + ]); + + $pressRelease->statusLogs()->create([ + 'from_status' => 'draft', + 'to_status' => 'review', + 'source' => 'api', + 'created_at' => $pressRelease->created_at, + ]); + + return $pressRelease; + }); + + $this->artisan('press-releases:fix-api-portal-duplicates') + ->assertSuccessful(); + + expect($pressReleases[1]->refresh()->portal)->toBe(Portal::Presseecho) + ->and($sharedCompany->refresh()->portal)->toBe(Portal::Businessportal24); +});