seed(RolesAndPermissionsSeeder::class); $this->wallet = app(CreditWalletService::class); }); /** * @return array{customer: User, pr: PressRelease} */ function publishedGreenPrForOwner(): array { $customer = User::factory()->create(['is_active' => true]); $customer->assignRole('customer'); $company = Company::factory()->presseecho()->create(); $customer->companies()->attach($company->id, ['role' => 'owner']); $pr = PressRelease::factory()->published()->create([ 'user_id' => $customer->id, 'company_id' => $company->id, 'category_id' => Category::factory()->create()->id, 'portal' => $company->portal->value, 'classification' => PressReleaseClassification::Green->value, ]); return compact('customer', 'pr'); } test('a published green release shows the boost and proof add-ons', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->wallet->credit($customer, 40); $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSee('Add-ons') ->assertSee('Startseiten-Boost') ->assertSee('7 Tage für 12 Credits buchen') ->assertSee('Kategorie-Boost') ->assertSee('7 Tage für 6 Credits buchen') // Slot-Transparenz: freie Plätze je Stufe (5 Startseite, 3 Branchenseite). ->assertSee('Noch 5 von 5 Plätzen frei.') ->assertSee('Noch 3 von 3 Plätzen frei.') ->assertSee('Veröffentlichungsnachweis') ->assertSee('Für 3 Credits kaufen'); }); test('the slot hint counts down when competitors occupy places', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->wallet->credit($customer, 40); // 2 der 5 Startseiten-Slots der Ausgabe sind belegt. foreach (range(1, 2) as $i) { $other = PressRelease::factory()->published()->create([ 'portal' => $pr->portal->value, 'classification' => PressReleaseClassification::Green->value, ]); Boost::factory()->for($other)->create(); } $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSee('Noch 3 von 5 Plätzen frei.') ->assertSee('Noch 3 von 3 Plätzen frei.'); }); test('add-on buttons only debit after the purchase confirmation modal', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->wallet->credit($customer, 40); $this->actingAs($customer); $component = Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->call('confirmBoost', 14) ->assertSet('pendingPurchase', [ 'type' => 'boost', 'placement' => 'home', 'label' => 'Startseiten-Boost — 14 Tage', 'credits' => 20, 'days' => 14, 'extendsUntil' => null, 'newEndsAt' => null, ]) ->assertSee('Kauf bestätigen') ->assertSee('Kostenpflichtig buchen'); // Der Klick auf die Laufzeit selbst bucht noch nichts ab. expect($customer->fresh()->creditBalance())->toBe(40); expect($pr->fresh()->isBoosted())->toBeFalse(); // Ohne den Pflicht-Schalter bleibt der Kauf aus und das Modal offen. $component->call('buyPendingAddOn') ->assertSet('pendingPurchase.type', 'boost'); expect($customer->fresh()->creditBalance())->toBe(40); expect($pr->fresh()->isBoosted())->toBeFalse(); $component->set('purchaseConfirmed', true) ->call('buyPendingAddOn') ->assertSet('pendingPurchase', null) ->assertSet('purchaseConfirmed', false); expect($customer->fresh()->creditBalance())->toBe(20); expect($pr->fresh()->isBoosted())->toBeTrue(); }); test('the confirmation modal warns when the purchase extends a running boost', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->wallet->credit($customer, 40); $activeUntil = now()->addDays(3)->setTime(12, 0); Boost::factory()->for($pr)->for($customer)->create(['ends_at' => $activeUntil]); $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->call('confirmBoost', 7) ->assertSet('pendingPurchase.extendsUntil', $activeUntil->format('d.m.Y H:i')) ->assertSet('pendingPurchase.newEndsAt', $activeUntil->copy()->addDays(7)->format('d.m.Y H:i')) ->assertSee('Diese Buchung verlängert den laufenden Boost.'); }); test('the proof purchase runs through the confirmation modal as well', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->wallet->credit($customer, 10); $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->call('confirmProof') ->assertSet('pendingPurchase.type', 'proof') ->assertSet('pendingPurchase.credits', 3) ->set('purchaseConfirmed', true) ->call('buyPendingAddOn') ->assertSee('Nachweis herunterladen'); expect(app(ProofPdfService::class)->hasPurchased($customer->fresh(), $pr))->toBeTrue(); expect($customer->fresh()->creditBalance())->toBe(7); }); test('an unknown boost duration does not open the confirmation modal', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->wallet->credit($customer, 40); $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->call('confirmBoost', 999) ->assertSet('pendingPurchase', null) ->call('buyPendingAddOn'); expect($customer->fresh()->creditBalance())->toBe(40); // nichts abgebucht }); test('booking a boost from the detail page debits the wallet and activates it', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->wallet->credit($customer, 40); $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->call('buyBoost', 14); expect($customer->fresh()->creditBalance())->toBe(20); // 40 - 20 (14 Tage) expect($pr->fresh()->isBoosted())->toBeTrue(); }); test('a category boost books through the confirmation modal at the half staffel', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->wallet->credit($customer, 10); $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->call('confirmBoost', 7, 'category') ->assertSet('pendingPurchase.placement', 'category') ->assertSet('pendingPurchase.label', 'Kategorie-Boost — 7 Tage') ->assertSet('pendingPurchase.credits', 6) ->set('purchaseConfirmed', true) ->call('buyPendingAddOn'); expect($customer->fresh()->creditBalance())->toBe(4); // 10 - 6 (7 Tage, halber Satz) expect(app(BoostService::class)->isBoosted($pr->fresh(), BoostPlacement::Category))->toBeTrue(); expect(app(BoostService::class)->isBoosted($pr->fresh(), BoostPlacement::Home))->toBeFalse(); }); test('sold-out slots replace the buy buttons with the next free date', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->wallet->credit($customer, 40); // Alle 5 Startseiten-Slots der Ausgabe belegt (fremde PMs, gleiche Ausgabe). $freeAt = now()->addDays(2); foreach (range(1, 5) as $i) { $other = PressRelease::factory()->published()->create([ 'portal' => $pr->portal->value, 'classification' => PressReleaseClassification::Green->value, ]); Boost::factory()->for($other)->create(['ends_at' => $freeAt->copy()->addDays($i - 1)]); } $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSee('Derzeit ausgebucht — der nächste Platz wird am '.$freeAt->format('d.m.Y').' frei.') ->assertDontSee('7 Tage für 12 Credits buchen') // Der Kategorie-Boost hat ein eigenes Slot-Inventar und bleibt buchbar. ->assertSee('7 Tage für 6 Credits buchen'); // Serverseitiges Gate: ein direkter Kauf-Call bucht trotzdem nicht. Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->call('buyBoost', 7); expect($customer->fresh()->creditBalance())->toBe(40); expect($pr->fresh()->isBoosted())->toBeFalse(); }); test('buying a proof from the detail page unlocks the download', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->wallet->credit($customer, 10); $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->call('buyProof') ->assertSee('Nachweis herunterladen'); expect(app(ProofPdfService::class)->hasPurchased($customer->fresh(), $pr))->toBeTrue(); expect($customer->fresh()->creditBalance())->toBe(7); }); test('an add-on purchase without enough credits opens the shortfall modal with a topup link', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->wallet->credit($customer, 1); $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->call('buyProof') ->assertSet('creditShortfall', [ 'label' => 'Der Veröffentlichungsnachweis', 'required' => 3, 'available' => 1, ]) ->assertSee('Nicht genügend Credits') ->assertSee('Es fehlen 2 Credits.') ->assertSee('Credits aufladen') ->assertSee(route('me.bookings.index').'#credits', escape: false); expect($customer->fresh()->creditBalance())->toBe(1); // nichts abgebucht }); test('a boost without enough credits opens the shortfall modal instead of booking', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->call('buyBoost', 7) ->assertSet('creditShortfall.label', 'Der Boost') ->assertSee('Nicht genügend Credits'); expect($pr->fresh()->isBoosted())->toBeFalse(); }); test('the press release list shows a hint with end date for an active boost', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->wallet->credit($customer, 40); $this->actingAs($customer); // Ohne laufenden Boost wirbt die grüne, veröffentlichte PM für das Add-on. Livewire::test('customer.press-releases.index') ->assertDontSee('Boost bis') ->assertSee('Boost möglich'); Boost::factory()->for($pr)->create([ 'ends_at' => now()->addDays(7)->setTime(9, 0), ]); Livewire::test('customer.press-releases.index') ->assertSee('Boost bis '.now()->addDays(7)->format('d.m.Y')) ->assertDontSee('Boost möglich'); }); test('the list shows the category under the portal and a green check for green releases', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $pr->category->translations()->create(['locale' => 'de', 'name' => 'Energie & Klima', 'slug' => 'energie-klima']); $this->actingAs($customer); Livewire::test('customer.press-releases.index') ->assertSee('Energie & Klima') ->assertSee('Inhaltsprüfung: Grün'); }); test('a non-green release shows neither the green check nor the boost hint', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $pr->update(['classification' => PressReleaseClassification::Yellow->value]); $this->actingAs($customer); Livewire::test('customer.press-releases.index') ->assertDontSee('Inhaltsprüfung: Grün') ->assertDontSee('Boost möglich'); }); test('an expired boost shows no hint in the press release list', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $this->actingAs($customer); Boost::factory()->for($pr)->expired()->create(); Livewire::test('customer.press-releases.index') ->assertDontSee('Boost bis'); }); test('a non-green release cannot be boosted from the detail page', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); $pr->update(['classification' => PressReleaseClassification::Yellow->value]); $this->wallet->credit($customer, 40); $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSee('Nur grün geprüfte Pressemitteilungen sind boostbar') ->call('buyBoost', 7); expect($customer->fresh()->creditBalance())->toBe(40); // nichts abgebucht expect($pr->fresh()->isBoosted())->toBeFalse(); });