presseportale/tests/Feature/PressReleaseQuotaTest.php
Kevin Adametz 082e6bb76c ki/quota: Hash-Bindung atomar bis in den Publish-Lock, roter Re-Check lehnt ab, Kontingent-Altbestand (Block 7, U02–U04)
- Jobs speichern Ergebnis nur per bedingtem UPDATE auf den analysierten Hash;
  publish() prüft bei ki/scheduler unter der Zeilensperre die jüngste gültige
  Klassifikation und plant sonst eine neue Prüfung ein (StaleClassificationException)
- Re-Check ohne Routing: Rot bei einer PM in Review lehnt wie die Erstprüfung ab
- consumePublishSlot() zählt eine NULL-Periode wie den laufenden Monat;
  Backfill-Migration setzt die Periode für Altverbrauch
- ApiPortalResolutionTest: gemeinsame Kategorie wegen der U10-Prüfung

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:22:22 +00:00

332 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Console\Commands\ResetMonthlyPressReleaseQuota;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\Plan;
use App\Models\PressRelease;
use App\Models\SinglePurchase;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->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);
});