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