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