mivita/tests/Feature/AboStatusGuardTest.php
Kevin 03416b0afe Audit 18.09.: Zahlungsdatum nur bei Zahlung, Neupartner-IDs im Snapshot, Pausenende folgt dem Termin
F1: partner_paid_at wird beim Rechnungskauf (paymentStatusPaidAction mit
paid=false) nicht mehr gesetzt, sondern erst bei der Rechnungszahlung; der
Backfill nimmt das Aktivierungsdatum nur noch ohne jede
Registrierungsbestellung (offene Rechnung bleibt leer).
F2: Snapshots speichern je Linie new_partner_ids, die Neupartner-Liste zeigt
genau diese; aeltere Snapshots ohne IDs rechnen mit der damaligen Regel
(Registrierungsmonat, scopeNewPartnerInByActiveDate), damit Kachel und Liste
uebereinstimmen. Keine Payload-Versionsaenderung, kein Neuaufbau noetig.
F3: Liefertag-/Termin-Aenderung waehrend einer Pause zieht paused_until mit;
eine Ausfuehrung loescht auch den paused_by_*-Grund.
F4: expiredCount der Firstlines-Seite (Controller, View, Sprachen) zum bereits
committeten Test nachgezogen.
F5: Backfills schreiben ohne updated_at (toBase()->update, timestamps=false).
Kuendigungsbestaetigung abo_cancel_confirm wird im Selfservice serverseitig
verlangt. AboStatusExplainer laedt die Historie je Abo und Aktion nur einmal.

Stellungnahme und Deploy-Folgen im Audit-Dokument, Regressionstests je Befund.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 08:59:45 +00:00

246 lines
8.7 KiB
PHP

<?php
use App\Models\Country;
use App\Models\Setting;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboStatusHistory;
use App\Repositories\AboRepository;
use App\Services\AboHelper;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(fn () => Carbon::setTestNow('2026-03-09 10:00:00'));
afterEach(fn () => Carbon::setTestNow());
/**
* A01 — Der Aktiv-Zustand darf sich im Selbstbedienungspfad nur über die dafür
* zugelassenen Aktionen ändern. Das Formular sendet ihn als verstecktes Feld mit;
* ein manipulierter Request könnte die Mindestlaufzeit sonst durch dauerhafte
* Deaktivierung umgehen, statt den Ein-Zyklus-Pausenpfad mit seinen Sperren zu
* benutzen.
*/
describe('A01: Aktiv-Schalter im Selbstbedienungspfad', function () {
it('deaktiviert das Abo nicht, wenn das Aktiv-Feld auf 0 gesetzt wird', function (string $view) {
$abo = makeStatusGuardAbo(['is_for' => $view === 'ot' ? 'ot' : 'me']);
$this->actingAs($abo->user);
$result = statusGuardUpdate($abo, [
'view' => $view,
'abo_is_active' => '0',
]);
expect($result)->toBeTrue()
->and($abo->refresh()->active)->toBeTrue()
->and($abo->status)->toBe(2)
->and($abo->status_histories()->count())->toBe(0);
})->with(['me', 'ot', 'portal']);
it('deaktiviert das Abo auch dann nicht, wenn das Aktiv-Feld ganz fehlt', function () {
$abo = makeStatusGuardAbo();
$this->actingAs($abo->user);
statusGuardUpdate($abo, ['view' => 'me']);
expect($abo->refresh()->active)->toBeTrue()
->and($abo->status)->toBe(2);
});
it('reaktiviert ein deaktiviertes Abo nicht über das Aktiv-Feld', function () {
$abo = makeStatusGuardAbo(['active' => false, 'status' => 6]);
$this->actingAs($abo->user);
statusGuardUpdate($abo, [
'view' => 'me',
'abo_is_active' => 'true',
]);
expect($abo->refresh()->active)->toBeFalse()
->and($abo->status)->toBe(6);
});
it('lässt wiederholte Umschaltversuche wirkungslos', function () {
$abo = makeStatusGuardAbo();
$this->actingAs($abo->user);
foreach (['0', 'true', '0'] as $value) {
statusGuardUpdate($abo, ['view' => 'me', 'abo_is_active' => $value]);
}
expect($abo->refresh()->active)->toBeTrue()
->and($abo->status_histories()->count())->toBe(0);
});
it('setzt die Mindestlaufzeit auch bei manipuliertem Aktiv-Feld durch', function () {
Setting::setContentBySlug('abo-min-duration', 3, 'int');
$abo = makeStatusGuardAbo();
$this->actingAs($abo->user);
// Ohne bezahlte Lieferungen ist weder Kündigung noch Pause erlaubt …
expect(AboHelper::canCancelAbo($abo, 'me'))->toBeFalse();
// … und der Umweg über das Aktiv-Feld führt ebenfalls nicht zum Ziel.
statusGuardUpdate($abo, ['view' => 'me', 'abo_is_active' => '0']);
expect($abo->refresh()->active)->toBeTrue();
});
it('lässt den Admin weiterhin de- und reaktivieren', function () {
$abo = makeStatusGuardAbo();
$admin = User::forceCreate([
'email' => 'status-guard-admin-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 4,
]);
$this->actingAs($admin);
statusGuardUpdate($abo, ['view' => 'admin', 'abo_next_month' => '2026-03']);
expect($abo->refresh()->active)->toBeFalse()
->and($abo->status)->toBe(6);
statusGuardUpdate($abo, ['view' => 'admin', 'abo_next_month' => '2026-03', 'abo_is_active' => 'true']);
expect($abo->refresh()->active)->toBeTrue()
->and($abo->status)->toBe(2);
});
});
/**
* A16 — Jeder Wechsel des Aktiv-Zustands muss protokolliert werden, egal welcher
* Schreibpfad ihn auslöst. Die historische Bestandsrückrechnung verwirft ein Abo,
* solange eine protokollierte Deaktivierung nicht durch eine spätere Reaktivierung
* aufgehoben ist.
*/
describe('A16: Statushistorie über alle Schreibpfade', function () {
it('protokolliert Deaktivierung und Reaktivierung aus dem Admin-Formular mit Kanal und Akteur', function () {
$abo = makeStatusGuardAbo();
$admin = User::forceCreate([
'email' => 'status-guard-admin-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 4,
]);
$this->actingAs($admin);
statusGuardUpdate($abo, ['view' => 'admin', 'abo_next_month' => '2026-03']);
statusGuardUpdate($abo, ['view' => 'admin', 'abo_next_month' => '2026-03', 'abo_is_active' => 'true']);
$entries = $abo->status_histories()->orderBy('id')->get();
expect($entries->pluck('action')->all())->toBe([
UserAboStatusHistory::ACTION_DEACTIVATED,
UserAboStatusHistory::ACTION_REACTIVATED,
])
->and($entries->pluck('view')->unique()->all())->toBe(['admin'])
->and($entries->pluck('actor_user_id')->unique()->all())->toBe([$admin->id]);
});
it('protokolliert die Kündigung aus dem Selbstbedienungspfad', function () {
Setting::setContentBySlug('abo-min-duration', 0, 'int');
$abo = makeStatusGuardAbo(['next_date' => '2026-04-20']);
$this->actingAs($abo->user);
statusGuardUpdate($abo, ['view' => 'me', 'abo_cancel' => 'true', 'abo_cancel_confirm' => '1']);
$entry = $abo->status_histories()->sole();
expect($abo->refresh()->active)->toBeFalse()
->and($abo->status)->toBe(4)
->and($entry->action)->toBe(UserAboStatusHistory::ACTION_CANCELLED)
->and($entry->view)->toBe('me');
});
it('protokolliert eine Reaktivierung aus einem Systemschreibpfad ohne Formularkanal', function () {
// Stellvertretend für Recovery-Zahlung, Retry und externe Erledigung: alle
// schreiben status=2/active=true direkt auf das Modell.
$abo = makeStatusGuardAbo(['active' => false, 'status' => 6]);
$abo->update(['status' => 2, 'active' => true]);
$entry = $abo->status_histories()->sole();
expect($entry->action)->toBe(UserAboStatusHistory::ACTION_REACTIVATED)
->and($entry->view)->toBeNull()
->and($entry->actor_user_id)->toBeNull();
});
it('schreibt keinen Eintrag, wenn der Aktiv-Zustand unverändert bleibt', function () {
$abo = makeStatusGuardAbo();
$abo->update(['status' => 2, 'active' => true, 'last_date' => now()]);
expect($abo->status_histories()->count())->toBe(0);
});
it('protokolliert nicht beim Löschen — dafür wertet die Rückrechnung deleted_at aus', function () {
$abo = makeStatusGuardAbo();
$abo->delete();
expect($abo->status_histories()->count())->toBe(0)
->and(UserAbo::withTrashed()->find($abo->id)->active)->toBeFalse();
});
});
/**
* @param array<string, mixed> $data
*/
function statusGuardUpdate(UserAbo $abo, array $data): mixed
{
$repository = new AboRepository;
$repository->setModel($abo);
return $repository->update(array_merge([
'action' => 'abo_update_settings',
'id' => $abo->id,
'abo_interval' => $abo->abo_interval,
], $data));
}
/**
* @param array<string, mixed> $overrides
*/
function makeStatusGuardAbo(array $overrides = []): UserAbo
{
$country = Country::create([
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Deutschland',
]);
$user = User::forceCreate([
'email' => 'status-guard-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 0,
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $user->id,
'member_id' => $user->id,
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_email' => $user->email,
'is_for' => 'me',
'is_from' => 'user_order',
]);
return UserAbo::create(array_merge([
'user_id' => $user->id,
'member_id' => $user->id,
'shopping_user_id' => $shoppingUser->id,
'is_for' => 'me',
'email' => $user->email,
'payone_userid' => random_int(100000, 999999),
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 20,
'start_date' => '2026-01-20',
'last_date' => '2026-02-20',
'next_date' => '2026-03-20',
], $overrides));
}