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>
This commit is contained in:
Kevin Adametz 2026-09-18 12:22:22 +00:00
parent 1cd9fd11af
commit ea68362310
11 changed files with 698 additions and 25 deletions

View file

@ -272,3 +272,61 @@ test('the monthly reset is idempotent and leaves this month\'s consumption alone
->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);
});