presseportale/tests/Feature/Billing/ExpireGrandfatheringTest.php
Kevin Adametz 1cd9fd11af billing: Vertragsablauf bedingt beenden, Kundenmail nachholbar (Block 7, U11/U12)
- Beenden je Kunde in einer Transaktion mit erneuter Prüfung aller
  Auswahlbedingungen unter Zeilensperre; Ereignis nur für getroffene Zeilen
- Marker grandfathering_expired_at/-notified_at; Mail in zweiter Phase,
  bei Fehler beim Einplanen FAILURE und Nachholen im nächsten Lauf

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

219 lines
8.7 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\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();
});