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) { $mock->shouldReceive('forDepublication')->once()->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); // Doppelbuchungs-Bremse: zweiter Aufruf legt keinen weiteren Kauf an. $this->actingAs($user) ->get(route('me.checkout.depublication', $release->id)) ->assertRedirect(route('me.bookings.index')); expect(SinglePurchase::count())->toBe(1); }); test('the paid webhook depublishes the release and the public page shows a tombstone', function () { /** @var TestCase $this */ $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', '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(); expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Paid); expect($purchase->fresh()->consumed_at)->not->toBeNull(); expect($release->statusLogs()->count())->toBeGreaterThan(0); // Ö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(); });