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