presseportale/tests/Feature/Billing/ExpireGrandfatheringTest.php
Kevin Adametz f0eeef0144 press-releases/jobs: Scheduler respektiert manuelle Prüfung und kommt an Blockaden vorbei, Newsletter-Versand per Schalter gesperrt, kurze Scheduler-Sperren (Umsetzungsaudit 29.09.: K-01, K-02, K-05, K-06, K-09, E9, OPT-8, OPT-9; 02.10.: O06)
- K-01: publish-scheduled filtert manual_review_required_at; publish() prüft
  für ki/scheduler unter der Sperre (ManualReviewRequiredException)
- K-02: Kontingent- und Bildsperre sind fachliche Ausgänge (Exit 0, einmaliger
  Verlaufseintrag); KI-Job ohne Retry bei Bildsperre
- O06: Kandidaten per Cursor (scheduled_at, id), Limit zählt bearbeitete
  Meldungen, Blockaden nur ein Scan-Budget
- K-05: KI-Job lädt vor dem Routing neu, überholter Status ohne Retry; backoff
- K-06: Admin-Aktionen mit Permission-Prüfung und Fehlermeldung statt 500
- K-09: withoutOverlapping() mit kurzer Sperrdauer, purge-events 03:50,
  neuer Wochenlauf press-release-images:sweep-orphan-originals (So 04:45)
- E9: NEWSLETTER_SENDING_ENABLED (Standard aus) sperrt Start, Resume und Job
- OPT-9: ExpireGrandfathering prüft die Queue-Voraussetzung aus E18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

322 lines
13 KiB
PHP
Raw 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\ExpireGrandfathering;
use App\Enums\ActivityType;
use App\Enums\EmailSuppressionReason;
use App\Enums\UserPaymentOptionStatus;
use App\Mail\GrandfatheringExpired;
use App\Models\ActivityEvent;
use App\Models\BillingAddress;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Mail\EmailSuppressionService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
/**
* Explizites Legacy-Vertragsende (Audit F06, Plan 5.2): Bestandsschutz mit
* überschrittenem `grandfathered_until` wird beendet und der Kunde
* informiert – bisher passierte das nur manuell über die Mahnung.
*/
function grandfatheredAgreement(?string $until = null, array $overrides = []): UserPaymentOption
{
$user = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $user->id]);
return UserPaymentOption::factory()->create([
'user_id' => $user->id,
'payment_option_id' => PaymentOption::factory()->create()->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'stripe_subscription_id' => null,
'grandfathered_until' => $until,
'cancelled_at' => null,
...$overrides,
]);
}
test('grandfathering past its contract end is ended and the customer is told once', function () {
/** @var TestCase $this */
Mail::fake();
Carbon::setTestNow('2026-09-18 04:15:00');
$expired = grandfatheredAgreement('2026-09-17');
$endsToday = grandfatheredAgreement('2026-09-18');
$open = grandfatheredAgreement(null);
$alreadyCancelled = grandfatheredAgreement('2026-09-01', ['status' => UserPaymentOptionStatus::Cancelled->value, 'cancelled_at' => now()->subDay()]);
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
expect($expired->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled)
->and($expired->fresh()->cancelled_at)->not->toBeNull()
->and($endsToday->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered)
->and($open->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
Mail::assertQueued(GrandfatheringExpired::class, fn (GrandfatheringExpired $mail) => $mail->user->is($expired->user));
Mail::assertQueuedCount(1);
// Wiederholung ist wirkungslos.
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
Mail::assertQueuedCount(1);
expect($alreadyCancelled->fresh()->cancelled_at->toDateString())->toBe('2026-09-17');
Carbon::setTestNow();
});
/**
* Audit U11: Zwischen Auswahl und Schreibzugriff kann sich der Posten
* ändern. Der `retrieved`-Listener simuliert das – er greift genau einmal,
* beim ersten Laden des Kandidaten, und schreibt am Model vorbei in die DB.
*/
function onFirstRetrieval(UserPaymentOption $target, callable $mutation): void
{
$done = false;
UserPaymentOption::retrieved(function (UserPaymentOption $retrieved) use ($target, $mutation, &$done): void {
if ($done || $retrieved->id !== $target->id) {
return;
}
$done = true;
$mutation();
});
}
test('a contract end extended between selection and update is kept (U11)', function () {
/** @var TestCase $this */
Mail::fake();
Carbon::setTestNow('2026-09-18 04:15:00');
$extended = grandfatheredAgreement('2026-09-17');
onFirstRetrieval($extended, fn () => UserPaymentOption::query()
->whereKey($extended->id)
->update(['grandfathered_until' => '2027-09-17']));
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
expect($extended->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered)
->and($extended->fresh()->cancelled_at)->toBeNull()
->and($extended->fresh()->grandfathered_until->toDateString())->toBe('2027-09-17')
->and(ActivityEvent::query()->where('type', ActivityType::SubscriptionChange->value)->count())->toBe(0);
Mail::assertNothingQueued();
Carbon::setTestNow();
});
test('a contract already ended by an overlapping run gets no second event or mail (U11)', function () {
/** @var TestCase $this */
Mail::fake();
Carbon::setTestNow('2026-09-18 04:15:00');
$raced = grandfatheredAgreement('2026-09-17');
onFirstRetrieval($raced, fn () => UserPaymentOption::query()
->whereKey($raced->id)
->update([
'status' => UserPaymentOptionStatus::Cancelled->value,
'cancelled_at' => now()->subSecond(),
'grandfathering_expired_at' => now()->subSecond(),
'grandfathering_notified_at' => now()->subSecond(),
]));
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
expect($raced->fresh()->cancelled_at->toDateTimeString())->toBe('2026-09-18 04:14:59')
->and(ActivityEvent::query()->where('type', ActivityType::SubscriptionChange->value)->count())->toBe(0);
Mail::assertNothingQueued();
Carbon::setTestNow();
});
test('a failed enqueue is retried on the next run without touching the contract again (U12)', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-09-18 04:15:00');
$expired = grandfatheredAgreement('2026-09-17');
// Partieller Proxy auf den echten MailManager: nur `to()` scheitert,
// alles andere (auch das spätere Mail::fake()) läuft normal weiter.
$brokenMailer = Mockery::mock(app('mail.manager'))->makePartial();
$brokenMailer->shouldReceive('to')->once()->andThrow(new RuntimeException('Queue weg'));
Mail::swap($brokenMailer);
$this->artisan(ExpireGrandfathering::class)->assertFailed();
$afterFirstRun = $expired->fresh();
expect($afterFirstRun->status)->toBe(UserPaymentOptionStatus::Cancelled)
->and($afterFirstRun->cancelled_at)->not->toBeNull()
->and($afterFirstRun->grandfathering_expired_at)->not->toBeNull()
->and($afterFirstRun->grandfathering_notified_at)->toBeNull()
->and(ActivityEvent::query()->where('type', ActivityType::SubscriptionChange->value)->count())->toBe(1);
// Nächster Lauf: Nachbenachrichtigung, kein zweiter Zustandswechsel.
Mail::fake();
Carbon::setTestNow('2026-09-19 04:15:00');
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
Mail::assertQueued(GrandfatheringExpired::class, 1);
Mail::assertQueued(GrandfatheringExpired::class, fn (GrandfatheringExpired $mail) => $mail->user->is($expired->user)
&& $mail->endedOn?->toDateString() === '2026-09-17');
$afterSecondRun = $expired->fresh();
expect($afterSecondRun->cancelled_at->toDateTimeString())->toBe($afterFirstRun->cancelled_at->toDateTimeString())
->and($afterSecondRun->grandfathering_expired_at->toDateTimeString())->toBe($afterFirstRun->grandfathering_expired_at->toDateTimeString())
->and($afterSecondRun->grandfathering_notified_at?->toDateTimeString())->toBe('2026-09-19 04:15:00')
->and(ActivityEvent::query()->where('type', ActivityType::SubscriptionChange->value)->count())->toBe(1);
// Dritter Lauf: nichts mehr offen.
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
Mail::assertQueued(GrandfatheringExpired::class, 1);
Carbon::setTestNow();
});
test('a suppressed address is marked as notified without a mail so it does not hang (U12)', function () {
/** @var TestCase $this */
Mail::fake();
Carbon::setTestNow('2026-09-18 04:15:00');
$expired = grandfatheredAgreement('2026-09-17');
app(EmailSuppressionService::class)->suppress($expired->user->email, EmailSuppressionReason::HardBounce, 'imap');
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
Mail::assertNothingQueued();
expect($expired->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled)
->and($expired->fresh()->grandfathering_notified_at)->not->toBeNull();
Carbon::setTestNow();
});
test('dry-run lists pending notifications and changes nothing', function () {
/** @var TestCase $this */
Mail::fake();
Carbon::setTestNow('2026-09-18 04:15:00');
$expired = grandfatheredAgreement('2026-09-17');
$pending = grandfatheredAgreement('2026-09-01', [
'status' => UserPaymentOptionStatus::Cancelled->value,
'cancelled_at' => now()->subDay(),
'grandfathering_expired_at' => now()->subDay(),
'grandfathering_notified_at' => null,
]);
$this->artisan(ExpireGrandfathering::class, ['--dry-run' => true])
->expectsOutputToContain(sprintf('Posten #%d', $expired->id))
->expectsOutputToContain(sprintf('Posten #%d', $pending->id))
->assertSuccessful();
Mail::assertNothingQueued();
expect($expired->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered)
->and($pending->fresh()->grandfathering_notified_at)->toBeNull();
Carbon::setTestNow();
});
test('two runs racing for the same open notification queue exactly one mail (N14)', function () {
/** @var TestCase $this */
Mail::fake();
Carbon::setTestNow('2026-09-18 04:15:00');
$pending = grandfatheredAgreement('2026-09-01', [
'status' => UserPaymentOptionStatus::Cancelled->value,
'cancelled_at' => now()->subDay(),
'grandfathering_expired_at' => now()->subDay(),
'grandfathering_notified_at' => null,
]);
// Der „andere Lauf" setzt den Marker genau zwischen der Auswahl der
// offenen Posten und dem gesperrten Nachlesen.
$raced = false;
DB::beforeExecuting(function (string $query) use (&$raced, $pending): void {
if ($raced || ! str_starts_with($query, 'select') || ! str_contains($query, 'user_payment_options') || ! str_contains($query, ' in (')) {
return;
}
$raced = true;
DB::table('user_payment_options')->where('id', $pending->id)->update(['grandfathering_notified_at' => now()->subSecond()]);
});
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
expect($raced)->toBeTrue()
->and($pending->fresh()->grandfathering_notified_at->toDateTimeString())->toBe('2026-09-18 04:14:59');
Mail::assertNothingQueued();
Carbon::setTestNow();
});
test('the notification marker and the queued job stand or fall together (N14)', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-09-18 04:15:00');
config()->set('queue.default', 'database');
$expired = grandfatheredAgreement('2026-09-17');
// Der Job-Insert scheitert einmal: Marker und Auftrag rollen gemeinsam
// zurück — sonst bliebe entweder eine Mail aus oder käme doppelt.
$failOnce = true;
DB::beforeExecuting(function (string $query) use (&$failOnce): void {
if ($failOnce && str_starts_with($query, 'insert') && str_contains($query, 'jobs')) {
$failOnce = false;
throw new RuntimeException('Queue weg');
}
});
$this->artisan(ExpireGrandfathering::class)->assertFailed();
expect($expired->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled)
->and($expired->fresh()->grandfathering_notified_at)->toBeNull()
->and(DB::table('jobs')->count())->toBe(0);
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
expect($expired->fresh()->grandfathering_notified_at?->toDateTimeString())->toBe('2026-09-18 04:15:00')
->and(DB::table('jobs')->count())->toBe(1);
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
expect(DB::table('jobs')->count())->toBe(1);
Carbon::setTestNow();
});
/*
* OPT-9 / E18 (Umsetzungsaudit 29.09.2026): Die Idempotenz der Benachrichtigung
* hängt daran, dass der Job-Insert in derselben DB-Transaktion läuft. Mit einer
* anderen Queue bricht der Befehl laut ab, statt still doppelt zu mailen.
*/
test('the command refuses to run on a queue outside the database transaction (OPT-9)', function (string $default, ?string $queueConnection) {
/** @var TestCase $this */
Mail::fake();
config()->set('queue.default', $default);
config()->set('queue.connections.database.connection', $queueConnection);
$expired = grandfatheredAgreement(now()->subDays(2)->toDateString());
$this->artisan(ExpireGrandfathering::class)
->expectsOutputToContain('E18')
->assertFailed();
expect($expired->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
Mail::assertNothingQueued();
})->with([
'redis' => ['redis', null],
'own queue connection' => ['database', 'queue_mysql'],
]);
test('the command accepts the database queue on the default connection (OPT-9)', function () {
/** @var TestCase $this */
config()->set('queue.default', 'database');
config()->set('queue.connections.database.connection', config('database.default'));
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
});