mivita/tests/Feature/ConsultantSuspensionTest.php
Kevin 42d2a9c0ab Berater faellt aus (B2): Berater ohne Sponsor gehen an das Firmenkonto
Der Dry-Run auf Produktion (25.09.) listet einen Berater, der ohne Sponsor
registriert wurde (m_sponsor und pre_sponsor leer). terminate() haette ihn
jede Nacht mit "kein aktiver Sponsor" uebersprungen. Pausieren und harter
Cut nehmen jetzt den naechsten aktiven Sponsor ueber m_sponsor/pre_sponsor
und sonst das Firmenkonto (ohne Mail).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 10:38:22 +00:00

355 lines
15 KiB
PHP

<?php
use App\Http\Controllers\AdminUserCleanupController;
use App\Mail\SponsorSuspensionNoticeMail;
use App\Mail\SponsorTransferMail;
use App\Models\ConsultantSuspensionTransfer;
use App\Models\Country;
use App\Models\Customer;
use App\Models\Product;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAccount;
use App\Models\UserCleanUpLog;
use App\Services\ConsultantSuspensionService;
use App\Services\Payment;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Entwicklungsplan B2: Berater faellt aus — pausieren nach 2 Tagen, Rueckgabe bei
* Zahlung, harter Cut nach 28 Tagen, Sponsor-Mails.
*/
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-10-01 03:30:00'));
config(['mivita.cleanup_suspend_days' => 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();
});
});