Files
coruna-lab/tests/Feature/AnalyticsReportTest.php
2026-09-17 00:25:39 +08:00

232 lines
8.3 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\AnalyticsDailyDim;
use App\Models\Channel;
use App\Models\Device;
use App\Models\PageVisit;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AnalyticsReportTest extends TestCase
{
use RefreshDatabase;
private const CHANNEL = 'ffffffffffffffffffffffffffffffff';
private const OTHER = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
protected function setUp(): void
{
parent::setUp();
config([
'coruna.panel.admin_hosts' => [],
'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<array{label: string}> $rows
* @return array<string, mixed>|null
*/
private function rowByLabel(array $rows, string $label): ?array
{
foreach ($rows as $row) {
if (($row['label'] ?? '') === $label) {
return $row;
}
}
return null;
}
}