service = app(BoostService::class); $this->wallet = app(CreditWalletService::class); }); 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(); expect($this->service->canBoost(boostableRelease($user)))->toBeTrue(); $yellow = PressRelease::factory()->published()->create([ 'classification' => PressReleaseClassification::Yellow->value, ]); expect($this->service->canBoost($yellow))->toBeFalse(); $draftGreen = PressRelease::factory()->create([ 'status' => PressReleaseStatus::Draft->value, 'classification' => PressReleaseClassification::Green->value, ]); expect($this->service->canBoost($draftGreen))->toBeFalse(); }); test('booking a boost debits the wallet and activates the placement', function () { $user = User::factory()->create(); $this->wallet->credit($user, 20); $pr = boostableRelease($user); $boost = $this->service->boost($user, $pr, 14); expect($boost->days)->toBe(14); expect($boost->credits_charged)->toBe(20); expect($this->wallet->balance($user))->toBe(0); expect($this->service->isBoosted($pr))->toBeTrue(); expect($pr->fresh()->isBoosted())->toBeTrue(); }); test('boosting a non-green release is rejected at the gate', function () { $user = User::factory()->create(); $this->wallet->credit($user, 50); $red = PressRelease::factory()->published()->create([ 'classification' => PressReleaseClassification::Red->value, ]); expect(fn () => $this->service->boost($user, $red, 7)) ->toThrow(BoostNotAllowedException::class); expect($this->wallet->balance($user))->toBe(50); }); test('an unaffordable boost throws and creates no boost record', function () { $user = User::factory()->create(); $this->wallet->credit($user, 5); $pr = boostableRelease($user); expect(fn () => $this->service->boost($user, $pr, 7)) ->toThrow(InsufficientCreditsException::class); expect($pr->boosts()->count())->toBe(0); expect($this->wallet->balance($user))->toBe(5); }); test('an unknown duration is rejected', function () { $user = User::factory()->create(); $this->wallet->credit($user, 50); $pr = boostableRelease($user); expect(fn () => $this->service->boost($user, $pr, 99)) ->toThrow(InvalidArgumentException::class); }); test('a second boost extends from the running end instead of overlapping', function () { $user = User::factory()->create(); $this->wallet->credit($user, 100); $pr = boostableRelease($user); $first = $this->service->boost($user, $pr, 7); $second = $this->service->boost($user, $pr, 14); 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); });