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:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user