Files
SkinbaseNova/tests/Feature/Adsense/AdsenseApiAndSyncTest.php
klevze ce0f278bac 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.
2026-09-20 14:49:48 +02:00

409 lines
16 KiB
PHP

<?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();
});