[], 'coruna.panel.agent_hosts' => [], ]); } #[Test] public function page_and_menu_are_available(): void { $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $html = $this->actingAs($admin, 'admin') ->get(route('admin.reports.analytics')) ->assertOk() ->assertSee('数据分析') ->assertSee('设备存活时长') ->assertSee('有钱包设备存活时长') ->assertDontSee('各版本受控比例') ->assertSee('访问 · 按系统') ->assertSee('访问版本明细') ->assertSee('受控设备版本明细') ->assertSee('an-os-chart') ->assertSee('an-ios-chart') ->assertSee('an-device-chart') ->assertSee('an-tag') ->assertSee('echarts') ->getContent(); $this->assertSame(1, substr_count($html, '有钱包设备存活时长均值')); $this->assertSame(1, substr_count($html, 'id="an-survival-wallet"')); } #[Test] public function data_reads_rollup_and_computes_control_rate(): void { $this->seedVisitsAndDevices(); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $payload = $this->actingAs($admin, 'admin') ->getJson(route('admin.reports.analytics.data', [ 'date_from' => now()->toDateString(), 'date_to' => now()->toDateString(), ])) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.from', now()->toDateString()) ->assertJsonPath('data.to', now()->toDateString()) ->json('data'); $this->assertSame(2, $payload['survival']['devices']); $this->assertSame(3600, $payload['survival']['avg_seconds']); $this->assertSame('1 小时', $payload['survival']['avg_label']); $this->assertSame(1, $payload['survival_wallet']['devices']); $this->assertSame(7200, $payload['survival_wallet']['avg_seconds']); $this->assertSame('2 小时', $payload['survival_wallet']['avg_label']); $this->assertSame('iOS', $payload['by_os'][0]['label']); $this->assertSame(10, $payload['by_os'][0]['uv']); $this->assertSame('其他', $payload['by_os'][1]['label']); $this->assertSame(1, $payload['by_os'][1]['uv']); $visit16 = $this->rowByLabel($payload['by_ios_version'], '16.6'); $this->assertNotNull($visit16); $this->assertSame(10, $visit16['uv']); $device16 = $this->rowByLabel($payload['by_device_ios_version'], '16.6'); $this->assertNotNull($device16); $this->assertSame(1, $device16['count']); $control16 = $this->rowByLabel($payload['control_by_version'], '16.6'); $this->assertNotNull($control16); $this->assertSame(10, $control16['effective_uv']); $this->assertSame(1, $control16['devices']); $this->assertEquals(10, $control16['pct']); $this->assertGreaterThanOrEqual(1, AnalyticsDailyDim::query() ->where('kind', AnalyticsDailyDim::KIND_SURVIVAL) ->count()); } #[Test] public function second_read_does_not_rebuild_when_fresh(): void { $this->seedVisitsAndDevices(); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->getJson(route('admin.reports.analytics.data', [ 'date_from' => now()->toDateString(), 'date_to' => now()->toDateString(), ])) ->assertOk(); $id = AnalyticsDailyDim::query() ->where('kind', AnalyticsDailyDim::KIND_SURVIVAL) ->where('stat_date', now()->toDateString()) ->value('id'); $this->assertNotNull($id); AnalyticsDailyDim::query()->whereKey($id)->update(['seconds_sum' => 999]); $payload = $this->actingAs($admin, 'admin') ->getJson(route('admin.reports.analytics.data', [ 'date_from' => now()->toDateString(), 'date_to' => now()->toDateString(), ])) ->json('data'); $this->assertSame(500, (int) round($payload['survival']['avg_seconds'])); } #[Test] public function agent_scope_only_sees_own_channels(): void { $agent = User::query()->create(['username' => 'ag-an', '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]); $this->seedVisitsAndDevices(); PageVisit::query()->create([ 'channel_id' => self::OTHER, 'client_uid' => 'other-uv', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'created_at' => now(), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $mine = $this->actingAs($admin, 'admin') ->getJson(route('admin.reports.analytics.data', [ 'date_from' => now()->toDateString(), 'date_to' => now()->toDateString(), 'agent_user_id' => $agent->id, ])) ->assertOk() ->json('data'); $this->assertSame(10, $this->rowByLabel($mine['by_ios_version'], '16.6')['uv']); } private function seedVisitsAndDevices(): void { Channel::query()->firstOrCreate( ['channel_id' => self::CHANNEL], ['user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1], ); for ($i = 1; $i <= 10; $i++) { PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'eff-'.$i, 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'created_at' => now(), ]); } PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'android-1', 'os' => 'Android', 'os_version' => '13', 'browser' => 'Chrome', 'created_at' => now(), ]); $alive = Device::query()->create([ 'device_id' => 'dev-alive', 'channel_id' => self::CHANNEL, 'ios_version' => '16.6', 'has_wallet' => Device::WALLET_YES, ]); $short = Device::query()->create([ 'device_id' => 'dev-short', 'channel_id' => self::CHANNEL, 'ios_version' => '18.5', 'has_wallet' => Device::WALLET_NONE, ]); $aliveEnd = now(); $aliveStart = $aliveEnd->copy()->subHours(2); if ($aliveStart->lt($aliveEnd->copy()->startOfDay())) { $aliveStart = $aliveEnd->copy()->startOfDay(); $aliveEnd = $aliveStart->copy()->addHours(2); } DB::table('devices')->where('id', $alive->id)->update([ 'created_at' => $aliveStart, 'updated_at' => $aliveEnd, ]); DB::table('devices')->where('id', $short->id)->update([ 'created_at' => now(), 'updated_at' => now(), ]); } /** * @param list $rows * @return array|null */ private function rowByLabel(array $rows, string $label): ?array { foreach ($rows as $row) { if (($row['label'] ?? '') === $label) { return $row; } } return null; } }