From 0474d2234705899f8eeb7b8666340a467c4dd9d9 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 13 Jul 2026 12:28:03 +0000 Subject: [PATCH] Boost-Slot-Grenzen (5/3) + Kategorie-Boost zum halben Satz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entscheidung 13.07.: verkauft wird nur, was sichtbar ist — max. 5 gleichzeitige Boosts je Startseite, 3 je Branchenseite (= die sichtbaren Plaetze; Hero-Slider damit vertagt). Neue Stufe Kategorie-Boost (nur Branchenseite, 6/10/18 Credits = 50 % aufgerundet); der Startseiten-Boost (12/20/35) erscheint weiter auf Start- UND Branchenseite. - boosts.placement (home/category, Bestand = home) + BoostPlacement-Enum; Preise/Slots in config/credits.php (boost_category, boost_slots) - BoostService::slotAvailability() je Ausgabe (Portal x Sprache, Kategorie zusaetzlich je Rubrik); ausgebucht -> soldOut-Exception mit fruehestem freien Termin; Verlaengerungen halten ihren Slot, Stufen verlaengern unabhaengig; Kategorie-Boost invalidiert nur Kategorie-Caches - Startseite zeigt nur Home-Boosts; Branchenseite: Kategorie-Boosts mit Vorrang (ihre 3 Slots sind die Garantie), Home-Boosts fuellen den Rest - Add-on-Panel der PM-Seite: zwei Boost-Angebote, bei vollem Inventar "Derzeit ausgebucht — frei ab" statt Kauf-Buttons (serverseitig gegated); Buchungsseiten-Preisliste um die Kategorie-Staffel ergaenzt Co-Authored-By: Claude Fable 5 --- app/Enums/BoostPlacement.php | 34 ++++ app/Exceptions/BoostNotAllowedException.php | 12 +- app/Models/Boost.php | 8 + app/Models/PressRelease.php | 10 +- app/Services/Billing/CreditPricingService.php | 20 +- app/Services/PressRelease/BoostService.php | 137 +++++++++++--- app/Services/Web/CategoryPageData.php | 21 ++- app/Services/Web/HomePageData.php | 5 +- app/Services/Web/PreisePageData.php | 6 +- config/credits.php | 26 ++- database/factories/BoostFactory.php | 13 ++ ...3_140855_add_placement_to_boosts_table.php | 31 ++++ .../livewire/customer/bookings.blade.php | 16 +- .../customer/press-releases/index.blade.php | 3 +- .../customer/press-releases/show.blade.php | 174 ++++++++++++------ tests/Feature/BoostServiceTest.php | 139 +++++++++++++- tests/Feature/CreditPricingServiceTest.php | 11 ++ tests/Feature/PressReleaseShowAddonsTest.php | 60 +++++- tests/Feature/Web/BoostPlacementTest.php | 74 ++++++++ 19 files changed, 702 insertions(+), 98 deletions(-) create mode 100644 app/Enums/BoostPlacement.php create mode 100644 database/migrations/2026_07_13_140855_add_placement_to_boosts_table.php diff --git a/app/Enums/BoostPlacement.php b/app/Enums/BoostPlacement.php new file mode 100644 index 00000000..a7f306ea --- /dev/null +++ b/app/Enums/BoostPlacement.php @@ -0,0 +1,34 @@ + 'Startseiten-Boost', + self::Category => 'Kategorie-Boost', + }; + } + + /** + * Wo die Platzierung sichtbar ist (für Add-on-UI und Kauf-Mails). + */ + public function placementDescription(): string + { + return match ($this) { + self::Home => 'Startseite und Branchenseite', + self::Category => 'Branchenseite der Rubrik', + }; + } +} diff --git a/app/Exceptions/BoostNotAllowedException.php b/app/Exceptions/BoostNotAllowedException.php index 97749313..477cbb3f 100644 --- a/app/Exceptions/BoostNotAllowedException.php +++ b/app/Exceptions/BoostNotAllowedException.php @@ -2,11 +2,14 @@ namespace App\Exceptions; +use App\Enums\BoostPlacement; +use Illuminate\Support\Carbon; use RuntimeException; /** * Wird geworfen, wenn eine PM nicht geboostet werden darf — nur - * veröffentlichte, grün klassifizierte Meldungen sind boostbar (§2.2). + * veröffentlichte, grün klassifizierte Meldungen sind boostbar (§2.2) und + * je Ausgabe gibt es nur begrenzte Slots (Entscheidung 13.07.2026). */ class BoostNotAllowedException extends RuntimeException { @@ -14,4 +17,11 @@ class BoostNotAllowedException extends RuntimeException { return new self('Nur veröffentlichte, grün klassifizierte Pressemitteilungen sind boostbar.'); } + + public static function soldOut(BoostPlacement $placement, ?Carbon $freeAt): self + { + return new self($freeAt + ? "{$placement->label()} derzeit ausgebucht — der nächste Platz wird am {$freeAt->format('d.m.Y')} frei." + : "{$placement->label()} derzeit ausgebucht."); + } } diff --git a/app/Models/Boost.php b/app/Models/Boost.php index dab2c63c..849bde9d 100644 --- a/app/Models/Boost.php +++ b/app/Models/Boost.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\BoostPlacement; use App\Services\PressRelease\BoostService; use Database\Factories\BoostFactory; use Illuminate\Database\Eloquent\Builder; @@ -21,6 +22,7 @@ class Boost extends Model protected $fillable = [ 'press_release_id', 'user_id', + 'placement', 'days', 'credits_charged', 'starts_at', @@ -30,6 +32,7 @@ class Boost extends Model protected function casts(): array { return [ + 'placement' => BoostPlacement::class, 'days' => 'integer', 'credits_charged' => 'integer', 'starts_at' => 'datetime', @@ -54,4 +57,9 @@ class Boost extends Model { return $query->where('ends_at', '>', now()); } + + public function scopeForPlacement(Builder $query, BoostPlacement $placement): Builder + { + return $query->where('placement', $placement); + } } diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 0ca4ffe4..ddf944ed 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\AiTextDeclaration; +use App\Enums\BoostPlacement; use App\Enums\Portal; use App\Enums\PressReleaseClassification; use App\Enums\PressReleaseContentTier; @@ -340,11 +341,14 @@ class PressRelease extends Model /** * Beschränkt auf PMs mit aktuell laufendem Boost — Basis für die - * Featured-Platzierung auf Start-/Branchenseite. + * Featured-Platzierung auf Start-/Branchenseite. Optional auf eine + * Boost-Stufe eingeschränkt (Startseite zeigt nur Startseiten-Boosts). */ - public function scopeBoosted(Builder $query): Builder + public function scopeBoosted(Builder $query, ?BoostPlacement $placement = null): Builder { - return $query->whereHas('boosts', fn (Builder $q) => $q->active()); + return $query->whereHas('boosts', fn (Builder $q) => $q + ->active() + ->when($placement, fn (Builder $q) => $q->forPlacement($placement))); } /** diff --git a/app/Services/Billing/CreditPricingService.php b/app/Services/Billing/CreditPricingService.php index 1022c577..2cee2141 100644 --- a/app/Services/Billing/CreditPricingService.php +++ b/app/Services/Billing/CreditPricingService.php @@ -2,6 +2,7 @@ namespace App\Services\Billing; +use App\Enums\BoostPlacement; use App\Enums\Tier; use App\Models\User; @@ -29,11 +30,12 @@ class CreditPricingService } /** - * Boost-Preis (Credits) für eine Laufzeit in Tagen. + * Boost-Preis (Credits) für eine Laufzeit in Tagen, je Boost-Stufe + * (Startseiten-Boost bzw. Kategorie-Boost zum halben Satz). */ - public function boostCredits(int $days): int + public function boostCredits(int $days, BoostPlacement $placement = BoostPlacement::Home): int { - $table = config('credits.boost', []); + $table = $this->boostTable($placement); if (! isset($table[$days])) { throw new \InvalidArgumentException("Unbekannte Boost-Laufzeit: {$days} Tage."); @@ -47,14 +49,22 @@ class CreditPricingService * * @return array */ - public function boostOptions(): array + public function boostOptions(BoostPlacement $placement = BoostPlacement::Home): array { - $table = config('credits.boost', []); + $table = $this->boostTable($placement); ksort($table); return $table; } + /** + * @return array + */ + private function boostTable(BoostPlacement $placement): array + { + return config($placement === BoostPlacement::Home ? 'credits.boost' : 'credits.boost_category', []); + } + public function proofPdfCredits(): int { return (int) config('credits.proof_pdf'); diff --git a/app/Services/PressRelease/BoostService.php b/app/Services/PressRelease/BoostService.php index 4bb96bd9..8f1301a7 100644 --- a/app/Services/PressRelease/BoostService.php +++ b/app/Services/PressRelease/BoostService.php @@ -2,6 +2,7 @@ namespace App\Services\PressRelease; +use App\Enums\BoostPlacement; use App\Enums\Portal; use App\Enums\PressReleaseClassification; use App\Enums\PressReleaseStatus; @@ -9,16 +10,24 @@ use App\Exceptions\BoostNotAllowedException; use App\Models\Boost; use App\Models\PressRelease; use App\Models\User; +use App\Scopes\PortalScope; use App\Services\Billing\CreditPricingService; use App\Services\Billing\CreditWalletService; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; /** - * Boost-Kauf laut Decision-Update §2.2. Gate: nur veröffentlichte, grüne PMs. - * Bezahlung über die Credit-Wallet, Preis nach Laufzeit (7/14/30 Tage). - * Mehrfachkauf verlängert: der neue Zeitraum schließt an ein laufendes Ende an. + * Boost-Kauf laut Decision-Update §2.2 + Platzierungs-Entscheidung 13.07.2026. + * Gate: nur veröffentlichte, grüne PMs. Bezahlung über die Credit-Wallet, + * Preis nach Laufzeit (7/14/30 Tage) und Stufe: Startseiten-Boost (Startseite + * + Branchenseite) bzw. Kategorie-Boost (nur Branchenseite, halber Satz). + * Slot-Inventar je Ausgabe (config credits.boost_slots): mehr gleichzeitige + * Boosts als sichtbare Plätze werden nicht verkauft — der Kauf wirft dann + * BoostNotAllowedException::soldOut() mit dem frühesten freien Termin. + * Mehrfachkauf derselben Stufe verlängert: der neue Zeitraum schließt an ein + * laufendes Ende an und braucht keinen freien Slot (die PM hält ihren). */ class BoostService { @@ -36,40 +45,111 @@ class BoostService && $pressRelease->classification === PressReleaseClassification::Green; } - public function isBoosted(PressRelease $pressRelease): bool + public function isBoosted(PressRelease $pressRelease, ?BoostPlacement $placement = null): bool { - return $pressRelease->boosts()->active()->exists(); + return $pressRelease->boosts() + ->active() + ->when($placement, fn (Builder $query) => $query->forPlacement($placement)) + ->exists(); } /** - * Ende des aktuell laufenden Boosts (das späteste zukünftige `ends_at`) - * oder null, wenn die PM gerade nicht geboostet ist. + * Ende des aktuell laufenden Boosts (das späteste zukünftige `ends_at`), + * optional je Stufe, oder null, wenn die PM gerade nicht geboostet ist. */ - public function activeUntil(PressRelease $pressRelease): ?Carbon + public function activeUntil(PressRelease $pressRelease, ?BoostPlacement $placement = null): ?Carbon { - $endsAt = $pressRelease->boosts()->active()->max('ends_at'); + $endsAt = $pressRelease->boosts() + ->active() + ->when($placement, fn (Builder $query) => $query->forPlacement($placement)) + ->max('ends_at'); return $endsAt ? Carbon::parse($endsAt) : null; } /** - * Bucht einen Boost. Wirft BoostNotAllowedException am Gate und + * Slot-Verfügbarkeit der Stufe für diese PM. Eine Verlängerung ist immer + * verfügbar (die PM hält ihren Slot); sonst muss auf jeder Ausgabe, auf + * der die PM erscheint (Portal × Sprache, Kategorie-Boost zusätzlich je + * Rubrik), ein Slot frei sein. `freeAt` nennt bei „ausgebucht" den + * frühesten Zeitpunkt, zu dem wieder ein Platz frei wird. + * + * @return array{available: bool, freeAt: Carbon|null} + */ + public function slotAvailability(PressRelease $pressRelease, BoostPlacement $placement): array + { + if ($this->isBoosted($pressRelease, $placement)) { + return ['available' => true, 'freeAt' => null]; + } + + $limit = $this->slotLimit($placement); + $freeAt = null; + + foreach ($this->affectedPortals($pressRelease) as $portal) { + // Je Konkurrent zählt seine PM einen Slot, gehalten bis zum + // spätesten Boost-Ende (Verlängerungen schließen lückenlos an). + $heldUntil = Boost::query() + ->forPlacement($placement) + ->active() + ->where('press_release_id', '!=', $pressRelease->id) + ->whereHas('pressRelease', function (Builder $query) use ($portal, $pressRelease, $placement): void { + $query + ->withoutGlobalScope(PortalScope::class) + ->whereIn('portal', [$portal, Portal::Both->value]) + ->where('language', $pressRelease->language) + ->when( + $placement === BoostPlacement::Category, + fn (Builder $query) => $query->where('category_id', $pressRelease->category_id), + ); + }) + ->groupBy('press_release_id') + ->selectRaw('press_release_id, MAX(ends_at) AS held_until') + ->pluck('held_until'); + + if ($heldUntil->count() < $limit) { + continue; + } + + // Diese Ausgabe ist voll: frei, sobald der zuerst endende + // Konkurrent seinen Slot räumt. Bei mehreren vollen Ausgaben + // zählt die späteste (die PM braucht überall einen Platz). + $portalFreeAt = Carbon::parse($heldUntil->min()); + $freeAt = $freeAt === null ? $portalFreeAt : $freeAt->max($portalFreeAt); + } + + return ['available' => $freeAt === null, 'freeAt' => $freeAt]; + } + + public function slotLimit(BoostPlacement $placement): int + { + return (int) config("credits.boost_slots.{$placement->value}"); + } + + /** + * Bucht einen Boost der angegebenen Stufe. Wirft BoostNotAllowedException + * am Gate (nicht boostbar bzw. Slots ausgebucht) und * InvalidArgumentException bei unbekannter Laufzeit; die Wallet-Belastung * wirft InsufficientCreditsException, falls das Guthaben nicht reicht. */ - public function boost(User $user, PressRelease $pressRelease, int $days): Boost + public function boost(User $user, PressRelease $pressRelease, int $days, BoostPlacement $placement = BoostPlacement::Home): Boost { if (! $this->canBoost($pressRelease)) { throw BoostNotAllowedException::notBoostable(); } - $credits = $this->pricing->boostCredits($days); + $credits = $this->pricing->boostCredits($days, $placement); - $boost = DB::transaction(function () use ($user, $pressRelease, $days, $credits): Boost { - $startsAt = $this->activeUntil($pressRelease) ?? now(); + $boost = DB::transaction(function () use ($user, $pressRelease, $days, $credits, $placement): Boost { + $availability = $this->slotAvailability($pressRelease, $placement); + if (! $availability['available']) { + throw BoostNotAllowedException::soldOut($placement, $availability['freeAt']); + } + + $startsAt = $this->activeUntil($pressRelease, $placement) ?? now(); $boost = $pressRelease->boosts()->create([ 'user_id' => $user->id, + 'placement' => $placement, 'days' => $days, 'credits_charged' => $credits, 'starts_at' => $startsAt, @@ -79,14 +159,14 @@ class BoostService $this->wallet->debit( $user, $credits, - "Boost {$days} Tage – PM #{$pressRelease->id}", + "{$placement->label()} {$days} Tage – PM #{$pressRelease->id}", $boost, ); return $boost; }); - $this->forgetPlacementCaches($pressRelease); + $this->forgetPlacementCaches($pressRelease, $placement); return $boost; } @@ -94,13 +174,12 @@ class BoostService /** * Gecachte Start-/Branchenseiten der betroffenen Ausgaben verwerfen, * damit die bezahlte Platzierung sofort sichtbar wird statt erst nach - * Ablauf der Cache-TTL (HomePageData/CategoryPageData, 300 s). + * Ablauf der Cache-TTL (HomePageData/CategoryPageData, 300 s). Der + * Kategorie-Boost berührt die Startseite nicht. */ - private function forgetPlacementCaches(PressRelease $pressRelease): void + private function forgetPlacementCaches(PressRelease $pressRelease, BoostPlacement $placement): void { - $portals = $pressRelease->portal === Portal::Both - ? [Portal::Presseecho->value, Portal::Businessportal24->value] - : [$pressRelease->portal->value]; + $portals = $this->affectedPortals($pressRelease); // Branchenseite der eigenen Rubrik plus Eltern-Rubrik (deren Seite // schließt Unterkategorien mit ein), je hinterlegter Sprache. @@ -111,7 +190,9 @@ class BoostService ->merge($category?->parent?->translations ?? []); foreach ($portals as $portal) { - Cache::forget(sprintf('web.home.%s.%s', $portal, $pressRelease->language)); + if ($placement === BoostPlacement::Home) { + Cache::forget(sprintf('web.home.%s.%s', $portal, $pressRelease->language)); + } foreach ($translations as $translation) { if (filled($translation->slug)) { @@ -120,4 +201,16 @@ class BoostService } } } + + /** + * Portale, auf deren Ausgaben die PM erscheint. + * + * @return list + */ + private function affectedPortals(PressRelease $pressRelease): array + { + return $pressRelease->portal === Portal::Both + ? [Portal::Presseecho->value, Portal::Businessportal24->value] + : [$pressRelease->portal->value]; + } } diff --git a/app/Services/Web/CategoryPageData.php b/app/Services/Web/CategoryPageData.php index 3d45690a..b1b93bfd 100644 --- a/app/Services/Web/CategoryPageData.php +++ b/app/Services/Web/CategoryPageData.php @@ -2,6 +2,7 @@ namespace App\Services\Web; +use App\Enums\BoostPlacement; use App\Models\Category; use App\Models\Company; use App\Models\PressRelease; @@ -97,13 +98,27 @@ class CategoryPageData // Gebuchte Boosts (Decision-Update §2.2): geboostete PMs der Branche // besetzen Lead- und Top-Plätze vor der Chronologie; die Views - // markieren sie über `boostedIds`. - $boostedReleases = $base() - ->boosted() + // markieren sie über `boostedIds`. Kategorie-Boosts haben Vorrang — + // ihr Slot-Inventar (max. 3 je Branchenseite) ist die verkaufte + // Garantie; Startseiten-Boosts erscheinen hier zusätzlich, solange + // Plätze übrig sind (Entscheidung 13.07.2026). + $categoryBoosted = $base() + ->boosted(BoostPlacement::Category) ->with($with) ->orderByDesc('published_at') ->limit(3) ->get(); + $homeBoostSlots = 3 - $categoryBoosted->count(); + $homeBoosted = $homeBoostSlots > 0 + ? $base() + ->boosted(BoostPlacement::Home) + ->when($categoryBoosted->isNotEmpty(), fn (Builder $query) => $query->whereNotIn('id', $categoryBoosted->modelKeys())) + ->with($with) + ->orderByDesc('published_at') + ->limit($homeBoostSlots) + ->get() + : collect(); + $boostedReleases = $categoryBoosted->concat($homeBoosted)->values(); $boostedIds = $boostedReleases->modelKeys(); $leadRelease = $boostedReleases->first() diff --git a/app/Services/Web/HomePageData.php b/app/Services/Web/HomePageData.php index ba9af307..40700bf0 100644 --- a/app/Services/Web/HomePageData.php +++ b/app/Services/Web/HomePageData.php @@ -2,6 +2,7 @@ namespace App\Services\Web; +use App\Enums\BoostPlacement; use App\Enums\Portal; use App\Models\Category; use App\Models\Company; @@ -75,8 +76,10 @@ class HomePageData // Gebuchte Boosts (Decision-Update §2.2): geboostete PMs besetzen die // Top-Positionen vor der Chronologie — die jüngste als Lead, weitere // in der Seitenleiste. Die Views markieren sie über `boostedIds`. + // Nur Startseiten-Boosts; der günstigere Kategorie-Boost kauft + // ausschließlich die Branchenseite (Entscheidung 13.07.2026). $boostedReleases = $publishedQuery() - ->boosted() + ->boosted(BoostPlacement::Home) ->with($with) ->orderByDesc('published_at') ->limit(5) diff --git a/app/Services/Web/PreisePageData.php b/app/Services/Web/PreisePageData.php index 9505a847..1cad6883 100644 --- a/app/Services/Web/PreisePageData.php +++ b/app/Services/Web/PreisePageData.php @@ -26,7 +26,7 @@ class PreisePageData ->values() ->all(); - $boost = collect(config('credits.boost', [])) + $staffel = static fn (array $table): array => collect($table) ->map(static fn (int $credits, int|string $days): array => [ 'days' => (int) $days, 'credits' => $credits, @@ -38,7 +38,9 @@ class PreisePageData 'plans' => Plan::query()->active()->get(), 'singlePmPriceCents' => (int) config('billing.single_pm_price_cents'), 'extraPm' => $extraPm, - 'boost' => $boost, + 'boost' => $staffel(config('credits.boost', [])), + // Kategorie-Boost (nur Branchenseite, halber Satz — 13.07.2026). + 'boostCategory' => $staffel(config('credits.boost_category', [])), 'proofPdf' => (int) config('credits.proof_pdf'), 'creditPacks' => config('credits.packs', []), // TODO: Lineup/Preise hängen am Bookings-/Magic-Link-Rework — final abstimmen. diff --git a/config/credits.php b/config/credits.php index f3c6581e..cae6e37f 100644 --- a/config/credits.php +++ b/config/credits.php @@ -28,8 +28,9 @@ return [ ], /* - | Boost (Platzierung Startseite + Branchenseite) — nach Dauer in Tagen. - | Pro-Tag-Preis sinkt mit der Dauer; Einstieg bleibt unter dem PM-Preis. + | Startseiten-Boost (Platzierung Startseite + Branchenseite) — nach Dauer + | in Tagen. Pro-Tag-Preis sinkt mit der Dauer; Einstieg bleibt unter dem + | PM-Preis. */ 'boost' => [ 7 => 12, @@ -37,6 +38,27 @@ return [ 30 => 35, ], + /* + | Kategorie-Boost (nur Branchenseite) — 50 % vom Startseiten-Boost, + | aufgerundet (Entscheidung 13.07.2026). + */ + 'boost_category' => [ + 7 => 6, + 14 => 10, + 30 => 18, + ], + + /* + | Slot-Inventar je Ausgabe (Portal × Sprache): mehr gleichzeitige Boosts + | als sichtbare Plätze werden nicht verkauft — das Kauf-Modal zeigt + | stattdessen „Derzeit ausgebucht" mit dem frühesten freien Termin. + | `home` gilt je Startseite, `category` je Branchenseite (Rubrik). + */ + 'boost_slots' => [ + 'home' => 5, + 'category' => 3, + ], + /* | Veröffentlichungsnachweis-PDF — pauschal pro PM (Impulskauf). */ diff --git a/database/factories/BoostFactory.php b/database/factories/BoostFactory.php index 19c6b8b1..c9b04736 100644 --- a/database/factories/BoostFactory.php +++ b/database/factories/BoostFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\BoostPlacement; use App\Models\Boost; use App\Models\PressRelease; use App\Models\User; @@ -20,6 +21,7 @@ class BoostFactory extends Factory return [ 'press_release_id' => PressRelease::factory(), 'user_id' => User::factory(), + 'placement' => BoostPlacement::Home, 'days' => 7, 'credits_charged' => 12, 'starts_at' => now(), @@ -34,4 +36,15 @@ class BoostFactory extends Factory 'ends_at' => now()->subDay(), ]); } + + /** + * Kategorie-Boost (nur Branchenseite, halber Satz). + */ + public function category(): static + { + return $this->state(fn (): array => [ + 'placement' => BoostPlacement::Category, + 'credits_charged' => 6, + ]); + } } diff --git a/database/migrations/2026_07_13_140855_add_placement_to_boosts_table.php b/database/migrations/2026_07_13_140855_add_placement_to_boosts_table.php new file mode 100644 index 00000000..526f9e73 --- /dev/null +++ b/database/migrations/2026_07_13_140855_add_placement_to_boosts_table.php @@ -0,0 +1,31 @@ +string('placement', 20)->default('home')->after('user_id'); + + $table->index(['placement', 'ends_at']); + }); + } + + public function down(): void + { + Schema::table('boosts', function (Blueprint $table): void { + $table->dropIndex(['placement', 'ends_at']); + $table->dropColumn('placement'); + }); + } +}; diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index c059d331..4fec1d76 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -1,5 +1,6 @@ $t === $currentTier, ])->all(), 'boostStaffel' => $pricing->boostOptions(), + 'boostCategoryStaffel' => $pricing->boostOptions(BoostPlacement::Category), 'proofPrice' => $pricing->proofPdfCredits(), ]; } @@ -953,12 +955,13 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte - {{-- Boost nach Laufzeit --}} + {{-- Boost nach Laufzeit (zwei Stufen — 13.07.2026) --}}

{{ __('Boost — Platzierung') }}

+
{{ __('Startseiten-Boost') }}
@foreach ($boostStaffel as $days => $credits) @@ -969,6 +972,17 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte @endforeach
+
{{ __('Kategorie-Boost (nur Branchenseite)') }}
+ + + @foreach ($boostCategoryStaffel as $days => $credits) + + + + + @endforeach + +
{{ __(':days Tage', ['days' => $days]) }}{{ $credits }} {{ __('Credits') }}
{{-- Veröffentlichungsnachweis --}} diff --git a/resources/views/livewire/customer/press-releases/index.blade.php b/resources/views/livewire/customer/press-releases/index.blade.php index 9dfa9917..6b006bfd 100644 --- a/resources/views/livewire/customer/press-releases/index.blade.php +++ b/resources/views/livewire/customer/press-releases/index.blade.php @@ -487,7 +487,8 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class @if ($boostEndsAt = $boostEndsById->get($pr->id)) {{-- Laufende Boost-Platzierung (Add-on) direkt unter dem Status. --}}
- + {{-- Deckt beide Stufen ab (Startseiten-/Kategorie-Boost), daher neutral formuliert. --}} + {{ __('Boost bis :date', ['date' => $boostEndsAt->format('d.m.Y')]) }}
diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 968bfa93..cc2d76cf 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -1,5 +1,6 @@ boostOptions(); + $placement = BoostPlacement::tryFrom($placement); + + if (! $placement) { + return; + } + + $options = app(CreditPricingService::class)->boostOptions($placement); if (! isset($options[$days])) { return; } - // Läuft bereits ein Boost, verlängert die Buchung dessen Laufzeit — - // das Modal weist ausdrücklich darauf hin (Entscheidung 10.07.). - $activeUntil = app(BoostService::class)->activeUntil($this->getMyPR()); + // Läuft bereits ein Boost dieser Stufe, verlängert die Buchung dessen + // Laufzeit — das Modal weist ausdrücklich darauf hin (10.07.). + $activeUntil = app(BoostService::class)->activeUntil($this->getMyPR(), $placement); $this->purchaseConfirmed = false; $this->pendingPurchase = [ 'type' => 'boost', - 'label' => __('Boost — :days Tage Platzierung', ['days' => $days]), + 'placement' => $placement->value, + 'label' => __(':placement — :days Tage', ['placement' => $placement->label(), 'days' => $days]), 'credits' => $options[$days], 'days' => $days, 'extendsUntil' => $activeUntil?->format('d.m.Y H:i'), @@ -171,6 +180,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends $this->purchaseConfirmed = false; $this->pendingPurchase = [ 'type' => 'proof', + 'placement' => null, 'label' => __('Veröffentlichungsnachweis (PDF)'), 'credits' => app(ProofPdfService::class)->priceCredits(), 'days' => null, @@ -209,7 +219,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends } if ($pending['type'] === 'boost') { - $this->buyBoost($pending['days']); + $this->buyBoost($pending['days'], $pending['placement'] ?? 'home'); return; } @@ -219,18 +229,26 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends /** * Boost (Platzierung) aus der Wallet buchen — nur veröffentlichte, grüne - * PMs sind boostbar (Gate im BoostService). + * PMs sind boostbar; ausgebuchte Slots lehnt der BoostService ab (Gate). */ - public function buyBoost(int $days): void + public function buyBoost(int $days, string $placement = 'home'): void { + $placement = BoostPlacement::tryFrom($placement); + + if (! $placement) { + return; + } + $pr = $this->getMyPR(); $this->authorize('view', $pr); try { - app(BoostService::class)->boost(auth()->user(), $pr, $days); + app(BoostService::class)->boost(auth()->user(), $pr, $days, $placement); Flux::toast( - heading: __('Boost gebucht'), - text: __('Ihre Pressemitteilung wird jetzt auf Start- und Branchenseite hervorgehoben.'), + heading: __(':placement gebucht', ['placement' => $placement->label()]), + text: $placement === BoostPlacement::Home + ? __('Ihre Pressemitteilung wird jetzt auf Start- und Branchenseite hervorgehoben.') + : __('Ihre Pressemitteilung wird jetzt auf der Branchenseite ihrer Rubrik hervorgehoben.'), variant: 'success', ); } catch (InsufficientCreditsException $e) { @@ -300,6 +318,41 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends ]); } + /** + * Anzeige-Daten der beiden Boost-Stufen (Startseiten-/Kategorie-Boost) + * fürs Add-on-Panel: Preise, laufende Platzierung, Slot-Verfügbarkeit + * („Derzeit ausgebucht — frei ab …", Entscheidung 13.07.). + * + * @return array, isBoosted: bool, activeUntil: \Illuminate\Support\Carbon|null, available: bool, freeAt: \Illuminate\Support\Carbon|null}> + */ + private function boostAddOns(PressRelease $pr, BoostService $boostService): array + { + $pricing = app(CreditPricingService::class); + $canBoost = $boostService->canBoost($pr); + $addOns = []; + + foreach (BoostPlacement::cases() as $placement) { + // Slot-Abfrage nur, wenn die PM überhaupt boostbar ist. + $availability = $canBoost + ? $boostService->slotAvailability($pr, $placement) + : ['available' => false, 'freeAt' => null]; + + $addOns[$placement->value] = [ + 'label' => $placement->label(), + 'description' => $placement === BoostPlacement::Home + ? __('Hervorhebung auf Start- und Branchenseite.') + : __('Hervorhebung auf der Branchenseite Ihrer Rubrik.'), + 'options' => $pricing->boostOptions($placement), + 'isBoosted' => $boostService->isBoosted($pr, $placement), + 'activeUntil' => $boostService->activeUntil($pr, $placement), + 'available' => $availability['available'], + 'freeAt' => $availability['freeAt'], + ]; + } + + return $addOns; + } + public function with(): array { $pr = $this->getMyPR(); @@ -335,9 +388,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends : [], 'creditBalance' => $user->creditBalance(), 'canBoost' => $boostService->canBoost($pr), - 'isBoosted' => $boostService->isBoosted($pr), - 'boostedUntil' => $boostService->activeUntil($pr), - 'boostOptions' => app(CreditPricingService::class)->boostOptions(), + 'boostAddOns' => $this->boostAddOns($pr, $boostService), 'proofPrice' => $proofService->priceCredits(), 'proofPurchased' => $proofService->hasPurchased($user, $pr), 'companyLogoUrl' => $company?->logoUrl(), @@ -647,45 +698,60 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends {{ __('Add-ons') }} {{ __(':n Credits Guthaben', ['n' => $creditBalance]) }} -
- {{-- Boost --}} -
-
-
- +
+ {{-- Boost-Stufen: Startseiten-Boost + Kategorie-Boost (13.07.) --}} + @foreach ($boostAddOns as $placementValue => $addOn) +
+
+
+ @if ($placementValue === 'home') + + @else + + @endif +
+
+

{{ $addOn['label'] }}

+

+ {{ $addOn['description'] }} +

+
-
-

{{ __('Boost — Platzierung') }}

-

- {{ __('Hervorhebung auf Start- und Branchenseite.') }} -

-
-
- @if ($isBoosted) -
- {{ __('Aktiv bis :date', ['date' => $boostedUntil?->format('d.m.Y H:i')]) }} -
- @endif - - @if ($canBoost) -
- @foreach ($boostOptions as $days => $credits) - - {{ __(':days Tage für :credits Credits buchen', ['days' => $days, 'credits' => $credits]) }} - - @endforeach -
- @if ($isBoosted) -

{{ __('Ein weiterer Kauf verlängert die Laufzeit.') }}

+ @if ($addOn['isBoosted']) +
+ {{ __('Aktiv bis :date', ['date' => $addOn['activeUntil']?->format('d.m.Y H:i')]) }} +
@endif - @else -

- {{ __('Nur grün geprüfte Pressemitteilungen sind boostbar.') }} -

- @endif -
+ + @if ($canBoost && $addOn['available']) +
+ @foreach ($addOn['options'] as $days => $credits) + + {{ __(':days Tage für :credits Credits buchen', ['days' => $days, 'credits' => $credits]) }} + + @endforeach +
+ @if ($addOn['isBoosted']) +

{{ __('Ein weiterer Kauf verlängert die Laufzeit.') }}

+ @endif + @elseif ($canBoost) + {{-- Slot-Inventar erschöpft: ehrlich ausweisen statt verkaufen (13.07.). --}} +
+ @if ($addOn['freeAt']) + {{ __('Derzeit ausgebucht — der nächste Platz wird am :date frei.', ['date' => $addOn['freeAt']->format('d.m.Y')]) }} + @else + {{ __('Derzeit ausgebucht.') }} + @endif +
+ @else +

+ {{ __('Nur grün geprüfte Pressemitteilungen sind boostbar.') }} +

+ @endif +
+ @endforeach {{-- Veröffentlichungsnachweis --}}
diff --git a/tests/Feature/BoostServiceTest.php b/tests/Feature/BoostServiceTest.php index 8aa1cef4..568026d7 100644 --- a/tests/Feature/BoostServiceTest.php +++ b/tests/Feature/BoostServiceTest.php @@ -1,9 +1,13 @@ wallet = app(CreditWalletService::class); }); -function boostableRelease(User $user): PressRelease +function boostableRelease(User $user, array $attributes = []): PressRelease { return PressRelease::factory()->published()->create([ 'user_id' => $user->id, 'classification' => PressReleaseClassification::Green->value, + ...$attributes, ]); } +/** + * Belegt `$count` Slots der Stufe mit fremden, aktiv geboosteten PMs + * derselben Ausgabe (Portal × Sprache, optional Rubrik). + * + * @return array + */ +function occupySlots(int $count, BoostPlacement $placement, Portal $portal, ?Category $category = null): array +{ + return collect(range(1, $count))->map(function (int $i) use ($placement, $portal, $category): Boost { + $pr = PressRelease::factory()->published()->forPortal($portal)->create([ + 'classification' => PressReleaseClassification::Green->value, + 'category_id' => ($category ?? Category::factory()->create())->id, + ]); + + return Boost::factory()->for($pr)->create([ + 'placement' => $placement, + 'ends_at' => now()->addDays($i), + ]); + })->all(); +} + test('only published green releases are boostable', function () { $user = User::factory()->create(); @@ -96,3 +122,114 @@ test('a second boost extends from the running end instead of overlapping', funct expect($second->starts_at->timestamp)->toBe($first->ends_at->timestamp); expect($second->ends_at->timestamp)->toBe($first->ends_at->copy()->addDays(14)->timestamp); }); + +test('a category boost costs half the staffel and only activates the category placement', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 10); + $pr = boostableRelease($user); + + $boost = $this->service->boost($user, $pr, 7, BoostPlacement::Category); + + expect($boost->placement)->toBe(BoostPlacement::Category); + expect($boost->credits_charged)->toBe(6); + expect($this->wallet->balance($user))->toBe(4); + expect($this->service->isBoosted($pr, BoostPlacement::Category))->toBeTrue(); + expect($this->service->isBoosted($pr, BoostPlacement::Home))->toBeFalse(); +}); + +test('the placements extend independently of each other', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 100); + $pr = boostableRelease($user); + + $home = $this->service->boost($user, $pr, 30, BoostPlacement::Home); + $category = $this->service->boost($user, $pr, 7, BoostPlacement::Category); + + // Der Kategorie-Boost startet sofort — er verlängert NICHT den laufenden + // Startseiten-Boost (getrennte Stufen). + expect($category->starts_at->timestamp)->toBeLessThan($home->ends_at->timestamp); + expect($this->service->activeUntil($pr, BoostPlacement::Category)->timestamp) + ->toBe($category->ends_at->timestamp); +}); + +test('a full homepage slot inventory rejects new home boosts with the next free date', function () { + $this->freezeTime(); + + $user = User::factory()->create(); + $this->wallet->credit($user, 50); + $pr = boostableRelease($user, ['portal' => Portal::Presseecho->value]); + + // 5 von 5 Startseiten-Slots der Ausgabe belegt; der früheste wird in + // einem Tag frei. + occupySlots(5, BoostPlacement::Home, Portal::Presseecho); + + expect(fn () => $this->service->boost($user, $pr, 7)) + ->toThrow(BoostNotAllowedException::class, 'ausgebucht'); + expect($this->wallet->balance($user))->toBe(50); + + $availability = $this->service->slotAvailability($pr, BoostPlacement::Home); + expect($availability['available'])->toBeFalse(); + expect($availability['freeAt']->timestamp)->toBe(now()->addDay()->timestamp); +}); + +test('an extension is allowed even when all slots are taken — the release keeps its slot', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 50); + $pr = boostableRelease($user, ['portal' => Portal::Presseecho->value]); + + $first = $this->service->boost($user, $pr, 7); + occupySlots(5, BoostPlacement::Home, Portal::Presseecho); + + $second = $this->service->boost($user, $pr, 7); + + expect($second->starts_at->timestamp)->toBe($first->ends_at->timestamp); +}); + +test('slots on another portal or in another language do not block the booking', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 50); + $pr = boostableRelease($user, ['portal' => Portal::Presseecho->value, 'language' => 'de']); + + // Volle Startseite beim anderen Spoke — für diese PM irrelevant. + occupySlots(5, BoostPlacement::Home, Portal::Businessportal24); + + expect($this->service->boost($user, $pr, 7))->toBeInstanceOf(Boost::class); +}); + +test('a full category page rejects new category boosts while other categories stay open', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 50); + + $category = Category::factory()->create(); + $pr = boostableRelease($user, [ + 'portal' => Portal::Presseecho->value, + 'category_id' => $category->id, + ]); + + // 3 von 3 Kategorie-Slots dieser Rubrik belegt. + occupySlots(3, BoostPlacement::Category, Portal::Presseecho, $category); + + expect(fn () => $this->service->boost($user, $pr, 7, BoostPlacement::Category)) + ->toThrow(BoostNotAllowedException::class, 'ausgebucht'); + + // Eine PM in einer anderen Rubrik ist nicht betroffen. + $other = boostableRelease($user, ['portal' => Portal::Presseecho->value]); + expect($this->service->boost($user, $other, 7, BoostPlacement::Category))->toBeInstanceOf(Boost::class); +}); + +test('home boosts do not consume category slots and vice versa', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 50); + + $category = Category::factory()->create(); + $pr = boostableRelease($user, [ + 'portal' => Portal::Presseecho->value, + 'category_id' => $category->id, + ]); + + // Startseite komplett voll (auch mit PMs derselben Rubrik) — die + // Kategorie-Slots bleiben davon unberührt. + occupySlots(5, BoostPlacement::Home, Portal::Presseecho, $category); + + expect($this->service->boost($user, $pr, 7, BoostPlacement::Category))->toBeInstanceOf(Boost::class); +}); diff --git a/tests/Feature/CreditPricingServiceTest.php b/tests/Feature/CreditPricingServiceTest.php index 21c45ec7..4e208ee0 100644 --- a/tests/Feature/CreditPricingServiceTest.php +++ b/tests/Feature/CreditPricingServiceTest.php @@ -1,5 +1,6 @@ toThrow(InvalidArgumentException::class); }); +test('category boost credits are half the homepage staffel, rounded up', function () { + expect($this->pricing->boostCredits(7, BoostPlacement::Category))->toBe(6); + expect($this->pricing->boostCredits(14, BoostPlacement::Category))->toBe(10); + expect($this->pricing->boostCredits(30, BoostPlacement::Category))->toBe(18); + expect($this->pricing->boostOptions(BoostPlacement::Category))->toBe([7 => 6, 14 => 10, 30 => 18]); + + expect(fn () => $this->pricing->boostCredits(99, BoostPlacement::Category)) + ->toThrow(InvalidArgumentException::class); +}); + test('flat prices match the decision update', function () { expect($this->pricing->proofPdfCredits())->toBe(3); expect($this->pricing->depublishCredits())->toBe(35); diff --git a/tests/Feature/PressReleaseShowAddonsTest.php b/tests/Feature/PressReleaseShowAddonsTest.php index 3bda163b..9d100231 100644 --- a/tests/Feature/PressReleaseShowAddonsTest.php +++ b/tests/Feature/PressReleaseShowAddonsTest.php @@ -1,5 +1,6 @@ $pr->id]) ->assertSee('Add-ons') - ->assertSee('Boost — Platzierung') + ->assertSee('Startseiten-Boost') ->assertSee('7 Tage für 12 Credits buchen') + ->assertSee('Kategorie-Boost') + ->assertSee('7 Tage für 6 Credits buchen') ->assertSee('Veröffentlichungsnachweis') ->assertSee('Für 3 Credits kaufen'); }); @@ -66,7 +70,8 @@ test('add-on buttons only debit after the purchase confirmation modal', function ->call('confirmBoost', 14) ->assertSet('pendingPurchase', [ 'type' => 'boost', - 'label' => 'Boost — 14 Tage Platzierung', + 'placement' => 'home', + 'label' => 'Startseiten-Boost — 14 Tage', 'credits' => 20, 'days' => 14, 'extendsUntil' => null, @@ -160,6 +165,57 @@ test('booking a boost from the detail page debits the wallet and activates it', expect($pr->fresh()->isBoosted())->toBeTrue(); }); +test('a category boost books through the confirmation modal at the half staffel', function () { + /** @var TestCase $this */ + ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); + $this->wallet->credit($customer, 10); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->call('confirmBoost', 7, 'category') + ->assertSet('pendingPurchase.placement', 'category') + ->assertSet('pendingPurchase.label', 'Kategorie-Boost — 7 Tage') + ->assertSet('pendingPurchase.credits', 6) + ->set('purchaseConfirmed', true) + ->call('buyPendingAddOn'); + + expect($customer->fresh()->creditBalance())->toBe(4); // 10 - 6 (7 Tage, halber Satz) + expect(app(BoostService::class)->isBoosted($pr->fresh(), BoostPlacement::Category))->toBeTrue(); + expect(app(BoostService::class)->isBoosted($pr->fresh(), BoostPlacement::Home))->toBeFalse(); +}); + +test('sold-out slots replace the buy buttons with the next free date', function () { + /** @var TestCase $this */ + ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); + $this->wallet->credit($customer, 40); + + // Alle 5 Startseiten-Slots der Ausgabe belegt (fremde PMs, gleiche Ausgabe). + $freeAt = now()->addDays(2); + foreach (range(1, 5) as $i) { + $other = PressRelease::factory()->published()->create([ + 'portal' => $pr->portal->value, + 'classification' => PressReleaseClassification::Green->value, + ]); + Boost::factory()->for($other)->create(['ends_at' => $freeAt->copy()->addDays($i - 1)]); + } + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->assertSee('Derzeit ausgebucht — der nächste Platz wird am '.$freeAt->format('d.m.Y').' frei.') + ->assertDontSee('7 Tage für 12 Credits buchen') + // Der Kategorie-Boost hat ein eigenes Slot-Inventar und bleibt buchbar. + ->assertSee('7 Tage für 6 Credits buchen'); + + // Serverseitiges Gate: ein direkter Kauf-Call bucht trotzdem nicht. + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->call('buyBoost', 7); + + expect($customer->fresh()->creditBalance())->toBe(40); + expect($pr->fresh()->isBoosted())->toBeFalse(); +}); + test('buying a proof from the detail page unlocks the download', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); diff --git a/tests/Feature/Web/BoostPlacementTest.php b/tests/Feature/Web/BoostPlacementTest.php index 90087741..9e74d8e7 100644 --- a/tests/Feature/Web/BoostPlacementTest.php +++ b/tests/Feature/Web/BoostPlacementTest.php @@ -1,5 +1,6 @@ $boosted, 'newest' => $newest] = boostPlacementFixture(); + + Boost::factory()->for($boosted)->category()->create(); + + // Branchenseite: Kategorie-Boost besetzt den Lead-Slot. + $this->get('https://presseecho.test/de/category/energie-klima') + ->assertSuccessful() + ->assertSeeTextInOrder(['Top-Platzierung', 'Geboostete aeltere Meldung']); + + // Startseite: bleibt bei der Chronologie — der Kategorie-Boost kauft + // sie nicht mit (Entscheidung 13.07.). + $data = app(HomePageData::class)->build(Portal::Presseecho); + expect($data['leadRelease']->id)->toBe($newest->id) + ->and($data['boostedIds'])->toBe([]); +}); + +test('category boosts take the category slots ahead of homepage boosts', function () { + /** @var TestCase $this */ + ['boosted' => $categoryBoosted, 'newest' => $newest, 'category' => $category, 'company' => $company] = boostPlacementFixture(); + + // Neuere PM mit Startseiten-Boost — auf der Branchenseite hat der + // Kategorie-Boost trotzdem Vorrang (seine 3 Slots sind die Garantie). + $homeBoosted = PressRelease::factory() + ->published() + ->forPortal(Portal::Presseecho) + ->for($category) + ->for($company) + ->create([ + 'title' => 'Startseiten-Boost Meldung', + 'slug' => 'startseiten-boost-meldung', + 'published_at' => now()->subHour(), + 'classification' => PressReleaseClassification::Green->value, + ]); + + Boost::factory()->for($categoryBoosted)->category()->create(); + Boost::factory()->for($homeBoosted)->create(); + + // Ohne Vorrang würde die neuere Startseiten-Boost-Meldung den Lead + // chronologisch gewinnen — der Kategorie-Boost muss vorne stehen. + $this->get('https://presseecho.test/de/category/energie-klima') + ->assertSuccessful() + ->assertSeeTextInOrder([ + 'Geboostete aeltere Meldung', + 'Startseiten-Boost Meldung', + ]); +}); + +test('booking a category boost invalidates the category page but keeps the homepage cache', function () { + /** @var TestCase $this */ + ['boosted' => $boosted, 'newest' => $newest] = boostPlacementFixture(); + + $owner = User::factory()->create(['is_active' => true]); + app(CreditWalletService::class)->credit($owner, 40); + + // Beide Seiten in den Cache legen — noch ohne Boost. + expect(app(HomePageData::class)->build(Portal::Presseecho)['leadRelease']->id)->toBe($newest->id); + $this->get('https://presseecho.test/de/category/energie-klima') + ->assertSuccessful() + ->assertDontSeeText('Top-Platzierung'); + + app(BoostService::class)->boost($owner, $boosted, 7, BoostPlacement::Category); + + // Branchenseite sofort aktualisiert, Startseiten-Cache bleibt stehen + // (der Kategorie-Boost berührt die Startseite nicht). + expect(Cache::has('web.home.presseecho.de'))->toBeTrue(); + $this->get('https://presseecho.test/de/category/energie-klima') + ->assertSuccessful() + ->assertSeeText('Top-Platzierung'); +});