Files
coruna-lab/tests/Feature/DailyReportTest.php
T
2026-09-14 01:09:01 +08:00

436 lines
16 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Channel;
use App\Models\DailyStat;
use App\Models\Device;
use App\Models\PageVisit;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\DashboardStatsService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class DailyReportTest 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 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 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();
}
}