presseportale/tests/Feature/Billing/ExpireGrandfatheringTest.php
Kevin Adametz 2a2215b3c3 audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand
PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute
(Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum
Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den
Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende
Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush
nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an
veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden.

Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit
erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit
FAILURE; record() über .tmp + move; Marker archive_pending_at mit
archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder
über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie
veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem
Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder;
archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks.

KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in
einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview();
Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte
Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht.

Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten;
Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht;
Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der
Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run
zählt alle Tabellen.

Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber.
Zwei Migrationen (nullable Spalten, INSTANT). N09 offen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 14:26:49 +00:00

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