Add a read-only Google AdSense analytics module for admins.

Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages.
This commit is contained in:
2026-09-20 14:49:48 +02:00
parent 04a60a981e
commit ce0f278bac
30 changed files with 5098 additions and 12 deletions
@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
use App\Jobs\SyncAdsenseJob;
use App\Models\AdsenseConnection;
use App\Models\AdsenseDailyStat;
use App\Models\AdsenseEntity;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'adsense.client_id' => 'test-client-id',
'adsense.client_secret' => 'test-client-secret',
'adsense.redirect_uri' => 'https://skinbase.org/admin/adsense/oauth/callback',
'adsense.sync_enabled' => true,
]);
});
function dashboardConnection(): AdsenseConnection
{
$connection = new AdsenseConnection;
$connection->account_resource_name = 'accounts/pub-123';
$connection->account_display_name = 'Skinbase';
$connection->status = AdsenseConnection::STATUS_CONNECTED;
$connection->connected_at = now()->subHour();
$connection->last_successful_sync_at = now()->subMinutes(12);
$connection->encrypted_refresh_token = 'refresh-secret';
$connection->save();
return $connection;
}
function seedStat(string $date, string $type, string $key, array $metrics, ?string $label = null): void
{
AdsenseDailyStat::query()->create(array_merge([
'date' => $date,
'account_resource_name' => 'accounts/pub-123',
'dimension_type' => $type,
'dimension_key' => $key,
'dimension_label' => $label ?? $key,
'currency_code' => 'EUR',
'last_synced_at' => now(),
], $metrics));
}
it('requires an admin for the adsense dashboard', function (): void {
$this->get(route('admin.adsense.index'))->assertRedirect(route('login'));
$user = User::factory()->create(['role' => 'user']);
$this->actingAs($user)->get(route('admin.adsense.index'))->assertForbidden();
});
it('renders connection status and period filters from local statistics', function (): void {
Http::fake();
$admin = User::factory()->create(['role' => 'admin']);
dashboardConnection();
AdsenseEntity::query()->create([
'account_resource_name' => 'accounts/pub-123',
'type' => AdsenseEntity::TYPE_AD_UNIT,
'reporting_dimension_id' => '111',
'display_name' => 'Skinbase_Artwork_BeforeComments',
'last_seen_at' => now(),
]);
seedStat(now()->toDateString(), 'total', '__total__', [
'page_views' => 100,
'impressions' => 80,
'clicks' => 4,
'estimated_earnings' => '5.00',
'ad_requests' => 90,
'matched_ad_requests' => 80,
]);
seedStat(now()->subDays(7)->toDateString(), 'total', '__total__', [
'page_views' => 50,
'impressions' => 40,
'clicks' => 1,
'estimated_earnings' => '1.00',
'ad_requests' => 45,
'matched_ad_requests' => 40,
]);
seedStat(now()->toDateString(), 'ad_unit', '111', [
'page_views' => 100,
'impressions' => 80,
'clicks' => 4,
'estimated_earnings' => '5.00',
], 'Skinbase_Artwork_BeforeComments');
seedStat(now()->toDateString(), 'platform', 'DESKTOP', [
'page_views' => 60,
'impressions' => 50,
'clicks' => 3,
'estimated_earnings' => '3.00',
]);
seedStat(now()->toDateString(), 'country', 'SI', [
'page_views' => 20,
'impressions' => 18,
'clicks' => 2,
'estimated_earnings' => '1.50',
]);
$this->actingAs($admin)
->get(route('admin.adsense.index', ['period' => 'today']))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('Admin/Adsense/Index')
->where('connection.status', 'connected')
->where('range.period', 'today')
->where('dashboard.placements.0.label', 'Skinbase_Artwork_BeforeComments')
->where('dashboard.kpis.0.key', 'estimated_earnings')
->missing('connection.encrypted_refresh_token'));
$this->actingAs($admin)
->get(route('admin.adsense.index', ['period' => 'yesterday']))
->assertOk()
->assertInertia(fn (Assert $page) => $page->where('range.period', 'yesterday'));
$this->actingAs($admin)
->get(route('admin.adsense.index', ['period' => '7d']))
->assertOk()
->assertInertia(fn (Assert $page) => $page->where('range.period', '7d'));
$this->actingAs($admin)
->get(route('admin.adsense.index', ['period' => '30d']))
->assertOk()
->assertInertia(fn (Assert $page) => $page->where('range.period', '30d'));
$from = now()->subDays(2)->toDateString();
$to = now()->toDateString();
$this->actingAs($admin)
->get(route('admin.adsense.index', ['period' => 'custom', 'from' => $from, 'to' => $to]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->where('range.period', 'custom')
->where('range.from', $from)
->where('range.to', $to));
Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'adsense.googleapis.com')
|| str_contains($request->url(), 'oauth2.googleapis.com'));
});
it('authorizes manual sync and keeps historical statistics after disconnect', function (): void {
Queue::fake();
$admin = User::factory()->create(['role' => 'admin']);
$connection = dashboardConnection();
seedStat(now()->toDateString(), 'total', '__total__', [
'page_views' => 10,
'clicks' => 1,
'estimated_earnings' => '2.00',
]);
$user = User::factory()->create(['role' => 'user']);
$this->actingAs($user)->post(route('admin.adsense.sync'))->assertForbidden();
$this->actingAs($user)->delete(route('admin.adsense.disconnect'))->assertForbidden();
$this->actingAs($admin)
->post(route('admin.adsense.sync'))
->assertRedirect(route('admin.adsense.index'));
Queue::assertPushed(SyncAdsenseJob::class);
Http::fake([
'https://oauth2.googleapis.com/revoke' => Http::response([], 200),
]);
$this->actingAs($admin)
->delete(route('admin.adsense.disconnect'))
->assertRedirect(route('admin.adsense.index'));
expect($connection->fresh()->encrypted_refresh_token)->toBeNull()
->and($connection->fresh()->status)->toBe(AdsenseConnection::STATUS_DISCONNECTED)
->and(AdsenseDailyStat::query()->count())->toBe(1);
});
it('registers hourly and nightly adsense scheduler entries', function (): void {
Artisan::call('schedule:list');
$output = Artisan::output();
expect($output)->toContain('adsense:sync --days=3')
->and($output)->toContain('adsense:sync --days=30');
});
it('does not call the adsense management api from the public homepage', function (): void {
Http::fake();
$this->get('/')->assertOk();
Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'adsense.googleapis.com')
|| str_contains($request->url(), 'oauth2.googleapis.com'));
});
@@ -0,0 +1,408 @@
<?php
declare(strict_types=1);
use App\Models\AdsenseConnection;
use App\Models\AdsenseDailyStat;
use App\Models\AdsenseEntity;
use App\Services\Adsense\AdsenseApiClient;
use App\Services\Adsense\AdsenseOAuthService;
use App\Services\Adsense\AdsenseSyncService;
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use function Pest\Laravel\artisan;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'adsense.client_id' => 'test-client-id',
'adsense.client_secret' => 'test-client-secret',
'adsense.redirect_uri' => 'https://skinbase.org/admin/adsense/oauth/callback',
'adsense.sync_enabled' => true,
'adsense.retry_times' => 3,
'adsense.retry_sleep_ms' => 0,
]);
});
function adsenseConnection(array $overrides = []): AdsenseConnection
{
$connection = new AdsenseConnection;
$connection->fill(array_merge([
'account_resource_name' => 'accounts/pub-123',
'account_display_name' => 'Skinbase',
'status' => AdsenseConnection::STATUS_CONNECTED,
'connected_at' => now(),
], $overrides));
$connection->encrypted_refresh_token = $overrides['encrypted_refresh_token'] ?? 'refresh-secret';
$connection->save();
return $connection->fresh();
}
function adsenseReportPayload(array $headers, array $rows): array
{
return [
'headers' => $headers,
'rows' => array_map(fn (array $values): array => [
'cells' => array_map(fn (string $value): array => ['value' => $value], $values),
], $rows),
];
}
it('refreshes a cached access token from the google token endpoint', function (): void {
$connection = adsenseConnection();
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response([
'access_token' => 'fresh-access-token',
'expires_in' => 3600,
]),
]);
$token = app(AdsenseOAuthService::class)->accessToken($connection);
expect($token)->toBe('fresh-access-token');
Http::assertSent(fn ($request): bool => str_contains($request->url(), 'oauth2.googleapis.com/token')
&& $request['grant_type'] === 'refresh_token'
&& $request['refresh_token'] === 'refresh-secret'
&& ! str_contains($request->body(), 'prompt=consent'));
});
it('discovers accounts with pagination', function (): void {
$connection = adsenseConnection();
Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300);
$calls = 0;
Http::fake(function ($request) use (&$calls) {
expect($request->url())->toContain('/accounts');
$calls++;
if ($calls === 1) {
return Http::response([
'accounts' => [['name' => 'accounts/pub-1', 'displayName' => 'One']],
'nextPageToken' => 'page-2',
]);
}
return Http::response([
'accounts' => [['name' => 'accounts/pub-2', 'displayName' => 'Two']],
]);
});
$accounts = app(AdsenseApiClient::class)->listAccounts($connection);
expect($accounts)->toHaveCount(2)
->and($calls)->toBe(2);
});
it('synchronizes ad clients units and custom channels', function (): void {
$connection = adsenseConnection();
Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300);
Http::fake(function ($request) {
$url = $request->url();
if (str_ends_with(parse_url($url, PHP_URL_PATH), '/adclients')) {
return Http::response([
'adClients' => [[
'name' => 'accounts/pub-123/adclients/ca-pub-123',
'reportingDimensionId' => 'ca-pub-123',
'state' => 'READY',
]],
]);
}
if (str_contains($url, '/adunits')) {
return Http::response([
'adUnits' => [[
'name' => 'accounts/pub-123/adclients/ca-pub-123/adunits/111',
'reportingDimensionId' => '111',
'displayName' => 'Skinbase_Artwork_BeforeComments',
'state' => 'ACTIVE',
]],
]);
}
if (str_contains($url, '/customchannels')) {
return Http::response([
'customChannels' => [[
'name' => 'accounts/pub-123/adclients/ca-pub-123/customchannels/222',
'reportingDimensionId' => '222',
'displayName' => 'Artwork',
'state' => 'ACTIVE',
]],
]);
}
return Http::response(['error' => ['message' => 'unexpected '.$url]], 500);
});
$result = app(AdsenseSyncService::class)->syncEntities($connection);
expect($result->adUnits)->toBe(1)
->and($result->customChannels)->toBe(1)
->and(AdsenseEntity::query()->count())->toBe(2)
->and(AdsenseEntity::query()->where('display_name', 'Skinbase_Artwork_BeforeComments')->exists())->toBeTrue();
});
it('skips youtube host ad clients and continues when an inventory client returns 404', function (): void {
$connection = adsenseConnection();
Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/adclients') && ! str_contains($url, '/adunits') && ! str_contains($url, '/customchannels')) {
return Http::response([
'adClients' => [
[
'name' => 'accounts/pub-123/adclients/ca-yt-host-pub-123',
'reportingDimensionId' => 'ca-yt-host-pub-123',
'productCode' => 'AFV',
'state' => 'READY',
],
[
'name' => 'accounts/pub-123/adclients/ca-pub-missing',
'reportingDimensionId' => 'ca-pub-missing',
'productCode' => 'AFC',
'state' => 'READY',
],
[
'name' => 'accounts/pub-123/adclients/ca-pub-123',
'reportingDimensionId' => 'ca-pub-123',
'productCode' => 'AFC',
'state' => 'READY',
],
],
]);
}
if (str_contains($url, 'ca-yt-host-pub-123')) {
return Http::response(['error' => ['status' => 'NOT_FOUND', 'message' => "Couldn't find the ad client"]], 404);
}
if (str_contains($url, 'ca-pub-missing')) {
return Http::response(['error' => ['status' => 'NOT_FOUND', 'message' => "Couldn't find the ad client"]], 404);
}
if (str_contains($url, '/adunits')) {
return Http::response([
'adUnits' => [[
'name' => 'accounts/pub-123/adclients/ca-pub-123/adunits/111',
'reportingDimensionId' => '111',
'displayName' => 'Skinbase_Artwork_BeforeComments',
'state' => 'ACTIVE',
]],
]);
}
if (str_contains($url, '/customchannels')) {
return Http::response(['customChannels' => []]);
}
return Http::response(['error' => ['message' => 'unexpected '.$url]], 500);
});
$result = app(AdsenseSyncService::class)->sync(
$connection,
Carbon::parse('2026-09-18'),
Carbon::parse('2026-09-18'),
true,
);
expect($result->adUnits)->toBe(1)
->and($result->reconnectRequired)->toBeFalse()
->and($result->failedReports)->toBe([])
->and($connection->fresh()->status)->toBe(AdsenseConnection::STATUS_CONNECTED)
->and(AdsenseEntity::query()->where('display_name', 'Skinbase_Artwork_BeforeComments')->exists())->toBeTrue();
Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'ca-yt-host-pub-123'));
Http::assertSent(fn ($request): bool => str_contains($request->url(), 'ca-pub-missing') && str_contains($request->url(), '/adunits'));
});
it('retries 401 by refreshing the access token once', function (): void {
$connection = adsenseConnection();
Cache::put('adsense.access_token.'.$connection->id, 'expired-access', 300);
$accountCalls = 0;
Http::fake(function ($request) use (&$accountCalls) {
if (str_contains($request->url(), 'oauth2.googleapis.com/token')) {
return Http::response([
'access_token' => 'rotated-access',
'expires_in' => 3600,
]);
}
$accountCalls++;
if ($accountCalls === 1) {
return Http::response(['error' => ['message' => 'Unauthenticated']], 401);
}
return Http::response([
'accounts' => [['name' => 'accounts/pub-123', 'displayName' => 'Skinbase']],
]);
});
$accounts = app(AdsenseApiClient::class)->listAccounts($connection);
expect($accounts)->toHaveCount(1)
->and($accountCalls)->toBe(2);
});
it('retries 429 and 5xx responses with a bound', function (): void {
$connection = adsenseConnection();
Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300);
$calls = 0;
Http::fake(function () use (&$calls) {
$calls++;
if ($calls === 1) {
return Http::response(['error' => ['message' => 'rate limit']], 429);
}
if ($calls === 2) {
return Http::response(['error' => ['message' => 'unavailable']], 503);
}
return Http::response(['accounts' => []]);
});
expect(app(AdsenseApiClient::class)->listAccounts($connection))->toBe([])
->and($calls)->toBe(3);
});
it('marks reconnect_required when the refresh token is revoked', function (): void {
$connection = adsenseConnection();
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response(['error' => 'invalid_grant'], 400),
]);
expect(fn () => app(AdsenseOAuthService::class)->accessToken($connection))
->toThrow(AdsenseAuthorizationException::class);
expect($connection->fresh()->status)->toBe(AdsenseConnection::STATUS_RECONNECT_REQUIRED);
});
it('upserts daily statistics idempotently and preserves successful reports on partial failure', function (): void {
$connection = adsenseConnection();
Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300);
$from = Carbon::parse('2026-09-18');
$to = Carbon::parse('2026-09-18');
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/adclients') && ! str_contains($url, '/adunits') && ! str_contains($url, '/customchannels')) {
return Http::response(['adClients' => [['name' => 'accounts/pub-123/adclients/ca-pub-123']]]);
}
if (str_contains($url, '/adunits')) {
return Http::response(['adUnits' => [[
'name' => 'accounts/pub-123/adclients/ca-pub-123/adunits/111',
'reportingDimensionId' => '111',
'displayName' => 'Skinbase_Artwork_BeforeComments',
'state' => 'ACTIVE',
]]]);
}
if (str_contains($url, '/customchannels')) {
return Http::response(['customChannels' => []]);
}
if (str_contains($url, 'reports:generate') && str_contains($url, 'COUNTRY_CODE')) {
return Http::response(['error' => ['message' => 'Invalid combination']], 400);
}
if (str_contains($url, 'reports:generate') && str_contains($url, 'AD_UNIT_ID')) {
return Http::response(adsenseReportPayload([
['name' => 'DATE', 'type' => 'DIMENSION'],
['name' => 'AD_UNIT_ID', 'type' => 'DIMENSION'],
['name' => 'PAGE_VIEWS', 'type' => 'METRIC_TALLY'],
['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'EUR'],
], [['2026-09-18', '111', '80', '2.50']]));
}
if (str_contains($url, 'reports:generate') && str_contains($url, 'PLATFORM_TYPE_CODE')) {
return Http::response(adsenseReportPayload([
['name' => 'DATE', 'type' => 'DIMENSION'],
['name' => 'PLATFORM_TYPE_CODE', 'type' => 'DIMENSION'],
['name' => 'PAGE_VIEWS', 'type' => 'METRIC_TALLY'],
['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'EUR'],
], [['2026-09-18', 'DESKTOP', '70', '2.00']]));
}
if (str_contains($url, 'reports:generate') && str_contains($url, 'CUSTOM_CHANNEL_ID')) {
return Http::response(adsenseReportPayload([
['name' => 'DATE', 'type' => 'DIMENSION'],
['name' => 'CUSTOM_CHANNEL_ID', 'type' => 'DIMENSION'],
['name' => 'CLICKS', 'type' => 'METRIC_TALLY'],
], []));
}
if (str_contains($url, 'reports:generate')) {
return Http::response(adsenseReportPayload([
['name' => 'DATE', 'type' => 'DIMENSION'],
['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'EUR'],
['name' => 'PAGE_VIEWS', 'type' => 'METRIC_TALLY'],
['name' => 'CLICKS', 'type' => 'METRIC_TALLY'],
], [['2026-09-18', '3.00', '100', '4']]));
}
return Http::response(['error' => ['message' => 'unexpected '.$url]], 500);
});
$sync = app(AdsenseSyncService::class);
$first = $sync->sync($connection, $from, $to);
$second = $sync->sync($connection, $from, $to);
expect($first->failedReports)->toBe([AdsenseDailyStat::DIMENSION_COUNTRY])
->and($second->totalRows)->toBe($first->totalRows)
->and(AdsenseDailyStat::query()->count())->toBe(3)
->and(AdsenseDailyStat::query()->where('dimension_type', 'total')->value('estimated_earnings'))->toBe('3.000000')
->and(AdsenseDailyStat::query()->where('dimension_type', 'country')->count())->toBe(0);
AdsenseDailyStat::query()->where('dimension_type', 'total')->update(['estimated_earnings' => '1.000000']);
$sync->sync($connection, $from, $to);
expect(AdsenseDailyStat::query()->where('dimension_type', 'total')->value('estimated_earnings'))->toBe('3.000000')
->and(AdsenseDailyStat::query()->count())->toBe(3);
});
it('runs the artisan command and rejects incompatible options', function (): void {
$connection = adsenseConnection();
Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/adclients')) {
return Http::response(['adClients' => []]);
}
if (str_contains($url, 'reports:generate')) {
return Http::response(['headers' => [['name' => 'DATE']], 'rows' => []]);
}
return Http::response(['error' => ['message' => 'unexpected']], 500);
});
artisan('adsense:sync', ['--days' => 3])
->expectsOutputToContain('AdSense account: Skinbase')
->expectsOutputToContain('Synchronization completed successfully.')
->assertSuccessful();
artisan('adsense:sync', ['--days' => 3, '--from' => '2026-09-01'])
->assertExitCode(2);
artisan('adsense:sync', ['--from' => '2026-09-01'])
->assertExitCode(2);
});
it('skips scheduled sync when disabled unless --force is used', function (): void {
config(['adsense.sync_enabled' => false]);
adsenseConnection();
artisan('adsense:sync')
->expectsOutputToContain('AdSense synchronization is disabled')
->assertSuccessful();
$connection = AdsenseConnection::current();
Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300);
Http::fake(function ($request) {
if (str_contains($request->url(), 'oauth2.googleapis.com/token')) {
return Http::response(['access_token' => 'access-secret', 'expires_in' => 3600]);
}
if (str_contains($request->url(), '/adclients')) {
return Http::response(['adClients' => []]);
}
return Http::response(['error' => ['message' => 'unexpected']], 500);
});
artisan('adsense:sync', ['--force' => true, '--entities-only' => true])
->assertSuccessful();
});
+180
View File
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
use App\Jobs\SyncAdsenseJob;
use App\Models\AdsenseConnection;
use App\Models\User;
use App\Services\Adsense\AdsenseOAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'adsense.client_id' => 'test-client-id',
'adsense.client_secret' => 'test-client-secret',
'adsense.redirect_uri' => 'https://skinbase.org/admin/adsense/oauth/callback',
'adsense.sync_enabled' => true,
'adsense.retry_sleep_ms' => 0,
]);
});
it('lets an admin start the oauth connection with readonly offline access', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$first = $this->actingAs($admin)->get(route('admin.adsense.connect'));
$first->assertRedirect();
$location = (string) $first->headers->get('Location');
$state = session(AdsenseOAuthService::SESSION_STATE_KEY);
expect($location)->toContain('https://accounts.google.com/o/oauth2/v2/auth')
->and($location)->toContain(urlencode('https://www.googleapis.com/auth/adsense.readonly'))
->and($location)->toContain('access_type=offline')
->and($location)->toContain('prompt=consent')
->and($location)->toContain('include_granted_scopes=true')
->and($location)->toContain('response_type=code')
->and($location)->toContain(urlencode('https://skinbase.org/admin/adsense/oauth/callback'))
->and($state)->toBeString()
->and(strlen((string) $state))->toBe(64);
$second = $this->actingAs($admin)->get(route('admin.adsense.connect'));
$secondState = session(AdsenseOAuthService::SESSION_STATE_KEY);
expect($secondState)->not->toBe($state);
});
it('rejects oauth start for guests and non-admins', function (): void {
$this->get(route('admin.adsense.connect'))->assertRedirect(route('login'));
$user = User::factory()->create(['role' => 'user']);
$this->actingAs($user)->get(route('admin.adsense.connect'))->assertForbidden();
$manager = User::factory()->create(['role' => 'manager']);
$this->actingAs($manager)->get(route('admin.adsense.connect'))->assertForbidden();
});
it('rejects invalid missing and denied oauth callbacks', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($admin)
->withSession([AdsenseOAuthService::SESSION_STATE_KEY => 'valid-state'])
->get(route('admin.adsense.callback', ['code' => 'abc', 'state' => 'wrong-state']))
->assertRedirect(route('admin.adsense.index'))
->assertSessionHas('error', 'Invalid OAuth state.');
$this->actingAs($admin)
->get(route('admin.adsense.callback', ['code' => 'abc', 'state' => 'anything']))
->assertRedirect(route('admin.adsense.index'))
->assertSessionHas('error', 'Invalid OAuth state.');
$this->actingAs($admin)
->withSession([AdsenseOAuthService::SESSION_STATE_KEY => 'valid-state'])
->get(route('admin.adsense.callback', ['state' => 'valid-state']))
->assertRedirect(route('admin.adsense.index'))
->assertSessionHas('error', 'Missing authorization code.');
$this->actingAs($admin)
->withSession([AdsenseOAuthService::SESSION_STATE_KEY => 'valid-state'])
->get(route('admin.adsense.callback', [
'state' => 'valid-state',
'error' => 'access_denied',
'error_description' => 'The user denied access',
]))
->assertRedirect(route('admin.adsense.index'));
});
it('exchanges the authorization code and stores an encrypted refresh token', function (): void {
Queue::fake();
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response([
'access_token' => 'access-secret',
'refresh_token' => 'refresh-secret',
'expires_in' => 3600,
'token_type' => 'Bearer',
]),
'https://adsense.googleapis.com/v2/accounts*' => Http::response([
'accounts' => [[
'name' => 'accounts/pub-1234567890',
'displayName' => 'Skinbase',
]],
]),
]);
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)
->withSession([AdsenseOAuthService::SESSION_STATE_KEY => 'valid-state'])
->get(route('admin.adsense.callback', [
'state' => 'valid-state',
'code' => 'auth-code-secret',
]));
$response->assertRedirect(route('admin.adsense.index'))
->assertSessionHas('success');
$connection = AdsenseConnection::current();
expect($connection)->not->toBeNull()
->and($connection->encrypted_refresh_token)->toBe('refresh-secret')
->and($connection->account_resource_name)->toBe('accounts/pub-1234567890')
->and($connection->status)->toBe(AdsenseConnection::STATUS_CONNECTED);
$raw = DB::table('adsense_connections')->value('encrypted_refresh_token');
expect($raw)->not->toBe('refresh-secret')
->and($raw)->not->toContain('refresh-secret');
$this->actingAs($admin)
->get(route('admin.adsense.index'))
->assertOk()
->assertDontSee('refresh-secret')
->assertDontSee('access-secret')
->assertDontSee('auth-code-secret')
->assertDontSee('test-client-secret')
->assertInertia(fn (Assert $page) => $page
->component('Admin/Adsense/Index')
->missing('connection.encrypted_refresh_token')
->where('connection.status', 'connected'));
Queue::assertPushed(SyncAdsenseJob::class);
});
it('asks the administrator to choose when multiple adsense accounts exist', function (): void {
Queue::fake();
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response([
'access_token' => 'access-secret',
'refresh_token' => 'refresh-secret',
'expires_in' => 3600,
]),
'https://adsense.googleapis.com/v2/accounts*' => Http::response([
'accounts' => [
['name' => 'accounts/pub-1', 'displayName' => 'Skinbase'],
['name' => 'accounts/pub-2', 'displayName' => 'Other'],
],
]),
]);
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($admin)
->withSession([AdsenseOAuthService::SESSION_STATE_KEY => 'valid-state'])
->get(route('admin.adsense.callback', [
'state' => 'valid-state',
'code' => 'auth-code-secret',
]))
->assertRedirect(route('admin.adsense.index'));
expect(session(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY))->toHaveCount(2);
Queue::assertNothingPushed();
$this->actingAs($admin)
->post(route('admin.adsense.account'), ['account_resource_name' => 'accounts/pub-1'])
->assertRedirect(route('admin.adsense.index'));
expect(AdsenseConnection::current()?->account_resource_name)->toBe('accounts/pub-1');
Queue::assertPushed(SyncAdsenseJob::class);
});
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
use App\Models\AdsenseDailyStat;
use App\Services\Adsense\AdsenseReportParser;
use Tests\TestCase;
uses(TestCase::class);
function adsenseReport(array $headers, array $rowValues): array
{
return [
'headers' => $headers,
'rows' => [
[
'cells' => array_map(fn (string $value): array => ['value' => $value], $rowValues),
],
],
];
}
it('maps report cells by header name rather than position', function (): void {
$parser = new AdsenseReportParser;
$report = adsenseReport([
['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'EUR'],
['name' => 'DATE', 'type' => 'DIMENSION'],
['name' => 'PAGE_VIEWS', 'type' => 'METRIC_TALLY'],
['name' => 'PAGE_VIEWS_CTR', 'type' => 'METRIC_RATIO'],
], ['12.50', '2026-09-18', '1000', '0.0125']);
$rows = $parser->parse($report, AdsenseDailyStat::DIMENSION_TOTAL, 'accounts/pub-1');
expect($rows)->toHaveCount(1)
->and($rows[0]['date'])->toBe('2026-09-18')
->and($rows[0]['dimension_key'])->toBe(AdsenseDailyStat::TOTAL_DIMENSION_KEY)
->and($rows[0]['estimated_earnings'])->toBe('12.50')
->and($rows[0]['page_views'])->toBe(1000)
->and($rows[0]['page_views_ctr'])->toBe('0.0125')
->and($rows[0]['currency_code'])->toBe('EUR');
});
it('parses the same values when Google returns columns in another order', function (): void {
$parser = new AdsenseReportParser;
$first = $parser->parse(adsenseReport([
['name' => 'DATE', 'type' => 'DIMENSION'],
['name' => 'AD_UNIT_ID', 'type' => 'DIMENSION'],
['name' => 'CLICKS', 'type' => 'METRIC_TALLY'],
['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'USD'],
], ['2026-09-18', 'unit-1', '8', '4.25']), AdsenseDailyStat::DIMENSION_AD_UNIT, 'accounts/pub-1');
$second = $parser->parse(adsenseReport([
['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'USD'],
['name' => 'CLICKS', 'type' => 'METRIC_TALLY'],
['name' => 'AD_UNIT_ID', 'type' => 'DIMENSION'],
['name' => 'DATE', 'type' => 'DIMENSION'],
], ['4.25', '8', 'unit-1', '2026-09-18']), AdsenseDailyStat::DIMENSION_AD_UNIT, 'accounts/pub-1');
expect($first[0]['dimension_key'])->toBe('unit-1')
->and($first[0]['clicks'])->toBe($second[0]['clicks'])
->and($first[0]['estimated_earnings'])->toBe($second[0]['estimated_earnings'])
->and($first[0]['date'])->toBe($second[0]['date']);
});
it('detects monetary currency from metric headers', function (): void {
$parser = new AdsenseReportParser;
$code = $parser->currencyCode([
['name' => 'DATE', 'type' => 'DIMENSION'],
['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'GBP'],
]);
expect($code)->toBe('GBP');
});
it('keeps ratio metrics as google decimals', function (): void {
$parser = new AdsenseReportParser;
$rows = $parser->parse(adsenseReport([
['name' => 'DATE', 'type' => 'DIMENSION'],
['name' => 'PLATFORM_TYPE_CODE', 'type' => 'DIMENSION'],
['name' => 'AD_REQUESTS_COVERAGE', 'type' => 'METRIC_RATIO'],
['name' => 'IMPRESSIONS_CTR', 'type' => 'METRIC_RATIO'],
], ['2026-09-19', 'DESKTOP', '0.84', '0.031']), AdsenseDailyStat::DIMENSION_PLATFORM, 'accounts/pub-1');
expect($rows[0]['ad_requests_coverage'])->toBe('0.84')
->and($rows[0]['impressions_ctr'])->toBe('0.031')
->and($rows[0]['dimension_key'])->toBe('DESKTOP');
});
it('parses country and custom channel dimension keys', function (): void {
$parser = new AdsenseReportParser;
$country = $parser->parse(adsenseReport([
['name' => 'COUNTRY_CODE', 'type' => 'DIMENSION'],
['name' => 'DATE', 'type' => 'DIMENSION'],
['name' => 'PAGE_VIEWS', 'type' => 'METRIC_TALLY'],
], ['SI', '2026-09-19', '12']), AdsenseDailyStat::DIMENSION_COUNTRY, 'accounts/pub-1');
$channel = $parser->parse(adsenseReport([
['name' => 'DATE', 'type' => 'DIMENSION'],
['name' => 'CUSTOM_CHANNEL_ID', 'type' => 'DIMENSION'],
['name' => 'CLICKS', 'type' => 'METRIC_TALLY'],
], ['2026-09-19', 'ch-9', '3']), AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL, 'accounts/pub-1');
expect($country[0]['dimension_key'])->toBe('SI')
->and($channel[0]['dimension_key'])->toBe('ch-9');
});