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>
246 lines
8.7 KiB
PHP
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));
|
|
}
|