Files

360 lines
15 KiB
PHP

<?php
namespace Tests\Feature;
use App\Http\Livewire\SingleReport;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
/**
* Guards the reciprocity rate against small-denominator distortion.
*
* The rate was a plain mean of per-partner percentages, so a partner given 6
* minutes who returned 1.941 scored 32.350% and outvoted partners representing
* hundreds of hours. A real organization's report showed 414,5% when its actual
* return was 93%. The number looked plausible, which is why it went unnoticed.
*/
class ReturnRatioTest extends TestCase
{
// The network tests below insert fixtures; roll them back afterwards.
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
// An in-memory cache: phpunit.xml configures Redis, which is the dev
// site's cache too, and the network graph built here caches the TEST
// database's transaction type names there for ever (see
// StatsAggregationTest::setUp).
config(['cache.default' => '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']);
}
}