1388 lines
58 KiB
PHP
1388 lines
58 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\User;
|
|
use App\Services\Stats\StatsFilter;
|
|
use App\Services\StatsService;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Guards the volume-vs-balance distinction.
|
|
*
|
|
* AccountInfoTrait zeroes internal transfers because it computes a *balance*.
|
|
* Reusing that rule for *volume* yields numbers that look plausible and are
|
|
* wrong, so these three quantities are asserted independently against a fixture
|
|
* whose expected values are obvious by inspection.
|
|
*/
|
|
class StatsAggregationTest extends TestCase
|
|
{
|
|
use DatabaseTransactions;
|
|
|
|
private array $accountIds = [];
|
|
private array $userIds = [];
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// DatabaseTransactions rolls back the database but not the cache, and
|
|
// StatsService caches both the stats payload and the network graph.
|
|
// Without a fresh cache a graph built in one test is served to the
|
|
// next, which inserts different fixture rows.
|
|
//
|
|
// An in-memory store of our own, NOT a flush of the default one:
|
|
// phpunit.xml configures Redis, which is the dev site's cache too. A
|
|
// flush there wiped the site's cache on every run and refilled it with
|
|
// test data - rememberForever() kept the test database's transaction
|
|
// type names, so the live network legend labelled every type "Other".
|
|
config(['cache.default' => 'array']);
|
|
\Illuminate\Support\Facades\Cache::flush();
|
|
|
|
$this->seedFixture();
|
|
}
|
|
|
|
/**
|
|
* Three profiles, A and B inside the filtered set, C outside it.
|
|
*
|
|
* 1. A -> B 60 internal
|
|
* 2. B -> A 30 internal
|
|
* 3. A -> C 90 external (outflow)
|
|
* 4. C -> B 20 external (inflow)
|
|
*/
|
|
private function seedFixture(): void
|
|
{
|
|
foreach (['stats_a', 'stats_b', 'stats_c'] as $name) {
|
|
$this->makeUser($name);
|
|
}
|
|
|
|
$tx = function (string $from, string $to, int $amount) {
|
|
DB::table('transactions')->insert([
|
|
'from_account_id' => $this->accountIds[$from],
|
|
'to_account_id' => $this->accountIds[$to],
|
|
'amount' => $amount,
|
|
'transaction_type_id' => 1,
|
|
'description' => 'stats fixture',
|
|
'created_at' => now()->subMonth(),
|
|
'updated_at' => now()->subMonth(),
|
|
]);
|
|
};
|
|
|
|
$tx('stats_a', 'stats_b', 60);
|
|
$tx('stats_b', 'stats_a', 30);
|
|
$tx('stats_a', 'stats_c', 90);
|
|
$tx('stats_c', 'stats_b', 20);
|
|
}
|
|
|
|
/** A user plus its account, recorded in $userIds / $accountIds by name. */
|
|
private function makeUser(string $name): int
|
|
{
|
|
$userId = DB::table('users')->insertGetId([
|
|
'name' => $name,
|
|
'full_name' => $name,
|
|
'email' => $name . '@example.test',
|
|
'password' => bcrypt('secret'),
|
|
'created_at' => now()->subMonths(2),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
$this->userIds[$name] = $userId;
|
|
$this->accountIds[$name] = DB::table('accounts')->insertGetId([
|
|
'name' => 'current',
|
|
'limit_min' => 0,
|
|
'limit_max' => 0,
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $userId,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
return $userId;
|
|
}
|
|
|
|
/** A service scoped to exactly accounts A and B. */
|
|
private function serviceForAB(): StatsService
|
|
{
|
|
$service = new class(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
)) extends StatsService {
|
|
public array $forcedAccountIds = [];
|
|
public array $forcedProfileKeys = [];
|
|
|
|
public function accountIds(): array
|
|
{
|
|
return $this->forcedAccountIds;
|
|
}
|
|
|
|
public function profileKeys(): array
|
|
{
|
|
return $this->forcedProfileKeys;
|
|
}
|
|
};
|
|
|
|
$service->forcedAccountIds = [
|
|
$this->accountIds['stats_a'],
|
|
$this->accountIds['stats_b'],
|
|
];
|
|
$service->forcedProfileKeys = [
|
|
User::class => [$this->userIds['stats_a'], $this->userIds['stats_b']],
|
|
];
|
|
|
|
return $service;
|
|
}
|
|
|
|
public function test_trading_partners_counts_distinct_counterparties_not_transactions(): void
|
|
{
|
|
// The fixture gives A three transactions (two with B, one with C) but
|
|
// only two distinct counterparties, so a column that counted rows
|
|
// instead of partners would read 3 here.
|
|
$rows = $this->serviceForAB()->distribution()['top_profiles']
|
|
->keyBy(fn($p) => $p['id']);
|
|
|
|
$a = $rows[$this->userIds['stats_a']];
|
|
$b = $rows[$this->userIds['stats_b']];
|
|
|
|
$this->assertSame(2, $a['partners'], 'A trades with B and C.');
|
|
$this->assertSame(3, $a['count'], 'A has three transactions.');
|
|
|
|
$this->assertSame(2, $b['partners'], 'B trades with A and C.');
|
|
$this->assertSame(3, $b['count'], 'B has three transactions.');
|
|
}
|
|
|
|
public function test_system_types_are_read_per_era_across_the_cyclos_cutover(): void
|
|
{
|
|
// MigrateCyclosCommand copies Cyclos transfer-type ids verbatim, and the
|
|
// two id sets disagree: before the cutover 52/53 mean currency creation,
|
|
// after it 4..8 are the system types. Filtering with a single list over
|
|
// both eras let 453 genuine currency-creation rows through as trade
|
|
// while dropping migrated rows on ids that did not apply to them.
|
|
//
|
|
// economic() deliberately reports every type, so this asserts on the
|
|
// network graph, which is the surface that must show real trade only.
|
|
$cut = \Carbon\Carbon::parse(StatsService::CYCLOS_CUTOVER);
|
|
|
|
$tx = function (int $type, \Carbon\Carbon $when) {
|
|
DB::table('transactions')->insert([
|
|
'from_account_id' => $this->accountIds['stats_a'],
|
|
'to_account_id' => $this->accountIds['stats_b'],
|
|
'amount' => 600,
|
|
'transaction_type_id' => $type,
|
|
'description' => 'era fixture',
|
|
'created_at' => $when,
|
|
'updated_at' => $when,
|
|
]);
|
|
};
|
|
|
|
// Pre-cutover, Cyclos ids: 48 = project work, 52 = currency creation.
|
|
// BOTH belong in the graph: currency creation is how hours enter the
|
|
// network, and hiding it left the banks' counterparties looking like
|
|
// unexplained sources. 50 is a self-payment and must stay out - it
|
|
// moves hours between one member's own accounts.
|
|
//
|
|
// Note the fixture books it between two DIFFERENT profiles, which real
|
|
// self-payments never are (all 9 on dev are same-profile, and those are
|
|
// already dropped by the self-pair rule). Deliberate: it tests the type
|
|
// filter itself rather than the self-pair rule that would mask it.
|
|
$tx(48, $cut->copy()->subDay());
|
|
$tx(52, $cut->copy()->subDay());
|
|
$tx(50, $cut->copy()->subDay());
|
|
|
|
// Measure the delta the two new rows make, so the fixture's own
|
|
// transactions (which predate this test) do not have to be unpicked.
|
|
$volumeInWindow = function () {
|
|
$service = new class(new StatsFilter(
|
|
periodFrom: \Carbon\Carbon::parse(StatsService::CYCLOS_CUTOVER)->subDays(2),
|
|
periodTill: \Carbon\Carbon::parse(StatsService::CYCLOS_CUTOVER)->subHour(),
|
|
)) extends StatsService {
|
|
public array $forcedAccountIds = [];
|
|
public function accountIds(): array { return $this->forcedAccountIds; }
|
|
};
|
|
$service->forcedAccountIds = [
|
|
$this->accountIds['stats_a'],
|
|
$this->accountIds['stats_b'],
|
|
];
|
|
|
|
return collect($service->network(0)['edges'])->sum('volume');
|
|
};
|
|
|
|
// Work + currency creation = 1200; the self-payment must not appear.
|
|
$this->assertSame(
|
|
1200,
|
|
(int) $volumeInWindow(),
|
|
'Cyclos 48 (work) and 52 (currency creation) both count; 50 (self-payment) must not.'
|
|
);
|
|
}
|
|
|
|
public function test_network_frames_split_by_year_and_carry_the_other_filters(): void
|
|
{
|
|
// The time-lapse builds one graph per year. Each frame must cover only
|
|
// its own year (or everything up to it, when cumulative) while keeping
|
|
// every other criterion the user set - withPeriod() exists for exactly
|
|
// that, and dropping a field there would silently widen the selection.
|
|
$service = new StatsService(new StatsFilter(
|
|
registeredFrom: now()->subYears(5),
|
|
includeRemoved: true,
|
|
));
|
|
|
|
$end = now()->subMonth()->startOfMonth();
|
|
$start = $end->copy()->subMonths(2);
|
|
|
|
// The end is an exact, inclusive moment - the time-lapse's end-date
|
|
// picker relies on that - so "through this month" is its last second.
|
|
$result = $service->networkTimelapse($start, $end->copy()->endOfMonth(), 'month', true);
|
|
|
|
// One payload: the graph once, plus each step as changes to the one
|
|
// before. Built per frame instead, each cumulative frame ran a full
|
|
// all-time query - ~1.1s each, 42.5s for a 36-month span.
|
|
$this->assertArrayHasKey('graph', $result);
|
|
$this->assertArrayHasKey('frames', $result);
|
|
$this->assertCount(3, $result['frames']);
|
|
$this->assertSame(
|
|
[
|
|
$start->format('M Y'),
|
|
$start->copy()->addMonth()->format('M Y'),
|
|
$end->format('M Y'),
|
|
],
|
|
array_column($result['frames'], 'label')
|
|
);
|
|
|
|
$frames = \App\Http\Livewire\Admin\Stats::decodeFrameDeltas($result['frames']);
|
|
|
|
// Accumulating: nothing ever leaves.
|
|
foreach ($result['frames'] as $frame) {
|
|
$this->assertSame([], $frame['nodes_removed']);
|
|
$this->assertSame([], $frame['edges_removed']);
|
|
}
|
|
|
|
// The last frame is the whole graph: every frame accumulates.
|
|
$last = end($frames);
|
|
|
|
$this->assertCount(
|
|
count($result['graph']['nodes']),
|
|
$last['nodes'],
|
|
'The final cumulative frame must hold every node in the graph.'
|
|
);
|
|
|
|
// Frames carry ids, not elements - that is what keeps one call cheap.
|
|
foreach ($last['nodes'] as $id) {
|
|
$this->assertIsString($id);
|
|
}
|
|
}
|
|
|
|
public function test_with_period_keeps_every_other_filter(): void
|
|
{
|
|
// Guards against a field being forgotten in withPeriod(): a dropped
|
|
// filter would widen the selection for every time-lapse frame without
|
|
// any visible error.
|
|
$original = new StatsFilter(
|
|
periodFrom: now()->subYear(),
|
|
periodTill: now(),
|
|
cityIds: [7, 9],
|
|
registeredFrom: now()->subYears(3),
|
|
counterparties: [['type' => User::class, 'id' => 42]],
|
|
includeRemoved: true,
|
|
markInactive: true,
|
|
);
|
|
|
|
$moved = $original->withPeriod(
|
|
\Carbon\Carbon::create(2024, 1, 1),
|
|
\Carbon\Carbon::create(2024, 12, 31)
|
|
);
|
|
|
|
$this->assertSame('2024-01-01', $moved->periodFrom->toDateString());
|
|
$this->assertSame([7, 9], $moved->cityIds);
|
|
$this->assertSame($original->registeredFrom, $moved->registeredFrom);
|
|
$this->assertSame($original->counterparties, $moved->counterparties);
|
|
$this->assertTrue($moved->includeRemoved);
|
|
$this->assertTrue($moved->markInactive);
|
|
|
|
// Different period must mean a different cache key, or frames would
|
|
// serve each other's graphs.
|
|
$this->assertNotSame($original->hash(), $moved->hash());
|
|
}
|
|
|
|
public function test_volume_counts_every_transaction_once_including_internal(): void
|
|
{
|
|
$economic = $this->serviceForAB()->economic();
|
|
|
|
// All four transactions touch A or B: 60 + 30 + 90 + 20.
|
|
$this->assertSame(200, $economic['total_volume']);
|
|
$this->assertSame(4, $economic['transaction_count']);
|
|
}
|
|
|
|
public function test_internal_and_external_volume_split(): void
|
|
{
|
|
$economic = $this->serviceForAB()->economic();
|
|
|
|
// Both sides inside the set: 60 + 30.
|
|
$this->assertSame(90, $economic['internal_volume']);
|
|
// One side outside the set: 90 + 20.
|
|
$this->assertSame(110, $economic['external_volume']);
|
|
$this->assertSame(
|
|
$economic['total_volume'],
|
|
$economic['internal_volume'] + $economic['external_volume']
|
|
);
|
|
}
|
|
|
|
public function test_balance_nets_internal_transfers_to_zero(): void
|
|
{
|
|
// Internal transfers cancel; only the external legs move the balance:
|
|
// -90 (A to C) + 20 (C to B) = -70.
|
|
$this->assertSame(-70, $this->serviceForAB()->balance());
|
|
}
|
|
|
|
public function test_volume_and_balance_are_not_the_same_quantity(): void
|
|
{
|
|
$service = $this->serviceForAB();
|
|
|
|
// The regression this suite exists for: if volume were computed with the
|
|
// balance's `THEN 0` rule it would report 110, not 200.
|
|
$this->assertNotSame($service->balance(), $service->economic()['total_volume']);
|
|
$this->assertSame(200, $service->economic()['total_volume']);
|
|
}
|
|
|
|
public function test_counterparty_filter_counts_only_dealings_with_the_selected_profile(): void
|
|
{
|
|
// Filtering on A must report A's transactions (1, 2, 3 = three rows),
|
|
// not everything its counterparties happen to do elsewhere. Transaction
|
|
// 4 (C -> B) involves two profiles that both transacted with A, but not
|
|
// A itself, so it must be excluded — this is the regression that made a
|
|
// single busy counterparty swamp the whole result.
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
counterparties: [['type' => User::class, 'id' => $this->userIds['stats_a']]],
|
|
));
|
|
|
|
$economic = $service->economic();
|
|
|
|
$this->assertSame(3, $economic['transaction_count']);
|
|
$this->assertSame(180, $economic['total_volume']); // 60 + 30 + 90
|
|
}
|
|
|
|
public function test_counterparty_filter_includes_the_selected_profile_itself(): void
|
|
{
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
counterparties: [['type' => User::class, 'id' => $this->userIds['stats_a']]],
|
|
));
|
|
|
|
$this->assertContains(
|
|
$this->userIds['stats_a'],
|
|
$service->profileKeys()[User::class] ?? [],
|
|
'The profile you filter on must appear in its own statistics.'
|
|
);
|
|
}
|
|
|
|
public function test_internal_external_split_is_hidden_when_it_cannot_be_non_zero(): void
|
|
{
|
|
// With a counterparty filter the set is "the selected profiles plus
|
|
// everyone they traded with", so every counted transaction has both ends
|
|
// inside it and external is 0 by construction. Reporting that would read
|
|
// as a finding ("no external activity") rather than a tautology.
|
|
$filtered = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
counterparties: [['type' => User::class, 'id' => $this->userIds['stats_a']]],
|
|
));
|
|
|
|
$economic = $filtered->economic();
|
|
|
|
$this->assertSame(0, $economic['external_volume']);
|
|
$this->assertFalse(
|
|
$economic['split_is_meaningful'],
|
|
'The split must not be presented when it is structurally always zero.'
|
|
);
|
|
}
|
|
|
|
public function test_counterparty_filter_reports_the_selected_profiles_balance(): void
|
|
{
|
|
// Filtering on A must report A's own balance, not the combined holdings
|
|
// of everyone A traded with — otherwise picking one member reports the
|
|
// balance of every organisation and bank they ever dealt with.
|
|
// A: -60 +30 -90 = -120.
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
counterparties: [['type' => User::class, 'id' => $this->userIds['stats_a']]],
|
|
));
|
|
|
|
$this->assertSame(-120, $service->balance());
|
|
}
|
|
|
|
public function test_balance_distribution_bands_every_profile_exactly_once(): void
|
|
{
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subMonths(6),
|
|
));
|
|
|
|
$distribution = $service->balanceDistribution();
|
|
$banded = collect($distribution['bands'])->sum('count');
|
|
|
|
$this->assertSame(
|
|
$distribution['profiles_counted'],
|
|
$banded,
|
|
'Every profile must land in exactly one band — no double-counting, none dropped.'
|
|
);
|
|
}
|
|
|
|
public function test_balance_distribution_excludes_the_selected_profile(): void
|
|
{
|
|
// Filtering on A charts A's trading partners (B and C). A itself is
|
|
// reported as the headline balance, so repeating it here would be noise.
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
counterparties: [['type' => User::class, 'id' => $this->userIds['stats_a']]],
|
|
));
|
|
|
|
$this->assertSame(2, $service->balanceDistribution()['profiles_counted']);
|
|
}
|
|
|
|
public function test_balance_distribution_drops_bands_no_account_can_reach(): void
|
|
{
|
|
// The axis stops at zero only when the accounts cannot go negative AND
|
|
// none actually has. Every profile in the base fixture trades with
|
|
// stats_a, which ends at -2 h, so this case needs its own pair: one
|
|
// payer and one receiver, isolated from the rest.
|
|
$payerId = $this->makeUser('stats_pos_payer');
|
|
$this->makeUser('stats_pos_payee');
|
|
$this->makeUser('stats_pos_square');
|
|
|
|
$pay = function (string $from, string $to, int $amount) {
|
|
DB::table('transactions')->insert([
|
|
'from_account_id' => $this->accountIds[$from],
|
|
'to_account_id' => $this->accountIds[$to],
|
|
'amount' => $amount,
|
|
'transaction_type_id' => 1,
|
|
'description' => 'stats fixture',
|
|
'created_at' => now()->subMonth(),
|
|
'updated_at' => now()->subMonth(),
|
|
]);
|
|
};
|
|
|
|
// The payer ends negative and is excluded from the charted set below.
|
|
// Of its two partners one ends at +1 h and the other exactly square —
|
|
// a zero lowest value is what pushes the axis padding below zero, so
|
|
// without it the clamp is never reached and the test proves nothing.
|
|
$pay('stats_pos_payer', 'stats_pos_payee', 60);
|
|
$pay('stats_pos_payer', 'stats_pos_square', 60);
|
|
$pay('stats_pos_square', 'stats_pos_payer', 60);
|
|
|
|
// Filtering on the payer charts its partners — here, only the payee.
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
counterparties: [['type' => User::class, 'id' => $payerId]],
|
|
));
|
|
|
|
$distribution = $service->balanceDistribution();
|
|
$labels = collect($distribution['bands'])->pluck('label');
|
|
|
|
// Sanity: both partners are charted, the lowest of them exactly zero,
|
|
// which is the case where the axis would otherwise pad into negatives.
|
|
$this->assertSame(2, $distribution['profiles_counted']);
|
|
|
|
// Numeric bounds, not label text: every range label contains a "-" as
|
|
// its separator, so matching on the character finds them all.
|
|
$negative = collect($distribution['bands'])->filter(fn($band) => $band['min'] < 0);
|
|
$this->assertTrue(
|
|
$negative->isEmpty(),
|
|
'Negative bands must not appear when no account can hold a negative balance. Got: '
|
|
. $negative->pluck('label')->implode(', ')
|
|
);
|
|
}
|
|
|
|
public function test_balance_distribution_shows_negative_bands_when_a_balance_is_negative(): void
|
|
{
|
|
// The mirror of the clamp: stats_a really ends at -2 h, and a limit_min
|
|
// of 0 does not make that profile disappear. Charting only what the
|
|
// limit permits would drop it off the axis entirely.
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subMonths(6),
|
|
));
|
|
|
|
$bands = collect($service->balanceDistribution()['bands']);
|
|
|
|
$this->assertNotEmpty(
|
|
$bands->filter(fn($band) => $band['min'] < 0),
|
|
'A real negative balance must still be charted, whatever the limit says.'
|
|
);
|
|
}
|
|
|
|
public function test_balance_distribution_bands_are_closed_ranges(): void
|
|
{
|
|
// Every band states both bounds. An open "> 50" reads the same whether
|
|
// the profiles inside hold 51 hours or 5.100, which is exactly what the
|
|
// chart is meant to reveal.
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subMonths(6),
|
|
));
|
|
|
|
foreach ($service->balanceDistribution()['bands'] as $band) {
|
|
if ($band['min'] == 0 && $band['max'] == 0) {
|
|
continue; // the exact-zero bucket is a point, not a range
|
|
}
|
|
|
|
// The axis label is the lower bound alone ("H 10") — the gridlines
|
|
// mark each edge, so repeating both ends is redundant there. The
|
|
// full range lives on 'range', for the tooltip.
|
|
$this->assertMatchesRegularExpression(
|
|
'/^\S+ -?[\d.]+(:\d{2})? - -?[\d.]+(:\d{2})?$/',
|
|
$band['range'],
|
|
'Every band must state both of its bounds in its range.'
|
|
);
|
|
}
|
|
}
|
|
|
|
public function test_balance_distribution_bands_are_uniform_width(): void
|
|
{
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subMonths(6),
|
|
));
|
|
|
|
$widths = collect($service->balanceDistribution()['bands'])
|
|
->reject(fn($band) => $band['min'] == 0 && $band['max'] == 0)
|
|
->map(function ($band) {
|
|
// Widths come from the numeric bounds, not the label text.
|
|
return round((float) $band['max'] - (float) $band['min'], 6);
|
|
})
|
|
->unique();
|
|
|
|
$this->assertCount(1, $widths, 'All range bands must share one width.');
|
|
}
|
|
|
|
public function test_balance_distribution_keeps_a_band_that_holds_someone(): void
|
|
{
|
|
// A balance outside the configured limit still has to be charted: the
|
|
// limit may have been raised, or predate the data. Dropping the band
|
|
// would silently drop the profile, so observed data outranks the limit.
|
|
// stats_c is outside the A/B set, holds +90 -20 = +70, and its account
|
|
// has limit_max = 0 — so a limit-only rule would cut the "> 50" band.
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subMonths(6),
|
|
));
|
|
|
|
$distribution = $service->balanceDistribution();
|
|
|
|
$this->assertSame(
|
|
$distribution['profiles_counted'],
|
|
collect($distribution['bands'])->sum('count'),
|
|
'Narrowing the axis must never drop a profile out of every band.'
|
|
);
|
|
}
|
|
|
|
public function test_balance_distribution_keeps_exactly_one_empty_band_at_the_top(): void
|
|
{
|
|
// Rounding the axis up to a whole step can leave several empty bands
|
|
// past the last occupied one. One shows where the range ends; the rest
|
|
// are dead width.
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subMonths(6),
|
|
));
|
|
|
|
$counts = collect($service->balanceDistribution()['bands'])->pluck('count');
|
|
|
|
$this->assertGreaterThan(0, $counts->count());
|
|
$this->assertSame(0, $counts->last(), 'The axis must end on an empty band.');
|
|
|
|
if ($counts->count() > 1) {
|
|
$this->assertGreaterThan(
|
|
0,
|
|
$counts->slice(-2, 1)->first(),
|
|
'Only one empty band may follow the last occupied one.'
|
|
);
|
|
}
|
|
}
|
|
|
|
public function test_balance_distribution_excludes_banks(): void
|
|
{
|
|
// A bank issues and withdraws currency, so its balance is the mirror
|
|
// image of every member's and always lands in an outermost band. The
|
|
// other distribution tests use a bank-free fixture, so they would still
|
|
// pass if the exclusion broke — this one adds a bank on purpose.
|
|
$bankId = DB::table('banks')->insertGetId([
|
|
'name' => 'stats_bank',
|
|
'full_name' => 'stats_bank',
|
|
'email' => 'stats_bank@example.test',
|
|
'password' => bcrypt('secret'),
|
|
'level' => 0,
|
|
'limit_min' => 0,
|
|
'limit_max' => 0,
|
|
'created_at' => now()->subMonths(2),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
$bankAccountId = DB::table('accounts')->insertGetId([
|
|
'name' => 'current',
|
|
'limit_min' => 0,
|
|
'limit_max' => 0,
|
|
'accountable_type' => \App\Models\Bank::class,
|
|
'accountable_id' => $bankId,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
// Give the bank a balance, so it would certainly be banded if counted.
|
|
DB::table('transactions')->insert([
|
|
'from_account_id' => $bankAccountId,
|
|
'to_account_id' => $this->accountIds['stats_a'],
|
|
'amount' => 6000,
|
|
'transaction_type_id' => 1,
|
|
'description' => 'stats fixture',
|
|
'created_at' => now()->subMonth(),
|
|
'updated_at' => now()->subMonth(),
|
|
]);
|
|
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subMonths(6),
|
|
));
|
|
|
|
$distribution = $service->balanceDistribution();
|
|
|
|
$this->assertArrayNotHasKey(
|
|
'Bank',
|
|
$distribution['series'],
|
|
'Banks must not be charted in the balance distribution.'
|
|
);
|
|
|
|
// And the bank must not be hiding inside the totals either.
|
|
$banded = collect($distribution['bands'])->sum('count');
|
|
$this->assertSame($distribution['profiles_counted'], $banded);
|
|
$this->assertSame(
|
|
collect($distribution['series'])->flatten()->sum(),
|
|
$banded,
|
|
'The per-type series must account for every banded profile.'
|
|
);
|
|
}
|
|
|
|
public function test_by_location_paid_columns_reconcile_with_the_section_totals(): void
|
|
{
|
|
// Attributing a transaction to BOTH ends counted it twice whenever payer
|
|
// and payee sat in different places, and doubled every volume outright —
|
|
// one city once reported more volume than the whole platform. Counting
|
|
// the payer only means these rows must add up exactly.
|
|
$service = $this->serviceForAB();
|
|
$byLocation = $service->distribution()['by_location'];
|
|
|
|
// The paid columns attribute each transaction to exactly one place —
|
|
// the payer's — so they are the figures that reconcile.
|
|
//
|
|
// Payments *sent by* the set {A, B}: 60 (A->B), 30 (B->A), 90 (A->C).
|
|
// Transaction 4 (C->B, 20) is excluded: C pays and sits outside the set.
|
|
$this->assertSame(
|
|
180,
|
|
(int) $byLocation->sum(fn($row) => $row['paid_volume']),
|
|
'Paid volume must count each payment once, under the payer.'
|
|
);
|
|
|
|
$this->assertSame(
|
|
3,
|
|
(int) $byLocation->sum(fn($row) => $row['paid_count']),
|
|
'Every payment must be counted under exactly one paying location.'
|
|
);
|
|
|
|
// The combined columns deliberately count both ends — that is what
|
|
// makes them "activity touching this place" — so they may exceed the
|
|
// section total. What must never happen is a *paid* column doing so.
|
|
$this->assertLessThanOrEqual(
|
|
$service->economic()['total_volume'],
|
|
(int) $byLocation->sum(fn($row) => $row['paid_volume']),
|
|
'The payer-attributed total can never exceed the volume it breaks down.'
|
|
);
|
|
}
|
|
|
|
public function test_distribution_tables_exclude_the_selected_profile(): void
|
|
{
|
|
// The selected profile is party to every transaction the filter counts,
|
|
// so it would top both tables and say nothing about its partners. Its
|
|
// transactions still count — they are what the partners' figures are.
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
counterparties: [['type' => User::class, 'id' => $this->userIds['stats_a']]],
|
|
));
|
|
|
|
$distribution = $service->distribution();
|
|
|
|
$this->assertNotContains(
|
|
$this->userIds['stats_a'],
|
|
$distribution['top_profiles']->where('type', 'User')->pluck('id'),
|
|
'The profile being filtered on must not appear among its own partners.'
|
|
);
|
|
|
|
$this->assertContains(
|
|
'stats_a',
|
|
$distribution['excluded_profiles'],
|
|
'The excluded profile must be named, so the omission is visible.'
|
|
);
|
|
|
|
// Its partners are still here, with the dealings they had with it.
|
|
$this->assertNotEmpty($distribution['top_profiles']);
|
|
}
|
|
|
|
public function test_anonymous_labels_match_the_graph_label_for_the_same_profile(): void
|
|
{
|
|
// The table and the network graph are built over different transaction
|
|
// sets: the graph has its own period override, degree thresholds and
|
|
// type filters. A rank-based number would therefore mean "12th busiest
|
|
// in THIS list" and the two sides would disagree about who "User 12"
|
|
// is, which defeats looking a profile up in the graph by the label the
|
|
// table shows. Deriving it from identity is what keeps them equal.
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subMonths(6),
|
|
));
|
|
|
|
$rows = $service->distribution()['top_profiles']
|
|
->reject(fn($p) => $p['type'] === 'Bank');
|
|
|
|
$this->assertNotEmpty($rows, 'Fixture produced no non-bank profiles.');
|
|
|
|
foreach ($rows as $row) {
|
|
// What the graph builds its label from is the node id, which is the
|
|
// same 'Type:id' key, so recomputing it here is the same operation
|
|
// the browser performs.
|
|
$key = 'App\\Models\\' . $row['type'] . ':' . $row['id'];
|
|
|
|
// Asserted against a literal, NOT against pseudonym() itself:
|
|
// calling the same method on both sides would make this pass even
|
|
// when that method is wrong, since both sides would move together.
|
|
// This string is also what the browser builds from the node id, so
|
|
// it is the real contract between the table and the graph.
|
|
$expected = __($row['type']) . ' ' . $row['id'];
|
|
|
|
$this->assertSame(
|
|
$expected,
|
|
$row['anonymous_name'],
|
|
"Table and graph disagree on the label for {$key}."
|
|
);
|
|
|
|
// And the helper the graph mirrors agrees with that same literal.
|
|
$this->assertSame($expected, StatsService::pseudonym($key));
|
|
}
|
|
}
|
|
|
|
public function test_anonymous_labels_stay_with_the_same_profile_across_sorts(): void
|
|
{
|
|
// The whole point of a numbered label is that it identifies one profile
|
|
// consistently. Numbering the fetched page instead of the filtered set
|
|
// made "User 3" a different person under each sort — two exports of the
|
|
// same data would disagree about who that was.
|
|
$labelsFor = function (string $sort, string $direction) {
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subMonths(6),
|
|
topSort: $sort,
|
|
topDirection: $direction,
|
|
));
|
|
|
|
return $service->distribution()['top_profiles']
|
|
->mapWithKeys(fn($p) => [$p['type'] . ':' . $p['id'] => $p['anonymous_name']]);
|
|
};
|
|
|
|
$default = $labelsFor('count', 'desc');
|
|
$this->assertNotEmpty($default);
|
|
|
|
foreach ([['paid_volume', 'desc'], ['received_count', 'asc'], ['paid_count', 'asc']] as [$sort, $direction]) {
|
|
foreach ($labelsFor($sort, $direction) as $key => $label) {
|
|
if (isset($default[$key])) {
|
|
$this->assertSame(
|
|
$default[$key],
|
|
$label,
|
|
"Profile {$key} changed label under {$sort} {$direction}."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public function test_anonymous_labels_leave_banks_named(): void
|
|
{
|
|
// A platform has a handful of banks, so a number conceals nothing while
|
|
// making the row harder to read.
|
|
$bankId = DB::table('banks')->insertGetId([
|
|
'name' => 'stats_named_bank', 'full_name' => 'stats_named_bank',
|
|
'email' => 'stats_named_bank@example.test', 'password' => bcrypt('secret'),
|
|
'level' => 0, 'limit_min' => 0, 'limit_max' => 0,
|
|
'created_at' => now()->subMonths(2), 'updated_at' => now(),
|
|
]);
|
|
$bankAccountId = DB::table('accounts')->insertGetId([
|
|
'name' => 'current', 'limit_min' => 0, 'limit_max' => 0,
|
|
'accountable_type' => \App\Models\Bank::class, 'accountable_id' => $bankId,
|
|
'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
DB::table('transactions')->insert([
|
|
'from_account_id' => $bankAccountId, 'to_account_id' => $this->accountIds['stats_a'],
|
|
'amount' => 120, 'transaction_type_id' => 1, 'description' => 'stats fixture',
|
|
'created_at' => now()->subMonth(), 'updated_at' => now()->subMonth(),
|
|
]);
|
|
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subMonths(6),
|
|
));
|
|
|
|
$bank = $service->distribution()['top_profiles']->firstWhere('type', 'Bank');
|
|
|
|
$this->assertNotNull($bank);
|
|
$this->assertSame($bank['name'], $bank['anonymous_name']);
|
|
}
|
|
|
|
public function test_empty_account_set_returns_zeros_without_querying(): void
|
|
{
|
|
$service = new StatsService(new StatsFilter(
|
|
// A registration window matching no profile at all.
|
|
registeredFrom: now()->addYears(50),
|
|
registeredTill: now()->addYears(51),
|
|
));
|
|
|
|
$economic = $service->economic();
|
|
|
|
$this->assertSame(0, $economic['total_volume']);
|
|
$this->assertSame(0, $economic['transaction_count']);
|
|
$this->assertSame(0, $service->balance());
|
|
$this->assertTrue($economic['by_type']->isEmpty());
|
|
}
|
|
|
|
/** Run the cumulative deactivation timeline over hand-built month counts. */
|
|
private function inactiveTimeline(
|
|
array $byType,
|
|
?string $from,
|
|
?string $till,
|
|
array $balance = [],
|
|
array $incomplete = []
|
|
): array {
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: $from ? \Carbon\Carbon::parse($from) : null,
|
|
periodTill: $till ? \Carbon\Carbon::parse($till) : null,
|
|
));
|
|
|
|
$method = new \ReflectionMethod($service, 'buildInactiveTimeline');
|
|
$method->setAccessible(true);
|
|
|
|
return $method->invoke($service, $byType, $balance, $incomplete);
|
|
}
|
|
|
|
public function test_deactivation_timeline_carries_the_running_total_through_quiet_months(): void
|
|
{
|
|
// Deactivations arrive in bursts, so most months are empty. An empty
|
|
// month must hold the total steady — dropping it to zero would say
|
|
// every deactivated profile came back.
|
|
$rows = $this->inactiveTimeline(
|
|
['User' => ['2020-01' => 5, '2020-04' => 2]],
|
|
'2020-01-01',
|
|
'2020-05-31'
|
|
);
|
|
|
|
$this->assertSame(
|
|
[5, 5, 5, 7, 7],
|
|
array_column($rows, 'count'),
|
|
'A month with no deactivations must carry the previous total forward.'
|
|
);
|
|
|
|
// The month's own additions stay available for the tooltip, so a flat
|
|
// stretch is distinguishable from missing data.
|
|
$this->assertSame([5, 0, 0, 2, 0], array_column($rows, 'added'));
|
|
}
|
|
|
|
public function test_deactivation_timeline_counts_profiles_deactivated_before_the_period(): void
|
|
{
|
|
// The axis is clamped to the filter period, so deactivations that
|
|
// happened earlier are off-chart. They must still be counted: the line
|
|
// shows how many profiles *stand* deactivated, not how many were
|
|
// deactivated since the window opened.
|
|
$rows = $this->inactiveTimeline(
|
|
['User' => ['2019-06' => 40, '2020-02' => 3]],
|
|
'2020-01-01',
|
|
'2020-03-31'
|
|
);
|
|
|
|
$this->assertSame(40, $rows[0]['count'], 'Earlier deactivations must seed the opening figure.');
|
|
$this->assertSame(0, $rows[0]['added'], 'They are not additions of the charted month.');
|
|
$this->assertSame([40, 43, 43], array_column($rows, 'count'));
|
|
}
|
|
|
|
public function test_deactivation_timeline_totals_every_profile_type(): void
|
|
{
|
|
$rows = $this->inactiveTimeline(
|
|
[
|
|
'User' => ['2020-01' => 4],
|
|
'Organization' => ['2020-02' => 1],
|
|
'Bank' => ['2020-02' => 1],
|
|
],
|
|
'2020-01-01',
|
|
'2020-02-29'
|
|
);
|
|
|
|
// Each type keeps its own running total, and 'count' is their sum.
|
|
$this->assertSame([4, 4], array_column($rows, 'User'));
|
|
$this->assertSame([0, 1], array_column($rows, 'Organization'));
|
|
$this->assertSame([0, 1], array_column($rows, 'Bank'));
|
|
$this->assertSame([4, 6], array_column($rows, 'count'));
|
|
}
|
|
|
|
public function test_balance_line_accumulates_and_converts_minutes_to_hours(): void
|
|
{
|
|
// Balances are stored in minutes and charted in hours, like every other
|
|
// currency axis. The deltas accumulate: a balance is a position, not a
|
|
// per-month quantity.
|
|
$rows = $this->inactiveTimeline(
|
|
['User' => ['2020-01' => 1]],
|
|
'2020-01-01',
|
|
'2020-04-30',
|
|
['2020-01' => 6000, '2020-03' => -1800]
|
|
);
|
|
|
|
// 6000 min = 100 h, held through February, then -1800 min = -30 h.
|
|
$this->assertSame([100.0, 100.0, 70.0, 70.0], array_column($rows, 'balance'));
|
|
}
|
|
|
|
public function test_balance_line_counts_movement_from_before_the_period(): void
|
|
{
|
|
// Same reasoning as the count: the balance carried into the window is
|
|
// part of the position, so the line must not open at zero.
|
|
$rows = $this->inactiveTimeline(
|
|
['User' => ['2019-01' => 1]],
|
|
'2020-01-01',
|
|
'2020-02-29',
|
|
['2019-05' => 12000, '2020-02' => 600]
|
|
);
|
|
|
|
$this->assertSame(200.0, $rows[0]['balance'], 'Earlier movement must seed the opening balance.');
|
|
$this->assertSame([200.0, 210.0], array_column($rows, 'balance'));
|
|
}
|
|
|
|
public function test_timeline_spans_months_where_only_a_balance_moved(): void
|
|
{
|
|
// The two series move independently: a transaction can land in a month
|
|
// where nobody was marked inactive. Such a month must still appear, or
|
|
// the balance line would be cut short.
|
|
$rows = $this->inactiveTimeline(
|
|
['User' => ['2020-01' => 2]],
|
|
null,
|
|
null,
|
|
['2020-03' => 3000]
|
|
);
|
|
|
|
$this->assertSame(['2020-01', '2020-02', '2020-03'], array_column($rows, 'ym'));
|
|
// The count holds flat while the balance moves in the final month.
|
|
$this->assertSame([2, 2, 2], array_column($rows, 'count'));
|
|
$this->assertSame([0.0, 0.0, 50.0], array_column($rows, 'balance'));
|
|
}
|
|
|
|
public function test_removed_profiles_are_excluded_from_the_inactivity_chart(): void
|
|
{
|
|
// "Marked as inactive" and "removed" are different states, even though
|
|
// deleting a profile sets both columns. A removed profile has had its
|
|
// currency withdrawn by DeleteUser (a "Currency removal" transaction
|
|
// for the full balance), so counting it would chart hours that no
|
|
// longer exist — which is what made the balance line fall off a cliff
|
|
// the month a deletion batch ran.
|
|
$marked = $this->makeUser('stats_inactive_live');
|
|
DB::table('users')->where('id', $marked)->update([
|
|
'inactive_at' => now()->subMonths(2),
|
|
]);
|
|
|
|
$removed = $this->makeUser('stats_inactive_removed');
|
|
DB::table('users')->where('id', $removed)->update([
|
|
'inactive_at' => now()->subMonths(2),
|
|
'deleted_at' => now()->subMonth(),
|
|
]);
|
|
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(4),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subYears(5),
|
|
));
|
|
|
|
$rows = $service->online()['inactivations'];
|
|
$this->assertNotEmpty($rows, 'The live inactive profile must appear.');
|
|
|
|
$userIds = $service->profileKeys()[User::class] ?? [];
|
|
$this->assertContains($marked, $userIds, 'A profile marked inactive but not removed belongs in the set.');
|
|
$this->assertNotContains($removed, $userIds, 'A removed profile must not be in the set.');
|
|
|
|
// Compare against the same run with neither fixture present: exactly
|
|
// one of the two must have been added to the running total.
|
|
$baseline = (new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(4),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subYears(5),
|
|
registeredTill: now()->subYears(4),
|
|
)))->online()['inactivations'];
|
|
$baselineCount = empty($baseline) ? 0 : (int) collect($baseline)->last()['count'];
|
|
|
|
$this->assertSame(
|
|
$baselineCount + 1,
|
|
(int) collect($rows)->last()['count'],
|
|
'The live profile is counted once; the removed one not at all.'
|
|
);
|
|
}
|
|
|
|
public function test_incomplete_line_accumulates_like_the_inactive_count(): void
|
|
{
|
|
// incomplete_at gives the state a timestamp, so this is a real
|
|
// cumulative series rather than the flat present-day figure it had to
|
|
// be before the column existed.
|
|
$rows = $this->inactiveTimeline(
|
|
['User' => ['2020-01' => 2]],
|
|
'2020-01-01',
|
|
'2020-04-30',
|
|
[],
|
|
['2020-01' => 10, '2020-03' => 5]
|
|
);
|
|
|
|
$this->assertSame([10, 10, 15, 15], array_column($rows, 'incomplete'));
|
|
// It moves independently of the inactivity count.
|
|
$this->assertSame([2, 2, 2, 2], array_column($rows, 'count'));
|
|
}
|
|
|
|
public function test_incomplete_line_counts_profiles_recorded_before_the_period(): void
|
|
{
|
|
// Same seeding rule as the other two series: a profile recorded
|
|
// incomplete before the window still is one inside it.
|
|
$rows = $this->inactiveTimeline(
|
|
[],
|
|
'2020-01-01',
|
|
'2020-03-31',
|
|
[],
|
|
['2019-08' => 40, '2020-02' => 2]
|
|
);
|
|
|
|
$this->assertSame([40, 42, 42], array_column($rows, 'incomplete'));
|
|
$this->assertSame([0, 0, 0], array_column($rows, 'count'));
|
|
}
|
|
|
|
public function test_network_counts_every_transaction_once_like_volume(): void
|
|
{
|
|
// Dezelfde valkuil als test_volume_and_balance_are_not_the_same_quantity:
|
|
// de netwerkgraaf toont *volume*, dus de `THEN 0`-regel van balance()
|
|
// mag hier niet gelden. Met de fixture A<->B (60 + 30 intern) hoort de
|
|
// kant A-B 90 minuten te dragen, niet 0.
|
|
$service = $this->serviceForAB();
|
|
$network = $service->network(1);
|
|
|
|
$edge = collect($network['edges'])->first();
|
|
|
|
$this->assertNotNull($edge, 'A en B handelen met elkaar, dus er is een kant.');
|
|
$this->assertSame(90, $edge['volume'], 'Interne overboekingen tellen mee in volume.');
|
|
$this->assertSame(2, $edge['tx']);
|
|
|
|
// Twee knopen, elk met één handelspartner.
|
|
$this->assertCount(2, $network['nodes']);
|
|
foreach ($network['nodes'] as $node) {
|
|
$this->assertSame(1, $node['degree']);
|
|
}
|
|
}
|
|
|
|
public function test_network_threshold_drops_nodes_below_the_minimum(): void
|
|
{
|
|
// A en B hebben elk één partner, dus bij een drempel van 2 blijft er
|
|
// niets over. Zonder drempel is de graaf op productiedata onleesbaar:
|
|
// de mediane knoop heeft 2 partners en 46% van de kanten is eenmalig.
|
|
$service = $this->serviceForAB();
|
|
|
|
$this->assertCount(2, $service->network(1)['nodes']);
|
|
$this->assertCount(0, $service->network(2)['nodes']);
|
|
$this->assertCount(0, $service->network(2)['edges'], 'Een kant zonder beide knopen hoort weg te vallen.');
|
|
}
|
|
|
|
public function test_network_reports_what_the_threshold_hid(): void
|
|
{
|
|
// De UI moet eerlijk kunnen melden hoeveel er is weggelaten; anders
|
|
// lijkt een gefilterde graaf het hele netwerk.
|
|
$stats = $this->serviceForAB()->network(2)['stats'];
|
|
|
|
$this->assertSame(2, $stats['total_nodes']);
|
|
$this->assertSame(0, $stats['shown_nodes']);
|
|
$this->assertSame(2, $stats['min_degree']);
|
|
}
|
|
|
|
public function test_network_scope_excludes_profiles_outside_the_circle(): void
|
|
{
|
|
// Een bank ziet alleen haar eigen clients. Dit is geen UI-filter maar
|
|
// een server-side beperking: knopen buiten de scope horen niet in de
|
|
// payload te staan, want daar staan namen in.
|
|
$scope = [User::class . ':' . $this->userIds['stats_a']];
|
|
|
|
$network = $this->serviceForAB()->network(1, $scope);
|
|
|
|
$ids = array_column($network['nodes'], 'id');
|
|
|
|
// Zonder randverkeer is een kant pas zichtbaar als BEIDE einden client
|
|
// zijn. A handelt alleen met B, en B is geen client, dus niets over.
|
|
$this->assertNotContains(User::class . ':' . $this->userIds['stats_b'], $ids);
|
|
$this->assertCount(0, $network['edges']);
|
|
}
|
|
|
|
public function test_network_edge_mode_flags_outsiders_as_such(): void
|
|
{
|
|
// With edge traffic on the counterparty appears. It is named — that
|
|
// changed on the operator's instruction — but must stay flagged as an
|
|
// outsider so the UI can draw it as belonging to another network.
|
|
$scope = [User::class . ':' . $this->userIds['stats_a']];
|
|
|
|
$network = $this->serviceForAB()->network(1, $scope, edge: true);
|
|
|
|
$outside = collect($network['nodes'])->firstWhere('outside', true);
|
|
$inside = collect($network['nodes'])->firstWhere('outside', false);
|
|
|
|
$this->assertNotNull($outside, 'The counterparty outside the circle should be visible.');
|
|
$this->assertNotNull($inside, 'The client itself should be visible.');
|
|
|
|
$this->assertSame('stats_a', $inside['name']);
|
|
$this->assertSame('stats_b', $outside['name'], 'Outsiders are named now.');
|
|
$this->assertTrue($outside['outside'], 'The outsider flag drives the distinct styling.');
|
|
$this->assertArrayHasKey('bank', $outside);
|
|
}
|
|
|
|
public function test_anonymised_network_leaks_no_names_or_ids(): void
|
|
{
|
|
// De belangrijkste test van deze feature: wat gedeeld wordt mag geen
|
|
// identiteiten bevatten. Faalt deze, dan lekt de gedeelde weergave.
|
|
//
|
|
// De fixture moet knopen OPLEVEREN, anders is deze test leeg: met de
|
|
// standaarddrempel van 3 valt A<->B weg en loopt de lus hieronder over
|
|
// niets. Daarom een ster: D handelt met A, B en C, dus D haalt graad 3.
|
|
$hub = $this->makeUser('stats_hub');
|
|
foreach (['stats_a', 'stats_b', 'stats_c'] as $peer) {
|
|
DB::table('transactions')->insert([
|
|
'from_account_id' => $this->accountIds['stats_hub'],
|
|
'to_account_id' => $this->accountIds[$peer],
|
|
'amount' => 60,
|
|
'transaction_type_id' => 1,
|
|
'description' => 'anon fixture',
|
|
'created_at' => now()->subMonth(),
|
|
'updated_at' => now()->subMonth(),
|
|
]);
|
|
}
|
|
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subYears(5),
|
|
));
|
|
|
|
$network = $service->networkAnonymised();
|
|
$this->assertNotEmpty($network['nodes'], 'Without nodes the loop below tests nothing.');
|
|
|
|
$json = json_encode($network);
|
|
|
|
foreach (['stats_a', 'stats_b', 'stats_c', 'stats_hub'] as $name) {
|
|
$this->assertStringNotContainsString($name, $json, "Name {$name} must not appear in the anonymous payload.");
|
|
}
|
|
|
|
// The hub's raw key must not appear either.
|
|
$this->assertStringNotContainsString((string) $hub, $json === null ? '' : preg_replace('/n[0-9a-f]{10}/', '', $json));
|
|
|
|
$this->assertStringNotContainsString('App\\Models\\User', $json, 'No raw profile keys.');
|
|
|
|
foreach ($network['nodes'] as $node) {
|
|
$this->assertArrayNotHasKey('name', $node);
|
|
// Added when the tooltip started showing the real name under the
|
|
// username: a new name-bearing field must never reach this payload.
|
|
$this->assertArrayNotHasKey('full_name', $node);
|
|
$this->assertArrayNotHasKey('volume', $node, 'Exact volume is a fingerprint; only a band may travel.');
|
|
$this->assertMatchesRegularExpression('/^n[0-9a-f]{10}$/', $node['id']);
|
|
$this->assertContains($node['band'], ['low', 'mid', 'high']);
|
|
}
|
|
}
|
|
|
|
public function test_anonymised_network_enforces_its_own_minimum_degree(): void
|
|
{
|
|
// The threshold is a privacy requirement here: a node with a single
|
|
// edge is identifiable. A caller must not push it below the floor.
|
|
$network = $this->serviceForAB()->networkAnonymised(1);
|
|
|
|
// A en B hebben elk 1 partner, dus onder de ondergrens van 3: leeg.
|
|
$this->assertCount(0, $network['nodes']);
|
|
$this->assertSame(StatsService::MIN_ANONYMOUS_DEGREE, $network['stats']['min_degree']);
|
|
}
|
|
|
|
public function test_not_logged_in_filter_includes_profiles_that_never_logged_in(): void
|
|
{
|
|
// Een `last_login_at < X` zonder meer laat NULL stilzwijgend vallen,
|
|
// terwijl juist een profiel dat nooit inlogde het meest inactief is.
|
|
DB::table('users')->where('id', $this->userIds['stats_a'])->update(['last_login_at' => null]);
|
|
DB::table('users')->where('id', $this->userIds['stats_b'])->update(['last_login_at' => now()->subYears(3)]);
|
|
DB::table('users')->where('id', $this->userIds['stats_c'])->update(['last_login_at' => now()]);
|
|
|
|
$service = new StatsService(new StatsFilter(
|
|
registeredFrom: now()->subYears(5),
|
|
notLoggedInSince: now()->subYear(),
|
|
));
|
|
|
|
$ids = $service->profileKeys()[User::class] ?? [];
|
|
|
|
$this->assertContains($this->userIds['stats_a'], $ids, 'Nooit ingelogd hoort mee te tellen als inactief.');
|
|
$this->assertContains($this->userIds['stats_b'], $ids, 'Drie jaar geleden ingelogd is inactief.');
|
|
$this->assertNotContains($this->userIds['stats_c'], $ids, 'Vandaag ingelogd is niet inactief.');
|
|
}
|
|
|
|
public function test_no_transaction_filter_ignores_system_entries(): void
|
|
{
|
|
// Een migratie raakt elke rekening; telde die mee als activiteit, dan
|
|
// zou dit filter na zo'n draai niemand meer opleveren.
|
|
DB::table('transactions')->insert([
|
|
'from_account_id' => $this->accountIds['stats_c'],
|
|
'to_account_id' => $this->accountIds['stats_a'],
|
|
'amount' => 60,
|
|
'transaction_type_id' => 6, // Migration
|
|
'description' => 'systeem',
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
$service = new StatsService(new StatsFilter(
|
|
registeredFrom: now()->subYears(5),
|
|
noTransactionSince: now()->subDay(),
|
|
));
|
|
|
|
$ids = $service->profileKeys()[User::class] ?? [];
|
|
|
|
$this->assertContains(
|
|
$this->userIds['stats_a'],
|
|
$ids,
|
|
'Een migratieboeking van vandaag maakt een profiel niet actief.'
|
|
);
|
|
}
|
|
|
|
public function test_no_transaction_filter_excludes_real_recent_trade(): void
|
|
{
|
|
// De tegenproef: een echte transactie (Work) telt wel.
|
|
DB::table('transactions')->insert([
|
|
'from_account_id' => $this->accountIds['stats_c'],
|
|
'to_account_id' => $this->accountIds['stats_a'],
|
|
'amount' => 60,
|
|
'transaction_type_id' => 1, // Work
|
|
'description' => 'echt',
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
$service = new StatsService(new StatsFilter(
|
|
registeredFrom: now()->subYears(5),
|
|
noTransactionSince: now()->subDay(),
|
|
));
|
|
|
|
$ids = $service->profileKeys()[User::class] ?? [];
|
|
|
|
$this->assertNotContains($this->userIds['stats_a'], $ids);
|
|
$this->assertNotContains($this->userIds['stats_c'], $ids, 'Beide zijden van de transactie zijn actief.');
|
|
}
|
|
|
|
public function test_inactivity_filters_alone_make_the_selection_non_empty(): void
|
|
{
|
|
// Zonder dit zou de pagina in de lege staat blijven terwijl er wel
|
|
// degelijk een filter actief is.
|
|
$filter = new StatsFilter(notLoggedInSince: now()->subYear());
|
|
|
|
$this->assertFalse($filter->isEmpty());
|
|
$this->assertNotSame(
|
|
(new StatsFilter())->hash(),
|
|
$filter->hash(),
|
|
'De cachesleutel moet het filter meenemen.'
|
|
);
|
|
}
|
|
|
|
public function test_network_max_degree_excludes_hubs(): void
|
|
{
|
|
// Een ster: D handelt met A, B en C, die onderling niets doen. Met een
|
|
// bovengrens van 2 valt de hub weg — en daarmee de hele graaf, want de
|
|
// andere drie hebben alleen hém als partner.
|
|
$this->makeUser('stats_hub');
|
|
foreach (['stats_a', 'stats_b', 'stats_c'] as $peer) {
|
|
DB::table('transactions')->insert([
|
|
'from_account_id' => $this->accountIds['stats_hub'],
|
|
'to_account_id' => $this->accountIds[$peer],
|
|
'amount' => 60,
|
|
'transaction_type_id' => 1,
|
|
'description' => 'hub fixture',
|
|
'created_at' => now()->subMonth(),
|
|
'updated_at' => now()->subMonth(),
|
|
]);
|
|
}
|
|
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subYears(5),
|
|
));
|
|
|
|
$withHub = $service->network(1, null, false, null, null);
|
|
$hubDegrees = array_column($withHub['nodes'], 'degree');
|
|
|
|
$this->assertContains(3, $hubDegrees, 'De hub heeft drie partners.');
|
|
|
|
// Bovengrens 2: de hub verdwijnt.
|
|
$capped = $service->network(1, null, false, null, 2);
|
|
$this->assertNotContains(3, array_column($capped['nodes'], 'degree'));
|
|
$this->assertLessThan(count($withHub['nodes']), count($capped['nodes']));
|
|
}
|
|
|
|
public function test_network_ceiling_means_no_upper_bound(): void
|
|
{
|
|
// De bovenste stand van de schuif hoort "geen grens" te betekenen,
|
|
// anders zou een knoop met meer partners dan het plafond onzichtbaar
|
|
// worden juist wanneer de gebruiker alles wil zien.
|
|
//
|
|
// De fixture moet dus een knoop BOVEN het plafond bevatten, anders
|
|
// geven beide varianten sowieso hetzelfde en test dit niets.
|
|
$hub = $this->makeUser('stats_bighub');
|
|
$ceiling = StatsService::DEGREE_CEILING;
|
|
|
|
for ($i = 0; $i < $ceiling + 5; $i++) {
|
|
$peer = $this->makeUser('stats_peer_' . $i);
|
|
DB::table('transactions')->insert([
|
|
'from_account_id' => $this->accountIds['stats_bighub'],
|
|
'to_account_id' => $this->accountIds['stats_peer_' . $i],
|
|
'amount' => 60,
|
|
'transaction_type_id' => 1,
|
|
'description' => 'ceiling fixture',
|
|
'created_at' => now()->subMonth(),
|
|
'updated_at' => now()->subMonth(),
|
|
]);
|
|
}
|
|
|
|
$service = new StatsService(new StatsFilter(
|
|
periodFrom: now()->subMonths(3),
|
|
periodTill: now(),
|
|
registeredFrom: now()->subYears(5),
|
|
));
|
|
|
|
$unbounded = $service->network(1, null, false, null, null);
|
|
$atCeiling = $service->network(1, null, false, null, $ceiling);
|
|
|
|
// De hub zit boven het plafond en moet in BEIDE varianten zichtbaar zijn.
|
|
$this->assertContains(
|
|
$ceiling + 5,
|
|
array_column($unbounded['nodes'], 'degree'),
|
|
'Zonder grens hoort de hub er te staan.'
|
|
);
|
|
$this->assertSame(count($unbounded['nodes']), count($atCeiling['nodes']));
|
|
$this->assertSame(count($unbounded['edges']), count($atCeiling['edges']));
|
|
}
|
|
}
|