presseportale/tests/Feature/BoostServiceTest.php
Kevin Adametz aae456954a billing: Nebenläufigkeit & Idempotenz härten (H4, Stripe, Boost-Slots)
- CreditTopup-Gutschrift, MAN-Rechnung, Proof-PDF und Stripe-Webhook-
  Spiegelung sperren die betroffene Zeile innerhalb der Transaktion und
  prüfen die Idempotenz erst danach (verhindert Doppelgutschrift bei
  at-least-once-Webhooks, H4).
- Gemeinsamer LocksModelForUpdate-Trait für die häufige Lock-by-Key-Form
  (CreditTopup, ManualInvoice).
- Unique-Index auf invoices.stripe_invoice_id; Boost-Inventar über
  boost_slot_locks serialisiert, verwaiste Kategorie-Locks werden beim
  Löschen der Rubrik entfernt.

Deployment: vor migrate prüfen, ob invoices.stripe_invoice_id bereits
Duplikate enthält (die Unique-Migration bricht sonst bewusst ab).

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

280 lines
11 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;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
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();
expect(DB::table('boost_slot_locks')->where(
'lock_key',
'home:'.$pr->portal->value.':'.$pr->language.':all',
)->exists())->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();
expect(DB::table('boost_slot_locks')->where(
'lock_key',
'category:'.$pr->portal->value.':'.$pr->language.':'.$pr->category_id,
)->exists())->toBeTrue();
});
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);
});
test('deleting a category prunes its orphaned boost slot locks', function () {
/** @var TestCase $this */
$categoryId = 4711;
// Simuliert vorhandene Lock-Zeilen: eine Kategorie-Lock plus eine
// Home-Lock (die nicht betroffen sein darf).
DB::table('boost_slot_locks')->insert([
['lock_key' => 'category:presseecho:de:'.$categoryId],
['lock_key' => 'category:businessportal24:de:'.$categoryId],
['lock_key' => 'home:presseecho:de:all'],
['lock_key' => 'category:presseecho:de:99'],
]);
$this->service->forgetSlotLocksForCategory($categoryId);
expect(DB::table('boost_slot_locks')->where('lock_key', 'like', 'category:%:'.$categoryId)->count())->toBe(0)
->and(DB::table('boost_slot_locks')->where('lock_key', 'home:presseecho:de:all')->exists())->toBeTrue()
->and(DB::table('boost_slot_locks')->where('lock_key', 'category:presseecho:de:99')->exists())->toBeTrue();
});