call('GET', '/statistic/t', [ 'c' => $channel, 'u' => '11111111-2222-4333-8444-555555555555', 'p' => '/channel/'.$channel.'/weifile/weifile.html', ])->assertOk(); $row = PageVisit::query()->first(); $this->assertNotNull($row); $this->assertSame($channel, $row->channel_id); } #[Test] public function hit_records_visit_with_parsed_ua(): void { Cache::flush(); $ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1'; $this->call('GET', '/statistic/t', [ 'c' => self::CHANNEL, 'u' => '11111111-2222-4333-8444-555555555555', 'p' => '/web/'.self::CHANNEL.'/support.html', 'r' => 'https://example.com/from', ], [], [], [ 'HTTP_USER_AGENT' => $ua, ])->assertOk() ->assertHeader('Content-Type', 'image/gif'); $row = PageVisit::query()->first(); $this->assertNotNull($row); $this->assertSame(self::CHANNEL, $row->channel_id); $this->assertSame(PageVisit::CHAIN_CORUNA, $row->chain); $this->assertSame('Coruna', PageVisit::chainLabel((int) $row->chain)); $this->assertSame(PageVisit::visitorUid($row->domain ?? 'localhost', $row->ip), $row->client_uid); $this->assertSame(PageVisit::VISITOR_UID_LENGTH, strlen($row->client_uid)); $this->assertSame('iOS', $row->os); $this->assertSame('16.6', $row->os_version); $this->assertSame('Safari', $row->browser); $this->assertSame('16.6', $row->browser_version); $this->assertSame($ua, $row->user_agent); $this->assertSame('https://example.com/from', $row->referer); } #[Test] public function hit_marks_darksword_only_for_allowlisted_ios(): void { $cases = [ ['17_2_1', PageVisit::CHAIN_CORUNA, '17.2.1'], ['17_2_2', PageVisit::CHAIN_CORUNA, '17.2.2'], ['18_0', PageVisit::CHAIN_CORUNA, '18.0'], ['18_1_1', PageVisit::CHAIN_DARKSWORD, '18.1.1'], ['18_4', PageVisit::CHAIN_DARKSWORD, '18.4'], ['18_4_1', PageVisit::CHAIN_DARKSWORD, '18.4.1'], ['18_5', PageVisit::CHAIN_DARKSWORD, '18.5'], ['18_5_1', PageVisit::CHAIN_CORUNA, '18.5.1'], ['18_6', PageVisit::CHAIN_DARKSWORD, '18.6'], ['18_6_1', PageVisit::CHAIN_DARKSWORD, '18.6.1'], ['18_6_2', PageVisit::CHAIN_DARKSWORD, '18.6.2'], ['18_6_3', PageVisit::CHAIN_CORUNA, '18.6.3'], ['18_7', PageVisit::CHAIN_DARKSWORD, '18.7'], ['18_7_1', PageVisit::CHAIN_DARKSWORD, '18.7.1'], ['18_7_2', PageVisit::CHAIN_DARKSWORD, '18.7.2'], ['18_7_3', PageVisit::CHAIN_CORUNA, '18.7.3'], ]; foreach ($cases as [$token, $chain, $osVersion]) { Cache::flush(); PageVisit::query()->delete(); $ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS '.$token.' like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/'.$osVersion.' Mobile/15E148 Safari/604.1'; $this->call('GET', '/statistic/t', [ 'c' => self::CHANNEL, 'u' => '11111111-2222-4333-8444-555555555555', ], [], [], [ 'HTTP_USER_AGENT' => $ua, ])->assertOk(); $row = PageVisit::query()->first(); $this->assertNotNull($row, $osVersion); $this->assertSame($osVersion, $row->os_version, $osVersion); $this->assertSame($chain, $row->chain, $osVersion); } } #[Test] public function hit_uses_x_forwarded_for_when_proxies_trusted(): void { Cache::flush(); $this->call('GET', '/statistic/t', [ 'c' => self::CHANNEL, 'u' => '11111111-2222-4333-8444-555555555555', ], [], [], [ 'REMOTE_ADDR' => '104.16.1.1', 'HTTP_X_FORWARDED_FOR' => '203.0.113.50', ])->assertOk(); $row = PageVisit::query()->first(); $this->assertNotNull($row); $this->assertSame('203.0.113.50', $row->ip); } #[Test] public function hit_prefers_cf_connecting_ip_over_x_forwarded_for(): void { Cache::flush(); $this->call('GET', '/statistic/t', [ 'c' => self::CHANNEL, 'u' => '11111111-2222-4333-8444-555555555555', ], [], [], [ 'REMOTE_ADDR' => '104.16.1.1', 'HTTP_X_FORWARDED_FOR' => '1.2.3.4', 'HTTP_CF_CONNECTING_IP' => '203.0.113.88', ])->assertOk(); $row = PageVisit::query()->first(); $this->assertNotNull($row); $this->assertSame('203.0.113.88', $row->ip); } #[Test] public function hit_stores_cf_ipcountry(): void { Cache::flush(); $this->call('GET', '/statistic/t', [ 'c' => self::CHANNEL, 'u' => '11111111-2222-4333-8444-555555555555', ], [], [], [ 'HTTP_CF_IPCOUNTRY' => 'cn', ])->assertOk(); $row = PageVisit::query()->first(); $this->assertNotNull($row); $this->assertSame('CN', $row->country); } #[Test] public function hit_ignores_invalid_cf_ipcountry(): void { Cache::flush(); $this->call('GET', '/statistic/t', [ 'c' => self::CHANNEL, 'u' => '11111111-2222-4333-8444-555555555555', ], [], [], [ 'HTTP_CF_IPCOUNTRY' => 'China', ])->assertOk(); $row = PageVisit::query()->first(); $this->assertNotNull($row); $this->assertNull($row->country); } #[Test] public function ds_register_fills_country_on_merged_visit(): void { Cache::flush(); $channel = '3.1.07'; $this->call('GET', '/statistic/t', [ 'c' => $channel, 'u' => '11111111-2222-4333-8444-555555555555', ])->assertOk(); $this->assertNull(PageVisit::query()->first()?->country); $this->postJson('/api/ds/device/register', [ 'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E', 'channelCode' => $channel, 'ios' => '18.5', 'chain' => 'darksword', ], [ 'CF-IPCountry' => 'US', ])->assertOk(); $this->assertSame(1, PageVisit::query()->count()); $this->assertSame('US', PageVisit::query()->first()?->country); } #[Test] public function hit_uses_referer_header_when_query_missing(): void { Cache::flush(); $this->call('GET', '/statistic/t', [ 'c' => self::CHANNEL, 'u' => '11111111-2222-4333-8444-555555555555', ], [], [], [ 'HTTP_REFERER' => 'https://search.example/q=ios', ])->assertOk(); $row = PageVisit::query()->first(); $this->assertNotNull($row); $this->assertSame('https://search.example/q=ios', $row->referer); } #[Test] public function canonical_ios_version_strips_trailing_zero_patch(): void { $this->assertSame('18.5', PageVisit::canonicalIosVersion('18.5')); $this->assertSame('18.5', PageVisit::canonicalIosVersion('18.5.0')); $this->assertSame('18.6.1', PageVisit::canonicalIosVersion('18.6.1')); $this->assertTrue(PageVisit::isDarkSwordIosVersionString('18.5.0')); $this->assertFalse(PageVisit::isDarkSwordIosVersionString('18.5.1')); } public function visitor_uid_is_stable_for_domain_and_ip(): void { $a = PageVisit::visitorUid('XXBB.TV', '203.0.113.9'); $b = PageVisit::visitorUid('https://xxbb.tv/path', '203.0.113.9'); $c = PageVisit::visitorUid('xxbb.tv', '203.0.113.10'); $d = PageVisit::visitorUid('other.tv', '203.0.113.9'); $this->assertSame(PageVisit::VISITOR_UID_LENGTH, strlen($a)); $this->assertSame($a, $b); $this->assertNotSame($a, $c); $this->assertNotSame($a, $d); } #[Test] public function statistic_and_ds_register_merge_into_one_visit(): void { Cache::flush(); $channel = '3.1.07'; $ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'; $this->call('GET', '/statistic/t', [ 'c' => $channel, 'u' => '11111111-2222-4333-8444-555555555555', 'p' => '/channel/'.$channel.'/weifile/weifile.html', ], [], [], [ 'HTTP_USER_AGENT' => $ua, ])->assertOk(); $this->postJson('/api/ds/device/register', [ 'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E', 'channelCode' => $channel, 'ios' => '18.5', 'chain' => 'darksword', 'user_agent' => $ua, ])->assertOk(); $this->assertSame(1, PageVisit::query()->count()); $row = PageVisit::query()->first(); $this->assertSame($channel, $row->channel_id); $this->assertSame(PageVisit::CHAIN_DARKSWORD, $row->chain); $this->assertSame('50624FE26CC4A0DF689EAEA117557C3E', $row->client_uid); $this->assertSame('18.5', $row->os_version); } #[Test] public function statistic_after_ds_register_does_not_add_a_second_visit(): void { Cache::flush(); $channel = '3.1.07'; $this->postJson('/api/ds/device/register', [ 'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E', 'channelCode' => $channel, 'ios' => '18.5', 'chain' => 'darksword', ])->assertOk(); $this->call('GET', '/statistic/t', [ 'c' => $channel, 'u' => '11111111-2222-4333-8444-555555555555', ])->assertOk(); $this->assertSame(1, PageVisit::query()->count()); $row = PageVisit::query()->first(); $this->assertSame(PageVisit::CHAIN_DARKSWORD, $row->chain); $this->assertSame('50624FE26CC4A0DF689EAEA117557C3E', $row->client_uid); } #[Test] public function hit_debounces_duplicate_uid(): void { Cache::flush(); $params = [ 'c' => self::CHANNEL, 'u' => 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', ]; $this->get('/statistic/t?'.http_build_query($params))->assertOk(); $this->get('/statistic/t?'.http_build_query($params))->assertOk(); $this->assertSame(1, PageVisit::query()->count()); } #[Test] public function hit_rejects_bad_channel(): void { Cache::flush(); $this->get('/statistic/t?c=not-a-channel&u=11111111-2222-4333-8444-555555555555')->assertOk(); $this->assertSame(0, PageVisit::query()->count()); } #[Test] public function hit_queues_record_when_queue_is_not_sync(): void { Cache::flush(); Queue::fake(); config(['queue.default' => 'redis']); $this->call('GET', '/statistic/t', [ 'c' => self::CHANNEL, 'u' => '11111111-2222-4333-8444-555555555555', ])->assertOk(); $this->assertSame(0, PageVisit::query()->count()); Queue::assertPushed(RecordPageHit::class, function (RecordPageHit $job) { return ($job->attrs['channel_id'] ?? null) === self::CHANNEL; }); } #[Test] public function show_alias_serves_channel_details_without_store_cache(): void { $ver = '8.8.88'; $dir = public_path('channel/'.$ver.'/details'); File::ensureDirectoryExists($dir); file_put_contents($dir.'/show.html', 'SHOW_ALIAS_BODY'); try { $response = $this->get('/c/'.$ver.'/show.htm') ->assertOk() ->assertHeader('Content-Type', 'application/octet-stream') ->assertHeader('Cache-Control', 'max-age=60, public'); $this->assertSame('SHOW_ALIAS_BODY', $response->streamedContent()); } finally { File::deleteDirectory(public_path('channel/'.$ver)); } } #[Test] public function user_agent_parser_handles_chrome_ios(): void { $parsed = UserAgentParser::parse( 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.109 Mobile/15E148 Safari/604.1' ); $this->assertSame('iOS', $parsed['os']); $this->assertSame('17.0', $parsed['os_version']); $this->assertSame('Chrome', $parsed['browser']); $this->assertSame('119.0.6045', $parsed['browser_version']); } #[Test] public function user_agent_parser_keeps_ios_patch(): void { $parsed = UserAgentParser::parse( 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1' ); $this->assertSame('iOS', $parsed['os']); $this->assertSame('15.8.4', $parsed['os_version']); $this->assertSame('Safari', $parsed['browser']); $this->assertSame('15.6.7', $parsed['browser_version']); } #[Test] public function user_agent_parser_maps_ios26_compat_ua(): void { $parsed = UserAgentParser::parse( 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Mobile/15E148 Safari/604.1' ); $this->assertSame('iOS', $parsed['os']); $this->assertSame('26.6', $parsed['os_version']); $this->assertSame('Safari', $parsed['browser']); $this->assertSame('26.6', $parsed['browser_version']); } #[Test] public function user_agent_parser_keeps_real_ios_18_7(): void { $parsed = UserAgentParser::parse( 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1' ); $this->assertSame('iOS', $parsed['os']); $this->assertSame('18.7', $parsed['os_version']); $this->assertSame('Safari', $parsed['browser']); $this->assertSame('18.7', $parsed['browser_version']); } #[Test] public function dashboard_includes_pv_uv(): void { Cache::flush(); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'u1', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'browser_version' => '16.6', 'created_at' => now(), ]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'u1', 'os' => 'iOS', 'os_version' => '16.6', 'browser' => 'Safari', 'browser_version' => '16.6', 'created_at' => now(), ]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'u2', 'os' => 'Android', 'os_version' => '13', 'browser' => 'Chrome', 'browser_version' => '120.0.0', 'created_at' => now(), ]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'u3', 'os' => 'iOS', 'os_version' => '17.2.1', 'browser' => 'Safari', 'browser_version' => '17.2', 'created_at' => now(), ]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'u4', 'os' => 'iOS', 'os_version' => '16.0', 'browser' => 'Chrome', 'browser_version' => '119.0', 'created_at' => now(), ]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'u5', 'os' => 'iOS', 'os_version' => '17.3', 'browser' => 'Safari', 'browser_version' => '17.3', 'created_at' => now(), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->getJson(route('admin.dashboard.data', ['range' => 'today'])) ->assertOk() ->assertJsonPath('data.pv', 6) ->assertJsonPath('data.uv', 5) // effective: two 16.6 Safari (u1) + one 17.2.1 Safari (u3); exclude Android/Chrome/17.3 ->assertJsonPath('data.effective_pv', 3) ->assertJsonPath('data.effective_uv', 2) ->assertJsonStructure(['data' => ['pv', 'uv', 'effective_pv', 'effective_uv', 'total', 'wallet_count', 'balances', 'mnemonic_count', 'address_count']]); } #[Test] public function dashboard_effective_pv_uses_new_ios_bands(): void { Cache::flush(); $rows = [ ['12.5.7', false], ['13.0', true], ['15.8.7', true], ['15.8.8', false], ['15.8.9', true], ['16.7.1', true], ['16.7.9', true], ['16.7.10', false], ['16.7.12', false], ['17.2.1', true], ['17.2.2', false], ['17.3', false], ['18.2', false], ['18.2.1', false], ['18.1.1', true], ['18.4', true], ['18.4.1', true], ['18.5', true], ['18.5.1', false], ['18.6', true], ['18.6.1', true], ['18.6.2', true], ['18.6.3', false], ['18.7', true], ['18.7.1', true], ['18.7.2', true], ['18.7.3', false], ]; foreach ($rows as $i => [$ver, $_]) { PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'band'.$i, 'os' => 'iOS', 'os_version' => $ver, 'browser' => 'Safari', 'created_at' => now(), ]); } PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'chrome185', 'os' => 'iOS', 'os_version' => '18.5', 'browser' => 'Chrome', 'created_at' => now(), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->getJson(route('admin.dashboard.data', ['range' => 'today'])) ->assertOk() ->assertJsonPath('data.pv', count($rows) + 1) ->assertJsonPath('data.effective_pv', count(array_filter($rows, static fn ($r) => $r[1]))); } #[Test] public function visits_data_labels_chain(): void { PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => '50624FE26CC4A0DF689EAEA117557C3E', 'chain' => PageVisit::CHAIN_DARKSWORD, 'os' => 'iOS', 'os_version' => '18.6', 'browser' => 'Safari', 'created_at' => now(), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->getJson(route('admin.visits.data', ['range' => 'today'])) ->assertOk() ->assertJsonPath('data.0.chain', 1) ->assertJsonPath('data.0.chain_label', 'DarkSword') ->assertJsonPath('data.0.client_uid', '50624FE26CC4A0DF689EAEA117557C3E'); } #[Test] public function visits_data_labels_and_filters_country(): void { PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'aaaaaaaaaaaaaaaa', 'chain' => PageVisit::CHAIN_CORUNA, 'os' => 'iOS', 'ip' => '1.2.3.4', 'country' => 'CN', 'created_at' => now(), ]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'bbbbbbbbbbbbbbbb', 'chain' => PageVisit::CHAIN_CORUNA, 'os' => 'iOS', 'ip' => '5.6.7.8', 'country' => 'US', 'created_at' => now(), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->getJson(route('admin.visits.data', ['range' => 'today'])) ->assertOk() ->assertJsonPath('count', 2); $this->actingAs($admin, 'admin') ->getJson(route('admin.visits.data', ['range' => 'today', 'country' => 'cn'])) ->assertOk() ->assertJsonPath('count', 1) ->assertJsonPath('data.0.country', 'CN') ->assertJsonPath('data.0.country_label', '中国'); } #[Test] public function visits_data_filters_country_by_chinese_name(): void { PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'aaaaaaaaaaaaaaaa', 'chain' => PageVisit::CHAIN_CORUNA, 'os' => 'iOS', 'ip' => '1.2.3.4', 'country' => 'CN', 'created_at' => now(), ]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'bbbbbbbbbbbbbbbb', 'chain' => PageVisit::CHAIN_CORUNA, 'os' => 'iOS', 'ip' => '5.6.7.8', 'country' => 'US', 'created_at' => now(), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); // Chinese name resolves to the same ISO code as the stored value. $this->actingAs($admin, 'admin') ->getJson(route('admin.visits.data', ['range' => 'today', 'country' => '中国'])) ->assertOk() ->assertJsonPath('count', 1) ->assertJsonPath('data.0.country', 'CN') ->assertJsonPath('data.0.country_label', '中国'); // Unrecognized text is ignored (no filter applied), consistent with // how invalid ISO codes are treated. $this->actingAs($admin, 'admin') ->getJson(route('admin.visits.data', ['range' => 'today', 'country' => '不存在的国家'])) ->assertOk() ->assertJsonPath('count', 2); } #[Test] public function visits_data_does_not_filter_chain_until_explicitly_chosen(): void { PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => 'aaaaaaaaaaaaaaaa', 'chain' => PageVisit::CHAIN_CORUNA, 'os' => 'iOS', 'created_at' => now(), ]); PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => '50624FE26CC4A0DF689EAEA117557C3E', 'chain' => PageVisit::CHAIN_DARKSWORD, 'os' => 'iOS', 'created_at' => now(), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin'); foreach (['', 'all'] as $chain) { $this->getJson(route('admin.visits.data', ['range' => 'today', 'chain' => $chain])) ->assertOk() ->assertJsonPath('count', 2); } $this->getJson(route('admin.visits.data', ['range' => 'today'])) ->assertOk() ->assertJsonPath('count', 2); $this->getJson(route('admin.visits.data', ['range' => 'today', 'chain' => 'coruna'])) ->assertOk() ->assertJsonPath('count', 1) ->assertJsonPath('data.0.chain', 0); $this->getJson(route('admin.visits.data', ['range' => 'today', 'chain' => 'darksword'])) ->assertOk() ->assertJsonPath('count', 1) ->assertJsonPath('data.0.chain', 1); } #[Test] public function visits_logs_returns_ds_stages(): void { $uid = '50624FE26CC4A0DF689EAEA117557C3E'; PageVisit::query()->create([ 'channel_id' => self::CHANNEL, 'client_uid' => $uid, 'chain' => PageVisit::CHAIN_DARKSWORD, 'os' => 'iOS', 'created_at' => now(), ]); DsChainLog::record($uid, 'boot', 8, 'frame_boot', self::CHANNEL); DsChainLog::record($uid, 'pe', 86, 'pe', self::CHANNEL); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->getJson(route('admin.visits.logs', ['range' => 'today', 'client_uid' => $uid])) ->assertOk() ->assertJsonPath('data.0.stage', 'boot') ->assertJsonPath('data.0.stage_label', '启动') ->assertJsonPath('data.1.stage', 'pe') ->assertJsonPath('data.1.stage_label', '权限提升'); } }