434 lines
16 KiB
PHP
434 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(route('admin.reports.daily'), 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();
|
|
}
|
|
}
|