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