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>
235 lines
8.9 KiB
PHP
235 lines
8.9 KiB
PHP
<?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);
|
||
});
|