- 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>
280 lines
11 KiB
PHP
280 lines
11 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;
|
||
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();
|
||
});
|