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