File: /home/xedaptot/be.naniguide.com/tests/Feature/AdsR9_1ObservabilityDashboardTest.php
<?php
/**
* Ads Phase R9.1 — Admin observability dashboard.
*
* Verifies:
* 1. Route registered as `refactor.admin.ads.observability`.
* 2. Controller renders the observability page for an admin user.
* 3. Page surfaces the four banner stats (events / error rate /
* errors / open breakers).
* 4. Per-platform breakdown table renders all 4 platforms even when
* they have no activity logs (zero-state row).
* 5. AdActivityLog rows in the window populate the per-platform
* success / error counts.
* 6. Recent errors timeline renders the latest 25 error rows.
* 7. Top error actions card aggregates by (action, platform).
* 8. Open circuit breakers card reads the decorator's cache state.
* 9. Window selector preserves the selected value via query param.
*/
use App\Model\AdActivityLog;
use App\Model\AdPlatform;
use App\Model\Customer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;
// (DB removed — unused)
uses(Tests\TestCase::class);
uses(DatabaseTransactions::class);
beforeEach(function () {
// Build a stub admin User per the F51 admin-test pattern — actingAs needs
// an Authenticatable, and AdminController guards just check the User has
// an `admin` relation present. Skip cleanly if the test DB is empty.
if (! Customer::orderBy('id')->first()) {
$this->markTestSkipped('No seeded customer — run DemoSeeder first');
}
$this->adminUser = r91MakeAdminUser();
});
function r91MakeAdminUser(): \App\Model\User
{
// Reuse the seeded admin row so the layout has all the fields it
// expects (first_name/last_name/permissions/groups) rather than
// a bare anonymous-class stub.
$existingAdmin = \App\Model\Admin::orderBy('id')->first();
$user = new \App\Model\User();
$user->id = $existingAdmin?->id ?? 999_998;
$user->email = $existingAdmin?->email ?? '[email protected]';
$user->first_name = $existingAdmin?->first_name ?? 'Admin';
$user->last_name = $existingAdmin?->last_name ?? 'User';
$user->customer_id = null;
if ($existingAdmin !== null) {
$user->setRelation('admin', $existingAdmin);
} else {
$admin = new class extends \App\Model\Admin {
public function getPermission($name) { return 'yes'; }
};
$admin->id = 999_998;
$admin->email = $user->email;
$admin->first_name = 'Admin';
$admin->last_name = 'User';
$user->setRelation('admin', $admin);
}
return $user;
}
function r91FactoryActivity(int $customerId, string $platform, string $status, string $action = 'campaign.created', ?\DateTimeInterface $when = null): AdActivityLog
{
$log = new AdActivityLog();
$log->customer_id = $customerId;
$log->loggable_type = \App\Model\AdCampaign::class; // schema requires non-null
$log->loggable_id = 0;
$log->platform = $platform;
$log->status = $status;
$log->action = $action;
$log->title = 'R9.1 test ' . $status;
$log->error_message = $status === AdActivityLog::STATUS_ERROR ? 'Sample error message for R9.1' : null;
$log->created_at = $when ?? now();
$log->save();
return $log;
}
// ---------------------------------------------------------------------------
// 1-2. Route + render
// ---------------------------------------------------------------------------
test('route registered and admin can access observability page', function () {
$response = $this->actingAs($this->adminUser)->withoutMiddleware()->get(route('refactor.admin.ads.observability'));
$response->assertOk();
$response->assertSee('Ads observability');
});
// ---------------------------------------------------------------------------
// 3. Banner stats rendered
// ---------------------------------------------------------------------------
test('banner surfaces the four key metrics', function () {
$response = $this->actingAs($this->adminUser)->withoutMiddleware()->get(route('refactor.admin.ads.observability'));
$response->assertOk();
$response->assertSee('Events');
$response->assertSee('Error rate');
$response->assertSee('Errors');
$response->assertSee('Open breakers');
});
// ---------------------------------------------------------------------------
// 4. Zero-state platform breakdown table
// ---------------------------------------------------------------------------
test('per-platform breakdown table renders all 4 platforms even without activity', function () {
$response = $this->actingAs($this->adminUser)->withoutMiddleware()->get(route('refactor.admin.ads.observability'));
$response->assertOk();
foreach (AdPlatform::PLATFORMS as $platform) {
$label = AdPlatform::PLATFORM_LABELS[$platform] ?? ucfirst($platform);
$response->assertSee($label);
}
});
// ---------------------------------------------------------------------------
// 5. AdActivityLog populates breakdown
// ---------------------------------------------------------------------------
test('AdActivityLog rows populate per-platform error counts', function () {
$customer = Customer::orderBy('id')->first();
if (! $customer) {
$this->markTestSkipped('No seeded customer');
}
$customerId = $customer->local()->id;
// 3 errors on meta + 1 success on google.
r91FactoryActivity($customerId, 'meta', AdActivityLog::STATUS_ERROR);
r91FactoryActivity($customerId, 'meta', AdActivityLog::STATUS_ERROR);
r91FactoryActivity($customerId, 'meta', AdActivityLog::STATUS_ERROR);
r91FactoryActivity($customerId, 'google', AdActivityLog::STATUS_SUCCESS);
$response = $this->actingAs($this->adminUser)->withoutMiddleware()->get(route('refactor.admin.ads.observability'));
$response->assertOk();
// Meta error rate at 100% (3/3) — should render the danger badge.
// The number of errors should appear in the breakdown.
expect(
substr_count($response->getContent(), 'mc-badge-danger')
)->toBeGreaterThan(0);
});
// ---------------------------------------------------------------------------
// 6. Recent errors timeline
// ---------------------------------------------------------------------------
test('recent errors timeline renders the latest error rows with title', function () {
$customer = Customer::orderBy('id')->first();
if (! $customer) {
$this->markTestSkipped('No seeded customer');
}
$log = new AdActivityLog();
$log->customer_id = $customer->local()->id;
$log->loggable_type = \App\Model\AdCampaign::class;
$log->loggable_id = 0;
$log->platform = 'meta';
$log->status = AdActivityLog::STATUS_ERROR;
$log->action = 'campaign.publish';
$log->title = 'R9.1 specific error title for assertion';
$log->error_message = 'Token expired';
$log->created_at = now();
$log->save();
$response = $this->actingAs($this->adminUser)->withoutMiddleware()->get(route('refactor.admin.ads.observability'));
$response->assertOk();
$response->assertSee('R9.1 specific error title for assertion');
$response->assertSee('campaign.publish');
});
// ---------------------------------------------------------------------------
// 7. Top error actions card
// ---------------------------------------------------------------------------
test('top error actions card aggregates by action+platform', function () {
$customer = Customer::orderBy('id')->first();
if (! $customer) {
$this->markTestSkipped('No seeded customer');
}
$customerId = $customer->local()->id;
// 5 same-action errors.
for ($i = 0; $i < 5; $i++) {
r91FactoryActivity($customerId, 'meta', AdActivityLog::STATUS_ERROR, 'r91.repeat.action');
}
// 2 different errors.
r91FactoryActivity($customerId, 'google', AdActivityLog::STATUS_ERROR, 'r91.other.action');
r91FactoryActivity($customerId, 'google', AdActivityLog::STATUS_ERROR, 'r91.other.action');
$response = $this->actingAs($this->adminUser)->withoutMiddleware()->get(route('refactor.admin.ads.observability'));
$response->assertOk();
$response->assertSee('r91.repeat.action');
$response->assertSee('r91.other.action');
});
// ---------------------------------------------------------------------------
// 8. Circuit breaker card reads cache
// ---------------------------------------------------------------------------
test('open circuit breakers card surfaces cache-tracked state', function () {
$customer = Customer::orderBy('id')->first();
if (! $customer) {
$this->markTestSkipped('No seeded customer');
}
$platform = new AdPlatform([
'uid' => 'r91cb_' . uniqid(),
'customer_id' => $customer->local()->id,
'platform' => 'meta',
'name' => 'R9.1 Circuit Test',
'access_token' => 'r91tok',
'platform_user_id' => 'p1',
'platform_user_name' => 'r91',
]);
$platform->status = AdPlatform::STATUS_ACTIVE;
$platform->health_status = AdPlatform::HEALTH_HEALTHY;
$platform->save();
Cache::put("ads_cb:meta:{$platform->id}:state", 'open', 60);
Cache::put("ads_cb:meta:{$platform->id}:failures", 7, 60);
$response = $this->actingAs($this->adminUser)->withoutMiddleware()->get(route('refactor.admin.ads.observability'));
$response->assertOk();
$response->assertSee('R9.1 Circuit Test');
$response->assertSee('open');
$response->assertSee('7 ');
});
// ---------------------------------------------------------------------------
// 9. Window selector
// ---------------------------------------------------------------------------
test('window selector preserves selection via query param', function () {
$response = $this->actingAs($this->adminUser)->withoutMiddleware()->get(route('refactor.admin.ads.observability', ['window' => '7d']));
$response->assertOk();
// 7-day window resolves to 168 hours; banner desc renders that.
$response->assertSee('168');
});