2, 'mivita.cleanup_delete_days' => 28, 'mivita.company_user_id' => 6]); Mail::fake(); }); afterEach(fn () => Carbon::setTestNow()); function susConsultant(?User $sponsor, string $expires, string $firstName = 'Berater'): User { $account = UserAccount::create([ 'first_name' => $firstName, 'last_name' => 'Test', 'm_first_name' => $firstName, 'm_last_name' => 'Test', 'm_account' => 'M'.random_int(1000, 99999), 'mobil' => '170 1234567', ]); return User::forceCreate([ 'email' => strtolower($firstName).'-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', 'active' => 1, 'blocked' => 0, 'admin' => 0, 'wizard' => 100, 'm_level' => 1, 'm_sponsor' => $sponsor?->id, 'payment_account' => Carbon::parse($expires), 'account_id' => $account->id, ])->fresh(); } function susCustomer(User $member, string $firstName = 'Kundin'): ShoppingUser { $country = Country::first() ?? Country::create([ 'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия', ]); return ShoppingUser::create([ 'auth_user_id' => 0, 'member_id' => $member->id, 'number' => random_int(1000, 99999), 'billing_firstname' => $firstName, 'billing_lastname' => 'Muster', 'billing_phone' => '0171 999', 'billing_email' => strtolower($firstName).'-'.uniqid('', true).'@example.com', 'billing_country_id' => $country->id, 'shipping_country_id' => $country->id, ]); } function susCustomerAbo(User $member, ShoppingUser $customer): UserAbo { return UserAbo::create([ 'user_id' => null, 'member_id' => $member->id, 'shopping_user_id' => $customer->id, 'is_for' => 'ot', 'email' => $customer->billing_email, 'payone_userid' => 1, 'clearingtype' => 'cc', 'active' => true, 'status' => 2, 'abo_interval' => 10, 'start_date' => '2026-01-10', 'next_date' => '2026-10-10', ]); } /** * Sponsor mit Berater darunter: Kundin mit Abo und Portal-Login, ein Teampartner. * * @return array{sponsor: User, consultant: User, child: User, customer: ShoppingUser, abo: UserAbo, portal: Customer} */ function susScenario(string $expires = '2026-09-28 10:00:00'): array { $sponsor = susConsultant(null, '2027-06-01', 'Sponsor'); $consultant = susConsultant($sponsor, $expires, 'Anna'); $child = susConsultant($consultant, '2027-03-01', 'Ben'); $customer = susCustomer($consultant); $abo = susCustomerAbo($consultant, $customer); $portal = Customer::create([ 'email' => $customer->billing_email, 'shopping_user_id' => $customer->id, 'member_id' => $consultant->id, 'number' => $customer->number, ]); return compact('sponsor', 'consultant', 'child', 'customer', 'abo', 'portal'); } describe('Stufe 1: pausieren', function () { it('laesst den Berater am ersten Tag nach Ablauf noch in Ruhe', function () { $s = susScenario('2026-09-30 10:00:00'); $this->artisan('user:cleanup')->assertSuccessful(); expect($s['consultant']->fresh()->active)->toBe(1) ->and($s['customer']->fresh()->member_id)->toBe($s['consultant']->id); Mail::assertNothingSent(); }); it('haengt nach 2 Tagen Team, Kunden, Kundenabos und Portal-Logins an den Sponsor', function () { $s = susScenario('2026-09-28 10:00:00'); $this->artisan('user:cleanup')->assertSuccessful(); $consultant = $s['consultant']->fresh(); expect($consultant->active)->toBe(0) ->and($consultant->m_sponsor)->toBeNull() ->and($consultant->pre_sponsor)->toBe($s['sponsor']->id) ->and($s['child']->fresh()->m_sponsor)->toBe($s['sponsor']->id) ->and($s['customer']->fresh()->member_id)->toBe($s['sponsor']->id) ->and($s['abo']->fresh()->member_id)->toBe($s['sponsor']->id) ->and($s['portal']->fresh()->member_id)->toBe($s['sponsor']->id) ->and(ConsultantSuspensionTransfer::openFor($consultant->id)->count())->toBe(3); }); it('schickt dem Sponsor die Kurzinfo ohne Listen, mit Frist', function () { $s = susScenario('2026-09-28 10:00:00'); $this->artisan('user:cleanup')->assertSuccessful(); Mail::assertSent(SponsorSuspensionNoticeMail::class, function (SponsorSuspensionNoticeMail $mail) use ($s) { $html = $mail->render(); return $mail->hasTo($s['sponsor']->email) && str_contains($html, 'Anna Test') && str_contains($html, '25.10.2026') && str_contains($html, '(24 Tage)') && ! str_contains($html, $s['customer']->billing_email); }); }); it('schickt dem Firmenkonto keine Mail', function () { $company = User::forceCreate(['id' => 6, 'email' => 'firma@example.com', 'password' => 'x', 'lang' => 'de', 'active' => 1, 'payment_account' => '2030-01-01']); $consultant = susConsultant($company, '2026-09-28 10:00:00'); $this->artisan('user:cleanup')->assertSuccessful(); expect($consultant->fresh()->active)->toBe(0); Mail::assertNothingSent(); }); it('aendert im Trockenlauf nichts und schickt nichts', function () { $s = susScenario('2026-09-28 10:00:00'); $this->artisan('user:cleanup', ['--dry-run' => true]) ->expectsOutputToContain('Pausieren (Ablauf vor mind. 2 Tagen): 1 Berater') ->assertSuccessful(); expect($s['consultant']->fresh()->active)->toBe(1) ->and($s['customer']->fresh()->member_id)->toBe($s['consultant']->id); Mail::assertNothingSent(); }); }); describe('Rueckgabe bei Zahlung', function () { it('gibt Team und Kundendaten zurueck, wenn der Berater innerhalb der Frist zahlt', function () { $s = susScenario('2026-09-28 10:00:00'); $this->artisan('user:cleanup')->assertSuccessful(); Carbon::setTestNow(Carbon::parse('2026-10-08 12:00:00')); $product = Product::forceCreate([ 'name' => 'Mitgliedschaft', 'title' => 'Mitgliedschaft', 'price' => 49.0, 'tax' => 19.0, 'active' => true, 'pos' => 1, 'weight' => 0, 'points' => 0, 'action' => [0], ]); $order = ShoppingOrder::forceCreate([ 'shopping_user_id' => 0, 'auth_user_id' => $s['consultant']->id, 'country_id' => 0, 'user_shop_id' => 0, 'payment_for' => 0, 'paid' => false, ]); ShoppingOrderItem::forceCreate(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 1, 'price' => 49.0]); Payment::paymentStatusPaidAction($order->fresh(), true); $consultant = $s['consultant']->fresh(); expect($consultant->active)->toBe(1) ->and($consultant->m_sponsor)->toBe($s['sponsor']->id) ->and($s['child']->fresh()->m_sponsor)->toBe($consultant->id) ->and($s['customer']->fresh()->member_id)->toBe($consultant->id) ->and($s['abo']->fresh()->member_id)->toBe($consultant->id) ->and($s['portal']->fresh()->member_id)->toBe($consultant->id) ->and(ConsultantSuspensionTransfer::openFor($consultant->id)->count())->toBe(0); }); it('laesst einen inzwischen von Hand uebertragenen Kunden, wo er ist', function () { $s = susScenario('2026-09-28 10:00:00'); $this->artisan('user:cleanup')->assertSuccessful(); $other = susConsultant($s['sponsor'], '2027-05-01', 'Clara'); $s['customer']->update(['member_id' => $other->id]); $summary = app(ConsultantSuspensionService::class)->reinstate($s['consultant']); expect($s['customer']->fresh()->member_id)->toBe($other->id) ->and($s['abo']->fresh()->member_id)->toBe($s['consultant']->id) ->and($summary['kept'])->toBe(1) ->and($summary['abos'])->toBe(1); }); }); describe('Stufe 2: harter Cut', function () { it('loescht den Berater nach 28 Tagen, macht ihn zum Kunden des Sponsors und schickt die Listen', function () { $s = susScenario('2026-09-28 10:00:00'); $this->artisan('user:cleanup')->assertSuccessful(); Carbon::setTestNow(Carbon::parse('2026-10-27 03:30:00')); $this->artisan('user:cleanup')->assertSuccessful(); $consultant = User::withTrashed()->find($s['consultant']->id); expect($consultant->deleted_at)->not->toBeNull() ->and($s['customer']->fresh()->member_id)->toBe($s['sponsor']->id) ->and(ShoppingUser::where('member_id', $s['sponsor']->id)->where('billing_lastname', 'Test')->count())->toBe(1); Mail::assertSent(SponsorTransferMail::class, function (SponsorTransferMail $mail) use ($s) { $html = $mail->render(); return $mail->hasTo($s['sponsor']->email) && str_contains($html, $s['customer']->billing_email) && str_contains($html, '0171 999') && str_contains($html, $s['child']->email) && str_contains($html, 'Kunden (2)') && str_contains($html, 'Teampartner (1)'); }); }); it('legt keinen zweiten Kundendatensatz an, wenn der Berater schon Kunde beim Sponsor ist', function () { $s = susScenario('2026-09-20 10:00:00'); $own = susCustomer($s['consultant'], 'Anna'); $own->update(['auth_user_id' => $s['consultant']->id]); Carbon::setTestNow(Carbon::parse('2026-10-19 03:30:00')); $this->artisan('user:cleanup')->assertSuccessful(); expect(ShoppingUser::where('auth_user_id', $s['consultant']->id)->count())->toBe(1) ->and($own->fresh()->member_id)->toBe($s['sponsor']->id); }); it('uebernimmt beim harten Cut auch Kunden von Beratern, die noch nach der alten Regel inaktiv sind', function () { $s = susScenario('2026-09-01 10:00:00'); // Alte 2-Wochen-Stufe: nur das Team war umgehaengt, die Kunden blieben stehen. $s['consultant']->forceFill(['active' => 0, 'pre_sponsor' => $s['sponsor']->id, 'm_sponsor' => null])->save(); $this->artisan('user:cleanup')->assertSuccessful(); expect(User::withTrashed()->find($s['consultant']->id)->deleted_at)->not->toBeNull() ->and($s['customer']->fresh()->member_id)->toBe($s['sponsor']->id) ->and($s['abo']->fresh()->member_id)->toBe($s['sponsor']->id) ->and($s['portal']->fresh()->member_id)->toBe($s['sponsor']->id); }); it('gibt Berater ganz ohne Sponsor an das Firmenkonto', function () { // Live 25.09.: ein Berater war ohne Sponsor registriert und waere jede Nacht uebersprungen worden. $company = User::forceCreate(['id' => 6, 'email' => 'firma@example.com', 'password' => 'x', 'lang' => 'de', 'active' => 1, 'payment_account' => '2030-01-01']); $consultant = susConsultant(null, '2026-09-01 10:00:00'); $customer = susCustomer($consultant); $this->artisan('user:cleanup')->assertSuccessful(); expect(User::withTrashed()->find($consultant->id)->deleted_at)->not->toBeNull() ->and($customer->fresh()->member_id)->toBe($company->id); Mail::assertNothingSent(); }); }); describe('Admin setzt das Ablaufdatum', function () { it('nutzt fuer Pausieren und Rueckgabe denselben Service', function () { $s = susScenario('2027-01-01 10:00:00'); $service = app(ConsultantSuspensionService::class); $service->suspend($s['consultant'], 'test'); expect($s['customer']->fresh()->member_id)->toBe($s['sponsor']->id) ->and(UserCleanUpLog::where('inactive_sponsor_id', $s['consultant']->id)->count())->toBe(1); $service->reinstate($s['consultant'], 'test'); expect($s['customer']->fresh()->member_id)->toBe($s['consultant']->id) ->and($s['child']->fresh()->m_sponsor)->toBe($s['consultant']->id); }); }); describe('User-Bereinigung: Wiederherstellen im Admin', function () { it('ruft user:restore genau einmal auf', function () { Artisan::shouldReceive('call') ->once() ->with('user:restore', ['user_id' => 42]) ->andReturn(0); Artisan::shouldReceive('output')->once()->andReturn('ok'); $response = app(AdminUserCleanupController::class) ->restore(new Request(['user_id' => 42])); expect($response->getData(true))->toMatchArray(['success' => true, 'output' => 'ok']); }); }); describe('Fristen in Kalendertagen (Beispiel aus der Kundenuebersicht)', function () { it('pausiert bei Ablauf am 1. in der Nacht zum 3. und entfernt in der Nacht zum 29.', function () { $s = susScenario('2026-10-01 23:00:00'); $service = app(ConsultantSuspensionService::class); expect($service->suspensionDateFor($s['consultant'])->format('d.m.Y'))->toBe('03.10.2026') ->and($service->deadlineFor($s['consultant'])->format('d.m.Y'))->toBe('28.10.2026'); $run = function (string $at) { Carbon::setTestNow(Carbon::parse($at)); $this->artisan('user:cleanup')->assertSuccessful(); }; $run('2026-10-02 03:30:00'); expect($s['consultant']->fresh()->active)->toBe(1); $run('2026-10-03 03:30:00'); expect($s['consultant']->fresh()->active)->toBe(0); $run('2026-10-28 03:30:00'); expect(User::withTrashed()->find($s['consultant']->id)->deleted_at)->toBeNull(); $run('2026-10-29 03:30:00'); expect(User::withTrashed()->find($s['consultant']->id)->deleted_at)->not->toBeNull(); }); });