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(); });