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); +});