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