seed(RolesAndPermissionsSeeder::class); config()->set('billing.depublication_price_cents', 19900); }); function depublicationCustomer(): User { $user = User::factory()->create(['is_active' => true, 'email_verified_at' => now()]); $user->assignRole('customer'); BillingAddress::factory()->create(['user_id' => $user->id]); // B2B-Gate (T1neu): zeitnahe Unternehmer-Bestätigung. BookingConfirmation::query()->create([ 'user_id' => $user->id, 'booking_type' => 'depublication', 'agb_version' => (string) config('legal.agb_version'), 'confirmed_at' => now(), ]); return $user; } function publishedReleaseFor(User $user): PressRelease { return PressRelease::factory() ->published() ->forPortal(Portal::Businessportal24) ->create(['user_id' => $user->id, 'published_at' => now()->subDay()]); } test('the depublication checkout is disabled without a configured price', function () { /** @var TestCase $this */ config()->set('billing.depublication_price_cents', null); $user = depublicationCustomer(); $release = publishedReleaseFor($user); $this->actingAs($user) ->get(route('me.checkout.depublication', $release->id)) ->assertRedirect(route('me.bookings.index')) ->assertSessionHas('checkout-notice'); expect(SinglePurchase::count())->toBe(0); }); test('only own published releases can be depublished', function () { /** @var TestCase $this */ $user = depublicationCustomer(); // Fremde PM → 404 (accessibleBy-Scope). $foreign = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(); $this->actingAs($user) ->get(route('me.checkout.depublication', $foreign->id)) ->assertNotFound(); // Eigener Entwurf → Hinweis statt Checkout. $draft = PressRelease::factory()->forPortal(Portal::Businessportal24)->create([ 'user_id' => $user->id, 'status' => PressReleaseStatus::Draft, ]); $this->actingAs($user) ->get(route('me.checkout.depublication', $draft->id)) ->assertRedirect(route('me.bookings.index')) ->assertSessionHas('checkout-notice'); expect(SinglePurchase::count())->toBe(0); }); test('the depublication checkout creates a pending purchase and hands it to stripe', function () { /** @var TestCase $this */ $user = depublicationCustomer(); $release = publishedReleaseFor($user); $checkout = Mockery::mock(Checkout::class); $checkout->shouldReceive('toResponse')->andReturn(new RedirectResponse('https://checkout.stripe.com/c/pay/test')); $this->mock(StripeCheckoutService::class, function ($mock) use ($checkout) { // Zweimal: der zweite Aufruf verwendet den offenen Kauf wieder. $mock->shouldReceive('forDepublication')->twice()->andReturn($checkout); }); $this->actingAs($user) ->get(route('me.checkout.depublication', $release->id)) ->assertRedirect('https://checkout.stripe.com/c/pay/test'); $purchase = SinglePurchase::sole(); expect($purchase->type)->toBe(SinglePurchaseType::Depublication); expect($purchase->status)->toBe(SinglePurchaseStatus::Pending); expect($purchase->price_cents)->toBe(19900); expect($purchase->press_release_id)->toBe($release->id); // Abgebrochener Checkout blockiert nicht: erneuter Aufruf nutzt den // Pending-Kauf wieder statt einen zweiten anzulegen. $this->actingAs($user) ->get(route('me.checkout.depublication', $release->id)) ->assertRedirect('https://checkout.stripe.com/c/pay/test'); expect(SinglePurchase::count())->toBe(1); }); test('a paid depublication blocks a second checkout for the same release', function () { /** @var TestCase $this */ $user = depublicationCustomer(); $release = publishedReleaseFor($user); SinglePurchase::query()->create([ 'user_id' => $user->id, 'type' => SinglePurchaseType::Depublication->value, 'status' => SinglePurchaseStatus::Paid->value, 'price_cents' => 19900, 'currency' => 'EUR', 'press_release_id' => $release->id, ]); $this->actingAs($user) ->get(route('me.checkout.depublication', $release->id)) ->assertRedirect(route('me.bookings.index')) ->assertSessionHas('checkout-notice'); expect(SinglePurchase::count())->toBe(1); }); test('the detail page offers depublication and logs the entrepreneur confirmation', function () { /** @var TestCase $this */ $user = depublicationCustomer(); $release = publishedReleaseFor($user); BookingConfirmation::query()->delete(); // nur die Bestätigung aus dem Test-Setup entfernen $this->actingAs($user); Livewire::test('customer.press-releases.show', ['id' => $release->id]) ->assertSee('Depublikation (Service)') ->assertSee('Kostenpflichtig depublizieren') ->set('depublicationConfirmed', true); $confirmation = BookingConfirmation::sole(); expect($confirmation->booking_type)->toBe('depublication'); expect($confirmation->booking_ref)->toBe((string) $release->id); expect($confirmation->user_id)->toBe($user->id); }); test('the detail page hides the depublication offer without a price and after a purchase', function () { /** @var TestCase $this */ $user = depublicationCustomer(); $release = publishedReleaseFor($user); $this->actingAs($user); // Ohne konfigurierten Preis erscheint die Sektion gar nicht (T12). config()->set('billing.depublication_price_cents', null); Livewire::test('customer.press-releases.show', ['id' => $release->id]) ->assertDontSee('Depublikation (Service)'); // Mit BEZAHLTEM Vorgang: Hinweis statt erneutem Kauf-Button. config()->set('billing.depublication_price_cents', 19900); $purchase = SinglePurchase::query()->create([ 'user_id' => $user->id, 'type' => SinglePurchaseType::Depublication->value, 'status' => SinglePurchaseStatus::Paid->value, 'price_cents' => 19900, 'currency' => 'EUR', 'press_release_id' => $release->id, ]); Livewire::test('customer.press-releases.show', ['id' => $release->id]) ->assertSee('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.') ->assertDontSee('Kostenpflichtig depublizieren'); // Ein offener Pending-Kauf (abgebrochener Checkout) blockiert das // Angebot dagegen nicht — der Button bleibt nutzbar. $purchase->update(['status' => SinglePurchaseStatus::Pending->value]); Livewire::test('customer.press-releases.show', ['id' => $release->id]) ->assertSee('Kostenpflichtig depublizieren') ->assertDontSee('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.'); }); test('the paid webhook depublishes the release and the public page shows a tombstone', function () { /** @var TestCase $this */ Mail::fake(); $user = depublicationCustomer(); $release = publishedReleaseFor($user); $purchase = SinglePurchase::query()->create([ 'user_id' => $user->id, 'type' => SinglePurchaseType::Depublication->value, 'status' => SinglePurchaseStatus::Pending->value, 'price_cents' => 19900, 'currency' => 'EUR', 'press_release_id' => $release->id, ]); $event = new WebhookReceived([ 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_test_depub', 'payment_intent' => 'pi_test_depub', 'payment_status' => 'paid', 'metadata' => ['single_purchase_id' => (string) $purchase->id], ]], ]); app(ProcessStripeWebhook::class)->handle($event); app(ProcessStripeWebhook::class)->handle($event); // Replay bleibt folgenlos. $release->refresh(); expect($release->status)->toBe(PressReleaseStatus::Archived); expect($release->depublished_at)->not->toBeNull(); // Re-Sync-Sperre (Meldeverfahren §7): ohne sie würde der nächste // Importer-Lauf die bezahlte Entfernung überschreiben. expect($release->isSyncLocked())->toBeTrue(); // Eingelöst = Status Consumed — nur so erscheint der Kauf unter // „Eingelöste Käufe" auf der Buchungs-Seite. expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Consumed); expect($purchase->fresh()->consumed_at)->not->toBeNull(); expect($release->statusLogs()->where('source', 'depublication')->count())->toBe(1); // Der Käufer erhält genau eine Bestätigung (Replay löst keine zweite aus). Mail::assertQueued(PressReleaseDepublished::class, function (PressReleaseDepublished $mail) use ($user, $release) { return $mail->hasTo($user->email) && $mail->pressRelease->is($release); }); Mail::assertQueuedCount(1); // Öffentliche URL: Tombstone mit 410 Gone statt 404, ohne Inhalte. $this->get('https://businessportal24.test/de/press-release/'.$release->slug) ->assertStatus(410) ->assertSeeText('Diese Pressemitteilung wurde entfernt') ->assertSeeText('auf Wunsch des Absenders') ->assertDontSeeText($release->title); // Unbekannte Slugs bleiben 404. $this->get('https://businessportal24.test/de/press-release/gibt-es-nicht') ->assertNotFound(); });