360 lines
15 KiB
PHP
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']);
|
|
}
|
|
}
|