$firstName, 'last_name' => $lastName, ]); return User::forceCreate([ 'email' => 'trs-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', 'm_level' => 1, 'm_sponsor' => $sponsorId, 'payment_account' => $paymentAccount === 'future' ? now()->addYear() : $paymentAccount, 'active_date' => $activeDate, 'partner_paid_at' => $activeDate, 'account_id' => $account->id, ]); } function trsAddVolume(int $userId, int $points, int $status, int $month = 8, int $year = 2026): void { UserSalesVolume::forceCreate([ 'user_id' => $userId, 'month' => $month, 'year' => $year, 'points' => $points, 'status' => $status, ]); } function trsMakeAbo(int $ownerId, string $isFor, string $startDate, bool $active = true, int $status = 2): UserAbo { return UserAbo::create([ 'user_id' => $ownerId, 'member_id' => $ownerId, 'shopping_user_id' => 0, 'email' => 'trs-abo-'.uniqid('', true).'@example.com', 'is_for' => $isFor, 'payone_userid' => 0, 'clearingtype' => 'cc', 'amount' => 5000, 'active' => $active, 'status' => $status, 'abo_interval' => 5, 'start_date' => $startDate, 'next_date' => '2026-09-05', ]); } function trsTop3(User $viewer): array { Cache::flush(); return app(TopResultsService::class)->monthlyTop3($viewer, 8, 2026); } it('rankt das persoenliche Volumen inkl. Status-1- und Status-2-Punkten, mit Betrachter, ohne Fremde', function (): void { $root = trsMakeUser(null, 'Root', 'Viewer'); $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); $ben = trsMakeUser($root->id, 'Ben', 'Beta'); $fremd = trsMakeUser(null, 'Frank', 'Fremd'); trsAddVolume($anna->id, 100, 1); // Eigenpunkte trsAddVolume($anna->id, 50, 2); // Kundenpunkte trsAddVolume($anna->id, 999, 3); // pending zaehlt nicht trsAddVolume($ben->id, 40, 1); trsAddVolume($ben->id, 70, 1, 7); // anderer Monat zaehlt nicht trsAddVolume($root->id, 500, 1); // Betrachter zaehlt mit ("ich") trsAddVolume($fremd->id, 800, 1); // nicht im Team $result = trsTop3($root); expect($result['top_volume'])->toHaveCount(3) ->and($result['top_volume'][0])->toMatchArray(['user_id' => $root->id, 'value' => 500.0, 'is_self' => true]) ->and($result['top_volume'][1])->toMatchArray(['user_id' => $anna->id, 'first_name' => 'Anna', 'last_name' => 'Alpha', 'value' => 150.0, 'is_self' => false]) ->and($result['top_volume'][1]['profile_image_url'])->toBeNull() ->and($result['top_volume'][2])->toMatchArray(['user_id' => $ben->id, 'value' => 40.0]); }); it('begrenzt auf Top 3 und sortiert Gleichstaende nach Name', function (): void { $root = trsMakeUser(null, 'Root', 'Viewer'); $carla = trsMakeUser($root->id, 'Carla', 'Chef'); $zoe = trsMakeUser($root->id, 'Zoe', 'Zett'); $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); $doris = trsMakeUser($root->id, 'Doris', 'Klein'); trsAddVolume($carla->id, 200, 1); trsAddVolume($zoe->id, 100, 1); trsAddVolume($anna->id, 100, 1); trsAddVolume($doris->id, 50, 1); // Platz 4, faellt raus $result = trsTop3($root); expect($result['top_volume'])->toHaveCount(3) ->and(array_column($result['top_volume'], 'user_id')) ->toBe([$carla->id, $anna->id, $zoe->id]); }); it('beruecksichtigt Gleichstaende jenseits des Auswahlfensters (A11)', function (): void { // Der Namens-Tiebreak darf nicht an der Groesse des Kandidatenfensters // haengen: "Alpha" wird zuletzt angelegt und liegt mit 25 anderen gleichauf. $root = trsMakeUser(null, 'Root', 'Viewer'); for ($i = 0; $i < 25; $i++) { $zulu = trsMakeUser($root->id, 'Zulu'.str_pad((string) $i, 2, '0', STR_PAD_LEFT), 'Zett'); trsAddVolume($zulu->id, 100, 1); } $alpha = trsMakeUser($root->id, 'Alpha', 'Anfang'); trsAddVolume($alpha->id, 100, 1); $result = trsTop3($root); expect($result['top_volume'])->toHaveCount(3) ->and($result['top_volume'][0]['user_id'])->toBe($alpha->id) ->and($result['top_volume'][0]['first_name'])->toBe('Alpha'); }); it('fuellt die Top 3 auch auf, wenn viele Kandidaten keinen Account haben (A11)', function (): void { $root = trsMakeUser(null, 'Root', 'Viewer'); // Kandidaten ohne Account koennen nicht angezeigt werden; sie duerfen die // Auswahl aber auch nicht verstopfen. for ($i = 0; $i < 25; $i++) { $orphan = User::forceCreate([ 'email' => 'trs-orphan-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', 'm_level' => 1, 'm_sponsor' => $root->id, 'payment_account' => now()->addYear(), ]); trsAddVolume($orphan->id, 500, 1); } $berta = trsMakeUser($root->id, 'Berta', 'Bach'); trsAddVolume($berta->id, 100, 1); $result = trsTop3($root); expect(array_column($result['top_volume'], 'user_id'))->toBe([$berta->id]); }); it('sortiert Namensgleichheit reproduzierbar nach user_id (A11)', function (): void { $root = trsMakeUser(null, 'Root', 'Viewer'); $first = trsMakeUser($root->id, 'Maria', 'Muster'); $second = trsMakeUser($root->id, 'Maria', 'Muster'); $third = trsMakeUser($root->id, 'Maria', 'Muster'); foreach ([$first, $second, $third] as $user) { trsAddVolume($user->id, 100, 1); } expect(array_column(trsTop3($root)['top_volume'], 'user_id')) ->toBe([$first->id, $second->id, $third->id]); }); it('zaehlt Neupartner je Sponsor nach der Dashboard-Definition', function (): void { $root = trsMakeUser(null, 'Root', 'Viewer'); $sponsor = trsMakeUser($root->id, 'Sandra', 'Sponsor'); $other = trsMakeUser($root->id, 'Olaf', 'Other'); trsMakeUser($sponsor->id, 'Neu', 'Eins', '2026-08-10'); trsMakeUser($sponsor->id, 'Neu', 'Zwei', '2026-08-20'); trsMakeUser($sponsor->id, 'Alt', 'Juli', '2026-07-10'); // anderer Monat trsMakeUser($sponsor->id, 'Neu', 'Inaktiv', '2026-08-12', null); // kein aktives Abrechnungskonto trsMakeUser($other->id, 'Neu', 'Drei', '2026-08-05'); trsMakeUser($root->id, 'Neu', 'BeimViewer', '2026-08-08'); // Sponsor = Betrachter, zaehlt mit $result = trsTop3($root); // Gleichstand 1:1 zwischen Olaf und Root -> nach Vorname sortiert expect($result['top_recruiters'])->toHaveCount(3) ->and($result['top_recruiters'][0])->toMatchArray(['user_id' => $sponsor->id, 'value' => 2, 'is_self' => false]) ->and($result['top_recruiters'][1])->toMatchArray(['user_id' => $other->id, 'value' => 1]) ->and($result['top_recruiters'][2])->toMatchArray(['user_id' => $root->id, 'value' => 1, 'is_self' => true]); }); it('zaehlt neue Abos als Kundenabos plus Eigenabo je Berater', function (): void { $root = trsMakeUser(null, 'Root', 'Viewer'); $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); $ben = trsMakeUser($root->id, 'Ben', 'Beta'); $fremd = trsMakeUser(null, 'Frank', 'Fremd'); trsMakeAbo($anna->id, 'ot', '2026-08-05'); trsMakeAbo($anna->id, 'ot', '2026-08-20'); trsMakeAbo($anna->id, 'me', '2026-08-03'); trsMakeAbo($anna->id, 'ot', '2026-07-05'); // anderer Monat trsMakeAbo($anna->id, 'me', '2026-08-10', true, 4); // gekuendigt trsMakeAbo($anna->id, 'ot', '2026-08-11', false); // inaktiv trsMakeAbo($ben->id, 'me', '2026-08-15'); trsMakeAbo($root->id, 'ot', '2026-08-09'); // Betrachter zaehlt mit trsMakeAbo($fremd->id, 'ot', '2026-08-09'); // nicht im Team $result = trsTop3($root); // Gleichstand 1:1 zwischen Ben und Root -> nach Vorname sortiert expect($result['top_new_abos'])->toHaveCount(3) ->and($result['top_new_abos'][0])->toMatchArray(['user_id' => $anna->id, 'value' => 3, 'is_self' => false]) ->and($result['top_new_abos'][1])->toMatchArray(['user_id' => $ben->id, 'value' => 1]) ->and($result['top_new_abos'][2])->toMatchArray(['user_id' => $root->id, 'value' => 1, 'is_self' => true]); }); it('cacht das Ergebnis fuer 6 Stunden', function (): void { $root = trsMakeUser(null, 'Root', 'Viewer'); $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); trsAddVolume($anna->id, 100, 1); $first = app(TopResultsService::class)->monthlyTop3($root, 8, 2026); trsAddVolume($anna->id, 900, 1); $second = app(TopResultsService::class)->monthlyTop3($root, 8, 2026); expect($first['top_volume'][0]['value'])->toBe(100.0) ->and($second)->toBe($first); }); it('zeigt ein neues Profilfoto sofort, trotz gecachter Rangliste', function (): void { Storage::fake('public'); $root = trsMakeUser(null, 'Root', 'Viewer'); $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); trsAddVolume($anna->id, 100, 1); $service = app(TopResultsService::class); expect($service->monthlyTop3($root, 8, 2026)['top_volume'][0]['profile_image_url'])->toBeNull(); // Foto wird hochgeladen, nachdem die Rangliste bereits im Cache liegt Storage::disk('public')->put('images/profile/anna.jpg', 'x'); $anna->account->forceFill(['profile_image' => 'anna.jpg'])->save(); expect($service->monthlyTop3($root, 8, 2026)['top_volume'][0]['profile_image_url']) ->toContain('images/profile/anna.jpg'); }); it('rendert die Top-Ergebnisse-Karte nur mit Eintraegen', function (): void { URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); $row = fn (string $first, string $last, $value) => [ 'user_id' => 1, 'first_name' => $first, 'last_name' => $last, 'profile_image_url' => null, 'value' => $value, ]; $html = view('dashboard._top_results', [ 'monthlyStats' => [ 'month' => 8, 'year' => 2026, 'top_results' => [ 'top_volume' => [$row('Anna', 'Alpha', 150.0)], 'top_recruiters' => [], 'top_new_abos' => [$row('Ben', 'Beta', 3)], ], ], ])->render(); expect($html) ->toContain(e(__('home.top_results_title'))) ->toContain(__('home.top_results_volume')) ->toContain('Anna Alpha') ->toContain('Ben Beta') ->toContain(__('home.top_results_empty')); // leere Recruiter-Spalte $emptyHtml = view('dashboard._top_results', [ 'monthlyStats' => [ 'month' => 8, 'year' => 2026, 'top_results' => ['top_volume' => [], 'top_recruiters' => [], 'top_new_abos' => []], ], ])->render(); expect($emptyHtml)->not->toContain(e(__('home.top_results_title'))); }); it('haelt "Top 3 neue Abos" fuer abgeschlossene Monate stabil, auch wenn Abos spaeter enden', function (): void { // Regression: die Liste zaehlte ueber activeAbo() und schrumpfte damit // rueckwirkend, sobald ein Abo endete — anders als die Top-3-Neupartner // daneben, die ueber newPartnerIn() bereits stichtagsstabil sind. $root = trsMakeUser(null, 'Root', 'Viewer'); $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); // Im Juli gestartet, erst im August gekuendigt: gehoert in die Juli-Zahl. trsMakeAbo($anna->id, 'ot', '2026-07-10', active: false) ->update(['cancel_date' => '2026-08-05']); trsMakeAbo($anna->id, 'ot', '2026-07-20'); // Im August gestartet: gehoert nicht in die Juli-Zahl. trsMakeAbo($anna->id, 'ot', '2026-08-02'); Cache::flush(); $result = app(TopResultsService::class)->monthlyTop3($root, 7, 2026); expect($result['top_new_abos'])->toHaveCount(1) ->and($result['top_new_abos'][0]['first_name'])->toBe('Anna') ->and($result['top_new_abos'][0]['value'])->toBe(2); }); it('rechnet dem Recruiter den Neupartner im Monat der Zahlung an', function (): void { // Registriert am 28.08., bezahlt am 01.09. -> September-Recruit, nicht August. Carbon::setTestNow(Carbon::parse('2026-09-15')); $root = trsMakeUser(null, 'Root', 'Viewer'); $sponsor = trsMakeUser($root->id, 'Sandra', 'Sponsor'); trsMakeUser($sponsor->id, 'Spaet', 'Zahler', '2026-08-28') ->forceFill(['partner_paid_at' => '2026-09-01 06:49:35'])->save(); Cache::flush(); $august = app(TopResultsService::class)->monthlyTop3($root, 8, 2026); Cache::flush(); $september = app(TopResultsService::class)->monthlyTop3($root, 9, 2026); expect(collect($august['top_recruiters'])->firstWhere('user_id', $sponsor->id))->toBeNull() ->and(collect($september['top_recruiters'])->firstWhere('user_id', $sponsor->id))->toMatchArray(['value' => 1]); });