presseportale/tests/Feature/BoostServiceTest.php
Kevin Adametz 0474d22347 Boost-Slot-Grenzen (5/3) + Kategorie-Boost zum halben Satz
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 <noreply@anthropic.com>
2026-07-13 12:28:03 +00:00

235 lines
8.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Exceptions\BoostNotAllowedException;
use App\Exceptions\InsufficientCreditsException;
use App\Models\Boost;
use App\Models\Category;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Billing\CreditWalletService;
use App\Services\PressRelease\BoostService;
beforeEach(function (): void {
$this->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<int, Boost>
*/
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);
});