presseportale/tests/Feature/BoostServiceTest.php
Kevin Adametz 0cddf5fbdc Add-on-Panel zeigt freie Boost-Plaetze je Stufe
slotAvailability() liefert zusaetzlich free/limit (knappste Ausgabe, ohne
den eigenen gehaltenen Slot); solange buchbar, steht unter den Kauf-Buttons
"Noch X von Y Plaetzen frei." — bei laufendem Boost weiterhin nur der
Verlaengerungs-Hinweis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:12:05 +00:00

250 lines
9.5 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['free'])->toBe(0);
expect($availability['limit'])->toBe(5);
expect($availability['freeAt']->timestamp)->toBe(now()->addDay()->timestamp);
});
test('the availability reports the remaining free slots', function () {
$user = User::factory()->create();
$pr = boostableRelease($user, ['portal' => Portal::Presseecho->value]);
expect($this->service->slotAvailability($pr, BoostPlacement::Home))
->toMatchArray(['available' => true, 'free' => 5, 'limit' => 5]);
occupySlots(2, BoostPlacement::Home, Portal::Presseecho);
expect($this->service->slotAvailability($pr, BoostPlacement::Home))
->toMatchArray(['available' => true, 'free' => 3, 'limit' => 5]);
});
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);
});