seed(RolesAndPermissionsSeeder::class); // Das Kontingent greift erst mit dem Launch-Schalter (sonst unbegrenzt). config()->set('billing.enforce_booking', true); }); function quotaTestPressRelease(User $user, string $status = 'draft'): PressRelease { $company = Company::factory()->presseecho()->create(); $user->companies()->attach($company->id, ['role' => 'owner']); return PressRelease::factory()->create([ 'user_id' => $user->id, 'company_id' => $company->id, 'category_id' => Category::factory()->create()->id, 'portal' => $company->portal->value, 'status' => $status, ]); } function quotaTestSubscriber(int $planQuota = 3, int $used = 0): User { $user = User::factory()->create([ 'press_release_quota_used_this_month' => $used, ]); $user->assignRole('customer'); $plan = Plan::factory()->create([ 'press_release_quota' => $planQuota, 'stripe_price_id_monthly' => 'price_test_m_'.fake()->unique()->randomNumber(6), ]); subscribeUserToPlan($user, $plan); return $user; } test('without the launch switch the quota is unlimited', function () { config()->set('billing.enforce_booking', false); $user = User::factory()->create(); expect($user->pressReleaseQuotaRemaining())->toBeNull(); expect($user->pressReleaseQuotaTotal())->toBeNull(); }); test('a subscriber inherits the monthly quota of the plan', function () { $user = quotaTestSubscriber(planQuota: 3, used: 1); expect($user->pressReleaseQuotaRemaining())->toBe(2); expect($user->pressReleaseQuotaTotal())->toBe(3); }); test('paid single purchases extend the quota', function () { $user = quotaTestSubscriber(planQuota: 3, used: 3); SinglePurchase::factory()->paid()->count(2)->create(['user_id' => $user->id]); SinglePurchase::factory()->consumed()->create(['user_id' => $user->id]); expect($user->pressReleaseQuotaRemaining())->toBe(2); expect($user->pressReleaseQuotaTotal())->toBe(5); }); test('a grandfathered legacy user has an unlimited quota', function () { // Entscheidung 12.06.2026: Bestandsschutz — das Alt-Produkt sah // unbegrenzte PMs vor, am Kontingent wird nichts umgestellt. $user = User::factory()->create(); $user->assignRole('customer'); UserPaymentOption::factory()->create([ 'user_id' => $user->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, 'grandfathered_until' => now()->addYear(), ]); expect($user->pressReleaseQuotaRemaining())->toBeNull(); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->publish($pr); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published); expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); }); test('submitting a press release does not consume a quota slot', function () { // Decision-Update §3.2: Der Slot zählt erst bei Veröffentlichung runter. Queue::fake(); $user = quotaTestSubscriber(); $pr = quotaTestPressRelease($user); app(PressReleaseService::class)->submitForReview($pr); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review); expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); }); test('publishing consumes exactly one plan slot', function () { $user = quotaTestSubscriber(); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->publish($pr); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published); expect($user->fresh()->press_release_quota_used_this_month)->toBe(1); }); test('two publications cannot consume the same final quota slot', function () { $user = quotaTestSubscriber(planQuota: 1); $first = quotaTestPressRelease($user, 'review'); $second = quotaTestPressRelease($user, 'review'); $service = app(PressReleaseService::class); $service->publish($first); expect(fn () => $service->publish($second)) ->toThrow(QuotaExceededException::class); expect($first->fresh()->status)->toBe(PressReleaseStatus::Published) ->and($second->fresh()->status)->toBe(PressReleaseStatus::Review) ->and($user->fresh()->press_release_quota_used_this_month)->toBe(1); }); test('re-publishing after archive does not consume a second slot', function () { $user = quotaTestSubscriber(); $pr = quotaTestPressRelease($user, 'review'); $service = app(PressReleaseService::class); $service->publish($pr); $service->archive($pr->fresh()); $service->changeStatusFromAdmin($pr->fresh(), PressReleaseStatus::Published); expect($user->fresh()->press_release_quota_used_this_month)->toBe(1); }); test('a rejected press release does not consume a quota slot', function () { $user = quotaTestSubscriber(); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->reject($pr, 'Unzulässiger Inhalt.', 'ki'); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Rejected); expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); }); test('a stale blacklist publication cannot reject a release that was already published', function () { config()->set('blacklist.words', ['gesperrt']); $user = quotaTestSubscriber(); $pressRelease = quotaTestPressRelease($user, 'review'); $pressRelease->update(['text' => 'Dieser Inhalt ist gesperrt.']); $staleReviewInstance = $pressRelease->fresh(); PressRelease::query()->whereKey($pressRelease->getKey())->update([ 'status' => PressReleaseStatus::Published->value, 'published_at' => now(), ]); expect(fn () => app(PressReleaseService::class)->publish($staleReviewInstance)) ->toThrow(LogicException::class); expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Published); }); test('publishing past the plan quota consumes the oldest paid purchase', function () { $user = quotaTestSubscriber(planQuota: 1, used: 1); $older = SinglePurchase::factory()->paid()->create([ 'user_id' => $user->id, 'paid_at' => now()->subDays(2), ]); $newer = SinglePurchase::factory()->paid()->create([ 'user_id' => $user->id, 'paid_at' => now()->subDay(), ]); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->publish($pr); expect($user->fresh()->press_release_quota_used_this_month)->toBe(1); expect($older->fresh()->status)->toBe(SinglePurchaseStatus::Consumed); expect($older->fresh()->press_release_id)->toBe($pr->id); expect($newer->fresh()->status)->toBe(SinglePurchaseStatus::Paid); }); test('a purchase-only user consumes the purchase on publish', function () { $user = User::factory()->create(); $user->assignRole('customer'); $purchase = SinglePurchase::factory()->paid()->create(['user_id' => $user->id]); expect($user->pressReleaseQuotaRemaining())->toBe(1); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->publish($pr); expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Consumed); expect($purchase->fresh()->consumed_at)->not->toBeNull(); }); test('submitting with an exhausted quota is blocked', function () { Queue::fake(); $user = quotaTestSubscriber(planQuota: 3, used: 3); $pr = quotaTestPressRelease($user); expect(fn () => app(PressReleaseService::class)->submitForReview($pr)) ->toThrow(QuotaExceededException::class); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); }); test('monthly reset command zeroes the used counter', function () { /** @var TestCase $this */ User::factory()->count(2)->create(['press_release_quota_used_this_month' => 2]); $untouched = User::factory()->create(['press_release_quota_used_this_month' => 0]); $this->artisan(ResetMonthlyPressReleaseQuota::class)->assertSuccessful(); expect(User::where('press_release_quota_used_this_month', '>', 0)->count())->toBe(0); expect($untouched->fresh()->press_release_quota_used_this_month)->toBe(0); }); test('quota consumption resets lazily when the calendar month changes, without the cron (Audit F18)', function () { Carbon::setTestNow('2026-09-18 10:00:00'); $user = quotaTestSubscriber(planQuota: 3, used: 3); // Verbrauch aus dem Vormonat – der Reset-Cron ist ausgefallen. $user->forceFill(['press_release_quota_period' => '2026-08'])->save(); expect($user->fresh()->pressReleaseQuotaRemaining())->toBe(3); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->publish($pr); $fresh = $user->fresh(); expect($fresh->press_release_quota_used_this_month)->toBe(1) ->and($fresh->press_release_quota_period)->toBe('2026-09') ->and($fresh->pressReleaseQuotaRemaining())->toBe(2); }); test('the monthly reset is idempotent and leaves this month\'s consumption alone (Audit F18)', function () { /** @var TestCase $this */ Carbon::setTestNow('2026-09-01 00:05:00'); $thisMonth = User::factory()->create(['press_release_quota_used_this_month' => 2, 'press_release_quota_period' => '2026-09']); $lastMonth = User::factory()->create(['press_release_quota_used_this_month' => 2, 'press_release_quota_period' => '2026-08']); $unknown = User::factory()->create(['press_release_quota_used_this_month' => 2, 'press_release_quota_period' => null]); $this->artisan(ResetMonthlyPressReleaseQuota::class)->assertSuccessful(); $this->artisan(ResetMonthlyPressReleaseQuota::class)->assertSuccessful(); expect($thisMonth->fresh()->press_release_quota_used_this_month)->toBe(2) ->and($lastMonth->fresh()->press_release_quota_used_this_month)->toBe(0) ->and($lastMonth->fresh()->press_release_quota_period)->toBe('2026-09') ->and($unknown->fresh()->press_release_quota_used_this_month)->toBe(0); }); test('legacy consumption without a period counts as this month and continues the counter on publish (Audit U04)', function () { Carbon::setTestNow('2026-09-18 10:00:00'); // Altbestand aus der Zeit vor der Periodenbindung: 2 von 3 verbraucht, // Monat unbekannt (NULL). $user = quotaTestSubscriber(planQuota: 3, used: 2); expect($user->press_release_quota_period)->toBeNull() ->and($user->pressReleaseQuotaRemaining())->toBe(1); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->publish($pr); $fresh = $user->fresh(); expect($fresh->press_release_quota_used_this_month)->toBe(3) ->and($fresh->press_release_quota_period)->toBe('2026-09') ->and($fresh->pressReleaseQuotaRemaining())->toBe(0); $second = quotaTestPressRelease($user, 'review'); expect(fn () => app(PressReleaseService::class)->publish($second)) ->toThrow(QuotaExceededException::class); }); test('the backfill migration pins legacy consumption to the migration month so the next month frees it (Audit U04)', function () { Carbon::setTestNow('2026-09-18 10:00:00'); $user = quotaTestSubscriber(planQuota: 3, used: 3); $untouched = User::factory()->create(['press_release_quota_used_this_month' => 0, 'press_release_quota_period' => null]); $pinned = User::factory()->create(['press_release_quota_used_this_month' => 1, 'press_release_quota_period' => '2026-08']); expect($user->pressReleaseQuotaRemaining())->toBe(0); // Heutige Lesart ohne Backfill: NULL gilt als laufender Monat – der // Monatswechsel gibt ohne Reset-Cron nichts frei. Carbon::setTestNow('2026-10-02 10:00:00'); expect($user->fresh()->pressReleaseQuotaRemaining())->toBe(0); Carbon::setTestNow('2026-09-18 10:00:00'); $migration = require base_path('database/migrations/2026_09_18_190000_backfill_press_release_quota_period.php'); $migration->up(); expect($user->fresh()->press_release_quota_period)->toBe('2026-09') ->and($user->fresh()->press_release_quota_used_this_month)->toBe(3) ->and($untouched->fresh()->press_release_quota_period)->toBeNull() ->and($pinned->fresh()->press_release_quota_period)->toBe('2026-08'); // Nach dem Backfill erkennt der Monatswechsel den Altverbrauch als Vormonat. Carbon::setTestNow('2026-10-02 10:00:00'); expect($user->fresh()->pressReleaseQuotaRemaining())->toBe(3); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->publish($pr); $fresh = $user->fresh(); expect($fresh->press_release_quota_used_this_month)->toBe(1) ->and($fresh->press_release_quota_period)->toBe('2026-10') ->and($fresh->pressReleaseQuotaRemaining())->toBe(2); });