api: Ziel-Portal einer Einreichung explizit auflösen statt vom Konto erben
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) <noreply@anthropic.com>
This commit is contained in:
parent
0c8e94a6ff
commit
982de2aba2
15 changed files with 720 additions and 13 deletions
208
app/Console/Commands/FixApiPortalDuplicates.php
Normal file
208
app/Console/Commands/FixApiPortalDuplicates.php
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Verteilt per API doppelt eingegangene Pressemitteilungen auf die Portale.
|
||||
*
|
||||
* Hintergrund (Diagnose 13.08.2026): Legacy-API-Kunden mit Keys beider
|
||||
* Altportale (z. B. connektar) senden jede PM an presseecho.de UND
|
||||
* businessportal24.de. Da Firmen- und PM-Portal bisher vom User geerbt
|
||||
* wurden, landeten beide Kopien im selben Portal — die zweite mit
|
||||
* Slug-Suffix "-2". Dieses Command findet solche Paare (gleicher User,
|
||||
* Titel und Sprache, gleiches Portal, kurz nacheinander per API
|
||||
* eingereicht) und verschiebt die jeweils zweite Kopie samt ihrer
|
||||
* exklusiven Firma ins jeweils andere Portal; Slugs werden dabei im
|
||||
* Zielportal neu (ohne Suffix, sofern frei) vergeben.
|
||||
*
|
||||
* Web-Doppel-Submits (kein API-Status-Log) und Legacy-Sync-Importe
|
||||
* (legacy_id gesetzt) werden nicht angefasst.
|
||||
*
|
||||
* Verwendung:
|
||||
* php artisan press-releases:fix-api-portal-duplicates --dry-run
|
||||
* php artisan press-releases:fix-api-portal-duplicates
|
||||
* php artisan press-releases:fix-api-portal-duplicates --user=13993 --window=120
|
||||
*/
|
||||
class FixApiPortalDuplicates extends Command
|
||||
{
|
||||
protected $signature = 'press-releases:fix-api-portal-duplicates
|
||||
{--since=2026-07-20 : Nur PMs ab diesem Datum (API-Go-Live) berücksichtigen}
|
||||
{--window=60 : Max. Minuten zwischen den beiden Einreichungen eines Paars}
|
||||
{--user= : Optional auf eine User-ID beschränken}
|
||||
{--dry-run : Nur anzeigen, nichts schreiben}';
|
||||
|
||||
protected $description = 'Verschiebt per API doppelt eingereichte PMs (Slug-Suffix "-2") samt Firma ins jeweils andere Portal.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$since = (string) $this->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<int, array{0: PressRelease, 1: PressRelease}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Services\CurrentPortalContext;
|
||||
|
||||
/**
|
||||
* Löst das Ziel-Portal eines API-Schreibzugriffs auf.
|
||||
*
|
||||
* Reihenfolge (Entscheidung 13.08.2026, API-Portal-Fix):
|
||||
* 1. Explizit mitgesendeter portal-Parameter (überstimmt alles).
|
||||
* 2. Domain des Requests (CurrentPortalContext, gesetzt via SetCurrentPortal:
|
||||
* presseecho.de → presseecho, businessportal24.de → businessportal24).
|
||||
* 3. Aufrufer-spezifischer Fallback (Portal der Firma bzw. des Users) —
|
||||
* greift z. B. bei Calls über die Portal-/Admin-Domain ohne Parameter.
|
||||
* 4. `Portal::Both` als letzter Ausweg.
|
||||
*
|
||||
* Stufe 4 macht die Rückgabe bewusst nicht-nullbar: `press_releases.portal` und
|
||||
* `companies.portal` sind NOT-NULL-ENUMs mit genau diesem Default. Ein `null`
|
||||
* aus dieser Methode landete also als SQL-Fehler und damit als 500er beim
|
||||
* Alt-Client, statt als verwertbare Antwort — und die Aufrufer, die das
|
||||
* Ergebnis direkt mit `->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -414,7 +414,6 @@ class PressRelease extends Model
|
|||
|
||||
$portals = match ($this->portal) {
|
||||
Portal::Both => [Portal::Presseecho, Portal::Businessportal24],
|
||||
null => [],
|
||||
default => [$this->portal],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')],
|
||||
|
|
|
|||
216
tests/Feature/Api/ApiPortalResolutionTest.php
Normal file
216
tests/Feature/Api/ApiPortalResolutionTest.php
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Regression zum API-Portal-Fix (13.08.2026): Kunden mit Keys beider
|
||||
* Altportale senden an presseecho.de UND businessportal24.de — bisher
|
||||
* erbten Firma und PM immer das User-/Firmen-Portal, sodass alles im
|
||||
* selben Portal landete (zweite Kopie mit Slug "-2"). Das Ziel-Portal
|
||||
* kommt jetzt aus explizitem Parameter > 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');
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
154
tests/Feature/FixApiPortalDuplicatesTest.php
Normal file
154
tests/Feature/FixApiPortalDuplicatesTest.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Tests zum Reparatur-Command für den API-Portal-Bug (13.08.2026):
|
||||
* Duplikat-Paare (gleicher User/Titel, gleiches Portal, per API kurz
|
||||
* nacheinander eingereicht) — die zweite Kopie wandert samt exklusiver
|
||||
* Firma ins andere Portal, Slugs werden dort suffixfrei neu vergeben.
|
||||
*/
|
||||
function apiDuplicatePair(User $user, string $title, string $slugBase): array
|
||||
{
|
||||
$createdAt = now()->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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue