[], 'coruna.panel.agent_hosts' => [], ]); } #[Test] public function schedule_includes_daily_stats(): void { $this->artisan('schedule:list') ->expectsOutputToContain('coruna:daily-stats') ->assertSuccessful(); } #[Test] public function admin_page_matches_dashboard_today_and_lists_thirty_days(): void { $this->seedTodayVisitsAndDevices(); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $dash = app(DashboardStatsService::class)->collect(['range' => 'today']); $this->actingAs($admin, 'admin') ->get(route('admin.reports.daily')) ->assertOk() ->assertSee('按日期统计') ->assertSee('总 UV') ->assertSee('有效 UV') ->assertSee('受控设备') ->assertSee('受控比例') ->assertSee('有钱包设备') ->assertSee('钱包地址') ->assertSee('助记词数') ->assertSee('近 30 天') ->assertSee('LAY-daily-date-range') ->assertSee('LAY-daily-report') ->assertSee('LAY-daily-report-chart') ->assertSee('echarts') ->assertSee("'受控设备': true", false) ->assertSee('LAY-daily-search') ->assertSee('代理用户') ->assertSee(now()->toDateString()) ->assertDontSee('最近30天'); $payload = $this->actingAs($admin, 'admin') ->getJson(route('admin.reports.daily.data')) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.from', now()->subDays(29)->toDateString()) ->assertJsonPath('data.to', now()->toDateString()) ->json('data'); $this->assertCount(30, $payload['rows']); $this->assertSame(now()->toDateString(), $payload['rows'][0]['date']); $this->assertSame($dash['uv'], $payload['rows'][0]['uv']); $this->assertSame($dash['effective_uv'], $payload['rows'][0]['effective_uv']); $this->assertSame($dash['total'], $payload['rows'][0]['devices']); $this->assertEquals(round($dash['total'] / $dash['effective_uv'] * 100, 1), $payload['rows'][0]['control_rate']); $this->assertSame($dash['wallet_count'], $payload['rows'][0]['wallet_devices']); $this->assertSame($dash['address_count'], $payload['rows'][0]['address_count']); $this->assertSame($dash['mnemonic_count'], $payload['rows'][0]['mnemonic_count']); $this->assertNull($payload['rows'][1]['control_rate']); $this->assertSame(30, DailyStat::query()->where('scope_key', DailyStat::SCOPE_ALL)->count()); } #[Test] public function data_accepts_custom_date_range(): void { $this->seedTodayVisitsAndDevices(); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $from = now()->subDays(2)->toDateString(); $to = now()->toDateString(); $payload = $this->actingAs($admin, 'admin') ->getJson(route('admin.reports.daily.data', [ 'date_from' => $from, 'date_to' => $to, ])) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.from', $from) ->assertJsonPath('data.to', $to) ->json('data'); $this->assertCount(3, $payload['rows']); $this->assertSame($to, $payload['rows'][0]['date']); $this->assertSame($from, $payload['rows'][2]['date']); } #[Test] public function data_can_span_more_than_thirty_days(): void { $old = now()->subDays(40); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'old-uv', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'created_at' => $old, ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $payload = $this->actingAs($admin, 'admin') ->getJson(route('admin.reports.daily.data', [ 'date_from' => $old->toDateString(), 'date_to' => now()->toDateString(), ])) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.from', $old->toDateString()) ->assertJsonPath('data.to', now()->toDateString()) ->json('data'); $this->assertCount(41, $payload['rows']); $oldRow = collect($payload['rows'])->firstWhere('date', $old->toDateString()); $this->assertNotNull($oldRow); $this->assertSame(1, $oldRow['uv']); } #[Test] public function command_rebuilds_historical_day(): void { $yesterday = now()->subDay(); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'hist-1', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'created_at' => $yesterday, ]); $device = Device::query()->create([ 'device_id' => 'hist-dev', 'channel_id' => self::CHANNEL, 'has_wallet' => Device::WALLET_YES, ]); \Illuminate\Support\Facades\DB::table('devices')->where('id', $device->id)->update([ 'created_at' => $yesterday->toDateTimeString(), 'updated_at' => $yesterday->toDateTimeString(), ]); $addr = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'THistDailyAddr1111111111111111111', 'chain_type' => 'TRON', ]); \Illuminate\Support\Facades\DB::table('wallet_addresses')->where('id', $addr->id)->update([ 'created_at' => $yesterday->toDateTimeString(), 'updated_at' => $yesterday->toDateTimeString(), ]); $mnemonic = new WalletMnemonic([ 'device_id' => $device->id, 'source' => 'imToken', ]); $mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; $mnemonic->save(); \Illuminate\Support\Facades\DB::table('wallet_mnemonics')->where('id', $mnemonic->id)->update([ 'created_at' => $yesterday->toDateTimeString(), 'updated_at' => $yesterday->toDateTimeString(), ]); $this->artisan('coruna:daily-stats', ['--days' => 2]) ->expectsOutputToContain('daily-stats rebuilt=2') ->assertSuccessful(); $row = DailyStat::query() ->where('scope_key', DailyStat::SCOPE_ALL) ->where('stat_date', $yesterday->toDateString()) ->first(); $this->assertNotNull($row); $this->assertSame(1, $row->uv); $this->assertSame(1, $row->effective_uv); $this->assertSame(1, $row->devices); $this->assertSame(1, $row->wallet_devices); $this->assertSame(1, $row->address_count); $this->assertSame(1, $row->mnemonic_count); } #[Test] public function command_all_backfills_from_earliest_row(): void { $old = now()->subDays(40); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'old-uv', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'created_at' => $old, ]); $this->artisan('coruna:daily-stats', ['--all' => true]) ->expectsOutputToContain('daily-stats rebuilt=') ->assertSuccessful(); $row = DailyStat::query() ->where('scope_key', DailyStat::SCOPE_ALL) ->where('stat_date', $old->toDateString()) ->first(); $this->assertNotNull($row); $this->assertSame(1, $row->uv); } #[Test] public function command_skips_fresh_historical_days_unless_forced(): void { $this->artisan('coruna:daily-stats', ['--days' => 5]) ->expectsOutputToContain('daily-stats rebuilt=5') ->assertSuccessful(); $this->artisan('coruna:daily-stats', ['--days' => 5]) ->expectsOutputToContain('daily-stats rebuilt=2') ->assertSuccessful(); $this->artisan('coruna:daily-stats', ['--days' => 5, '--force' => true]) ->expectsOutputToContain('daily-stats rebuilt=5') ->assertSuccessful(); } #[Test] public function agent_only_sees_own_channel(): void { $agent = User::query()->create(['username' => 'rep-agent', 'password' => 'secret12', 'status' => 1]); Channel::query()->create(['channel_id' => self::CHANNEL, 'user_id' => $agent->id, 'status' => 1]); Channel::query()->create(['channel_id' => self::OTHER, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'mine', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'created_at' => now(), ]); PageVisit::query()->create([ 'channel_id' => self::OTHER, 'client_uid' => 'other', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'created_at' => now(), ]); Device::query()->create([ 'device_id' => 'mine-dev', 'channel_id' => self::CHANNEL, 'has_wallet' => Device::WALLET_YES, ]); Device::query()->create([ 'device_id' => 'other-dev', 'channel_id' => self::OTHER, 'has_wallet' => Device::WALLET_YES, ]); $this->actingAs($agent, 'agent') ->get(route('user.reports.daily')) ->assertOk() ->assertSee('按日期统计') ->assertSee('近 30 天') ->assertDontSee('代理用户'); $payload = $this->actingAs($agent, 'agent') ->getJson(route('user.reports.daily.data')) ->assertOk() ->assertJsonPath('code', 0) ->json('data'); $this->assertSame(1, $payload['rows'][0]['uv']); $this->assertSame(1, $payload['rows'][0]['devices']); $this->assertEquals(100, $payload['rows'][0]['control_rate']); $this->assertSame(1, $payload['rows'][0]['wallet_devices']); $this->assertSame(30, DailyStat::query()->where('scope_key', 'a:'.$agent->id)->count()); $this->assertSame(0, (int) DailyStat::query()->where('scope_key', DailyStat::SCOPE_ALL)->sum('uv')); } #[Test] public function admin_can_filter_by_agent(): void { $agent = User::query()->create(['username' => 'filter-agent', 'password' => 'secret12', 'status' => 1]); Channel::query()->create(['channel_id' => self::CHANNEL, 'user_id' => $agent->id, 'status' => 1]); Channel::query()->create(['channel_id' => self::OTHER, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'mine', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'created_at' => now(), ]); PageVisit::query()->create([ 'channel_id' => self::OTHER, 'client_uid' => 'official', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'created_at' => now(), ]); Device::query()->create([ 'device_id' => 'mine-dev', 'channel_id' => self::CHANNEL, 'has_wallet' => Device::WALLET_YES, ]); Device::query()->create([ 'device_id' => 'official-dev', 'channel_id' => self::OTHER, 'has_wallet' => Device::WALLET_NONE, ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $all = $this->actingAs($admin, 'admin') ->getJson(route('admin.reports.daily.data')) ->assertOk() ->json('data.rows.0'); $this->assertSame(2, $all['uv']); $this->assertSame(2, $all['devices']); $this->assertSame(1, $all['wallet_devices']); $mine = $this->actingAs($admin, 'admin') ->getJson(route('admin.reports.daily.data', ['agent_user_id' => $agent->id])) ->assertOk() ->json('data.rows.0'); $this->assertSame(1, $mine['uv']); $this->assertSame(1, $mine['devices']); $this->assertSame(1, $mine['wallet_devices']); $official = $this->actingAs($admin, 'admin') ->getJson(route('admin.reports.daily.data', ['agent_user_id' => 0])) ->assertOk() ->json('data.rows.0'); $this->assertSame(1, $official['uv']); $this->assertSame(1, $official['devices']); $this->assertSame(0, $official['wallet_devices']); } #[Test] public function command_rebuilds_agent_scopes(): void { $agent = User::query()->create(['username' => 'cron-agent', 'password' => 'secret12', 'status' => 1]); Channel::query()->create(['channel_id' => self::CHANNEL, 'user_id' => $agent->id, 'status' => 1]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'cron-uv', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'created_at' => now(), ]); $this->artisan('coruna:daily-stats', ['--days' => 1]) ->expectsOutputToContain('rebuilt=1 agents=1') ->assertSuccessful(); $row = DailyStat::query() ->where('scope_key', 'a:'.$agent->id) ->where('stat_date', now()->toDateString()) ->first(); $this->assertNotNull($row); $this->assertSame(1, $row->uv); } #[Test] public function home_menu_includes_daily_report(): void { $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->get(route('admin.home')) ->assertOk() ->assertSee('每日报表') ->assertSee('数据分析') ->assertSee(route('admin.reports.daily'), false) ->assertSee(route('admin.reports.analytics'), false); } private function seedTodayVisitsAndDevices(): void { PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'u1', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'created_at' => now(), ]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'u1', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'created_at' => now(), ]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'u2', 'os' => 'Android', 'os_version' => '13', 'browser' => 'Chrome', 'created_at' => now(), ]); $wallet = Device::query()->create([ 'device_id' => 'dev-wallet', 'channel_id' => self::CHANNEL, 'has_wallet' => Device::WALLET_YES, ]); Device::query()->create([ 'device_id' => 'dev-empty', 'channel_id' => self::CHANNEL, 'has_wallet' => Device::WALLET_NONE, ]); WalletAddress::query()->create([ 'device_id' => $wallet->id, 'address' => 'TDailyReportAddr11111111111111111', 'chain_type' => 'TRON', ]); $mnemonic = new WalletMnemonic([ 'device_id' => $wallet->id, 'source' => 'imToken', ]); $mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; $mnemonic->save(); } }