- 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>
322 lines
13 KiB
PHP
322 lines
13 KiB
PHP
<?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();
|
||
});
|