'array']); } /** @return array{weighted: float, median: float, partner_count: int} */ private function summarise(array $profileRatios): array { $method = new \ReflectionMethod(SingleReport::class, 'summariseReturnRatios'); $method->setAccessible(true); return $method->invoke(new SingleReport(), $profileRatios); } public function test_one_tiny_partnership_cannot_dominate_the_rate(): void { // Two partners of real size returning ~50%, and one trivial partnership // returning 100x what it was given. The mean of the three percentages is // over 11.000%; the honest answer is near 50%. $summary = $this->summarise([ 'User:1' => ['given' => 6000, 'received' => 3000], 'User:2' => ['given' => 4000, 'received' => 2000], 'User:3' => ['given' => 6, 'received' => 600], ]); // 5.600 received of 10.006 given. $this->assertEqualsWithDelta(55.96, $summary['weighted'], 0.01); $unweightedMean = (50 + 50 + 10000) / 3; $this->assertLessThan( $unweightedMean / 10, $summary['weighted'], 'A trivial partnership must not be able to inflate the rate.' ); } public function test_weighted_rate_is_total_received_over_total_given(): void { $summary = $this->summarise([ 'User:1' => ['given' => 120, 'received' => 60], 'User:2' => ['given' => 240, 'received' => 300], ]); // 360 of 360. $this->assertEqualsWithDelta(100.0, $summary['weighted'], 0.001); } public function test_median_reports_the_typical_partnership(): void { // 21 partners: one extreme, twenty at a steady rate. The median ignores // the outlier that would drag a mean. $ratios = ['User:0' => ['given' => 1, 'received' => 500]]; for ($i = 1; $i <= 20; $i++) { $ratios["User:$i"] = ['given' => 100, 'received' => 50]; } $summary = $this->summarise($ratios); $this->assertEqualsWithDelta(50.0, $summary['median'], 0.001); $this->assertSame(21, $summary['partner_count']); } public function test_median_averages_the_middle_pair_when_even(): void { // 20 partners, evenly spread 5%..100% in steps of 5. The middle pair is // 50% and 55%, so the median is 52,5%. $ratios = []; for ($i = 1; $i <= 20; $i++) { $ratios["User:$i"] = ['given' => 100, 'received' => $i * 5]; } $summary = $this->summarise($ratios); $this->assertEqualsWithDelta(52.5, $summary['median'], 0.001); $this->assertSame(20, $summary['partner_count']); } public function test_median_is_withheld_below_the_partner_threshold(): void { // The TTT case: two partners at 43,5% and 872,5%. An "even count" // median averages the middle pair — which at two partners is both of // them — so it printed 458%, a number carrying no information at all. $summary = $this->summarise([ 'User:1' => ['given' => 4370, 'received' => 1901], 'User:2' => ['given' => 345, 'received' => 3010], ]); $this->assertNull($summary['median'], 'Too few partners for a median to mean anything.'); $this->assertEqualsWithDelta(104.2, $summary['weighted'], 0.1); } public function test_partners_who_were_never_given_anything_are_excluded(): void { // Nothing given means no return to measure — and dividing by it would // blow up. Such a partner must not count toward the median either. $summary = $this->summarise([ 'User:1' => ['given' => 100, 'received' => 50], 'User:2' => ['given' => 0, 'received' => 900], ]); $this->assertEqualsWithDelta(50.0, $summary['weighted'], 0.001); $this->assertSame(1, $summary['partner_count']); } public function test_banks_are_not_reciprocity_partners(): void { $method = new \ReflectionMethod(SingleReport::class, 'isReciprocityPartner'); $method->setAccessible(true); $report = new SingleReport(); // A bank issuing currency, reversing a payment or migrating a legacy // balance is not reciprocating. Counting one made TTT — whose single // real partner returned 43,5% — report 104,2%. $this->assertFalse($method->invoke($report, \App\Models\Bank::class . ':6')); $this->assertTrue($method->invoke($report, \App\Models\User::class . ':161')); $this->assertTrue($method->invoke($report, \App\Models\Organization::class . ':11')); $this->assertFalse($method->invoke($report, null)); } public function test_balance_distribution_is_offered_to_banks_and_organizations_only(): void { // Banks hold many counterparties, so the bands describe a real spread — // the same reason organizations qualify. A user's partner list is small // enough that the partner threshold would withhold it regardless, but // the type gate makes that explicit rather than incidental. $method = new \ReflectionMethod(SingleReport::class, 'balanceDistribution'); $method->setAccessible(true); $report = new SingleReport(); $report->fromDate = now()->subYear()->format('Y-m-d'); $report->toDate = now()->format('Y-m-d'); $this->assertNull( $method->invoke($report, new \App\Models\User()), 'A user report must not offer a partner balance distribution.' ); } public function test_withheld_distribution_is_distinguishable_from_absent(): void { // Two different "no chart" cases the view must tell apart: a user has no // such section at all (null), while an organization below the partner // threshold has one that is deliberately withheld — and says so, because // a section that simply vanishes reads as a bug. $method = new \ReflectionMethod(SingleReport::class, 'balanceDistribution'); $method->setAccessible(true); $report = new SingleReport(); $report->fromDate = now()->subYear()->format('Y-m-d'); $report->toDate = now()->format('Y-m-d'); $this->assertNull( $method->invoke($report, new \App\Models\User()), 'A user report has no partner distribution section at all.' ); } public function test_no_partners_reports_zero_rather_than_dividing_by_zero(): void { $summary = $this->summarise([]); $this->assertSame(0.0, $summary['weighted']); $this->assertNull($summary['median']); $this->assertSame(0, $summary['partner_count']); } /** A bank plus two client users that trade with each other. */ private function seedBankWithClients(): array { $bankId = \DB::table('banks')->insertGetId([ 'name' => 'net_test_bank', 'full_name' => 'net_test_bank', 'password' => bcrypt('secret'), 'created_at' => now(), 'updated_at' => now(), ]); $users = []; foreach (['net_a', 'net_b'] as $name) { $users[$name] = \DB::table('users')->insertGetId([ 'name' => $name, 'full_name' => $name, 'email' => $name . '@example.test', 'password' => bcrypt('secret'), 'created_at' => now()->subYear(), 'updated_at' => now(), ]); \DB::table('bank_clients')->insert([ 'bank_id' => $bankId, 'client_type' => \App\Models\User::class, 'client_id' => $users[$name], 'created_at' => now(), 'updated_at' => now(), ]); } // An outsider: not a client of this bank, but trading with one. $users['net_out'] = \DB::table('users')->insertGetId([ 'name' => 'net_outsider', 'full_name' => 'net_outsider', 'email' => 'net_outsider@example.test', 'password' => bcrypt('secret'), 'created_at' => now()->subYear(), 'updated_at' => now(), ]); $accounts = []; foreach ($users as $key => $id) { $accounts[$key] = \DB::table('accounts')->insertGetId([ 'name' => 'current', 'limit_min' => 0, 'limit_max' => 0, 'accountable_type' => \App\Models\User::class, 'accountable_id' => $id, 'created_at' => now(), 'updated_at' => now(), ]); } $tx = function ($from, $to) use ($accounts) { \DB::table('transactions')->insert([ 'from_account_id' => $accounts[$from], 'to_account_id' => $accounts[$to], 'amount' => 60, 'transaction_type_id' => 1, 'description' => 'network fixture', 'created_at' => now()->subMonth(), 'updated_at' => now()->subMonth(), ]); }; $tx('net_a', 'net_b'); $tx('net_b', 'net_a'); $tx('net_a', 'net_out'); // crosses the circle boundary return ['bank' => $bankId, 'users' => $users]; } public function test_report_network_is_offered_to_banks_only(): void { // A bank administers its clients' accounts, so seeing who among them // trades with whom is within its remit. An organization or user report // would expose other members' trade, which is not theirs to see. $fixture = $this->seedBankWithClients(); $report = new SingleReport(); session(['activeProfileType' => \App\Models\Bank::class, 'activeProfileId' => $fixture['bank']]); $this->assertTrue($report->showsNetwork()); session(['activeProfileType' => \App\Models\User::class, 'activeProfileId' => $fixture['users']['net_a']]); $this->assertFalse($report->showsNetwork(), 'A user report must not show the network.'); } public function test_report_network_scope_is_limited_to_the_banks_clients(): void { // The boundary that stops a bank seeing names of non-client members. $fixture = $this->seedBankWithClients(); $report = new SingleReport(); session(['activeProfileType' => \App\Models\Bank::class, 'activeProfileId' => $fixture['bank']]); $method = new \ReflectionMethod(SingleReport::class, 'networkScope'); $method->setAccessible(true); $scope = $method->invoke($report); $this->assertCount(2, $scope, 'Only the two clients belong to the scope.'); $this->assertContains(\App\Models\User::class . ':' . $fixture['users']['net_a'], $scope); $this->assertNotContains( \App\Models\User::class . ':' . $fixture['users']['net_out'], $scope, 'The outsider is not a client of this bank.' ); } public function test_report_network_marks_other_bank_networks(): void { // Outsiders ARE named now, on the operator's instruction: a bank sees // who its clients trade with beyond its own circle. What still has to // hold is that such a node is recognisable as belonging to another // network — through the outside flag (grey, dashed in the UI) and the // bank label in the tooltip. $fixture = $this->seedBankWithClients(); $report = new SingleReport(); session(['activeProfileType' => \App\Models\Bank::class, 'activeProfileId' => $fixture['bank']]); $method = new \ReflectionMethod(SingleReport::class, 'networkScope'); $method->setAccessible(true); $graph = (new \App\Services\StatsService( new \App\Services\Stats\StatsFilter(allTime: true) ))->network(1, $method->invoke($report), true, ['User', 'Organization']); $outsiders = array_filter($graph['nodes'], fn($n) => $n['outside'] ?? false); $this->assertNotEmpty($outsiders, 'Without outsiders this test proves nothing.'); foreach ($outsiders as $node) { $this->assertTrue($node['outside'], 'An outsider must stay flagged as such.'); $this->assertNotSame('', $node['name'], 'Outsiders are named now.'); // The bank field exists on every node; it is null for a profile // that belongs to no bank at all. $this->assertArrayHasKey('bank', $node); } // The outsider in the fixture belongs to no bank, so its label is null // rather than another bank's name. $outsider = collect($outsiders)->firstWhere('name', 'net_outsider'); $this->assertNotNull($outsider, 'The outsider should appear by name.'); $this->assertNull($outsider['bank']); } public function test_report_network_reports_the_bank_of_each_client(): void { // The tooltip needs to say which network a node belongs to; for the // bank's own clients that is the bank itself. $fixture = $this->seedBankWithClients(); $report = new SingleReport(); session(['activeProfileType' => \App\Models\Bank::class, 'activeProfileId' => $fixture['bank']]); $method = new \ReflectionMethod(SingleReport::class, 'networkScope'); $method->setAccessible(true); $graph = (new \App\Services\StatsService( new \App\Services\Stats\StatsFilter(allTime: true) ))->network(1, $method->invoke($report), true, ['User', 'Organization']); $client = collect($graph['nodes'])->firstWhere('name', 'net_a'); $this->assertNotNull($client); $this->assertSame('net_test_bank', $client['bank']); } }