Files

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']));
}
}