File: /home/xedaptot/be.naniguide.com/tests/Feature/AdsR9TelemetryTest.php
<?php
/**
* Ads Phase R9 — TelemetryDecorator structured logging test suite.
*
* Closes gates O-1..O-10 (observability — trace_id, per-method duration,
* sampled success, always-on error logging, structured payload shape).
*
* Verifies:
* 1. Success path sampled per `ads.observability.sample_rate_success`
* 2. Error path always logged at error level (rate=1.0 default)
* 3. Log payload includes trace_id, platform, method, duration_ms,
* status, customer_id, ad_platform_id
* 4. Error payload adds error_class + error_code + error_message
* 5. trace_id is a UUID (36 chars) unique per call
* 6. rate_success=0 never samples; rate=1 always samples
* 7. Generic (non-AdPlatformException) throws still log at error level
* 8. Every network-hitting method is wrapped (exists as override)
*/
use App\Model\AdPlatform;
use App\Services\Ads\AdPlatformAdapter;
use App\Services\Ads\AdPlatformException;
use App\Services\Ads\Decorators\TelemetryDecorator;
use App\Services\Ads\Exceptions\NetworkException;
use App\Services\Ads\MockAdPlatform;
use App\Services\Ads\Results\PublishResult;
use Illuminate\Support\Facades\Log;
uses(Tests\TestCase::class);
beforeEach(function () {
config([
'ads.observability.log_channel' => 'stack',
'ads.observability.sample_rate_success' => 1.0, // always log in tests
'ads.observability.sample_rate_error' => 1.0,
]);
});
function r9MakePlatform(): AdPlatform
{
$p = new AdPlatform([
'uid' => 'r9p_' . uniqid(),
'customer_id' => 42,
'platform' => 'mock',
'name' => 'Mock',
]);
$p->id = 77;
return $p;
}
/** Adapter that succeeds. */
function r9Successful(): AdPlatformAdapter
{
return new class extends MockAdPlatform {
public function pauseCampaign(string $t, string $rcId, string $k): PublishResult
{
return new PublishResult(status: PublishResult::STATUS_PAUSED, remoteCampaignId: $rcId);
}
};
}
/** Adapter that throws NetworkException (an AdPlatformException subclass). */
function r9Network(): AdPlatformAdapter
{
return new class extends MockAdPlatform {
public function pauseCampaign(string $t, string $rcId, string $k): PublishResult
{
throw new NetworkException(message: 'downstream exploded', platformCode: 'TEST_NETWORK');
}
};
}
/** Adapter that throws a non-AdPlatformException (programmer error). */
function r9Generic(): AdPlatformAdapter
{
return new class extends MockAdPlatform {
public function pauseCampaign(string $t, string $rcId, string $k): PublishResult
{
throw new \RuntimeException('boom');
}
};
}
// ============================================================
// Success path — sampled per rate
// ============================================================
test('success path logs at info level with full payload', function () {
$hits = 0;
Log::partialMock()->shouldReceive('channel')->with('stack')->andReturnUsing(function () use (&$hits) {
return new class($hits) {
public function __construct(public &$h) {}
public function info(string $m, array $c): void { $this->h++; }
public function error(string $m, array $c): void { $this->h++; }
public function warning(string $m, array $c): void { $this->h++; }
};
});
$dec = new TelemetryDecorator(r9Successful(), r9MakePlatform());
$dec->pauseCampaign('t', 'rc_abc', 'k1');
expect($hits)->toBe(1);
});
test('success path includes trace_id + duration_ms + platform + method + customer_id', function () {
$captured = null;
Log::partialMock()
->shouldReceive('channel')
->andReturnUsing(function () use (&$captured) {
return new class($captured) {
public function __construct(public &$cap) {}
public function info(string $msg, array $ctx): void { $this->cap = ['level' => 'info', 'msg' => $msg, 'ctx' => $ctx]; }
public function error(string $msg, array $ctx): void { $this->cap = ['level' => 'error', 'msg' => $msg, 'ctx' => $ctx]; }
public function warning(string $msg, array $ctx): void { $this->cap = ['level' => 'warning', 'msg' => $msg, 'ctx' => $ctx]; }
};
});
$dec = new TelemetryDecorator(r9Successful(), r9MakePlatform());
$dec->pauseCampaign('t', 'rc_abc', 'k1');
expect($captured)->not->toBeNull()
->and($captured['level'])->toBe('info')
->and($captured['msg'])->toBe('ads.adapter.call')
->and($captured['ctx'])->toHaveKeys([
'trace_id', 'platform', 'method', 'duration_ms',
'status', 'customer_id', 'ad_platform_id',
])
->and($captured['ctx']['status'])->toBe('success')
->and($captured['ctx']['platform'])->toBe('mock')
->and($captured['ctx']['method'])->toBe('pauseCampaign')
->and($captured['ctx']['customer_id'])->toBe(42)
->and($captured['ctx']['ad_platform_id'])->toBe(77)
->and(strlen($captured['ctx']['trace_id']))->toBe(36); // UUID
});
test('sample_rate_success=0 never logs success', function () {
config(['ads.observability.sample_rate_success' => 0.0]);
$hits = 0;
Log::partialMock()->shouldReceive('channel')->andReturnUsing(function () use (&$hits) {
return new class($hits) {
public function __construct(public &$h) {}
public function info(string $m, array $c): void { $this->h++; }
public function error(string $m, array $c): void { $this->h++; }
public function warning(string $m, array $c): void { $this->h++; }
};
});
$dec = new TelemetryDecorator(r9Successful(), r9MakePlatform());
for ($i = 0; $i < 20; $i++) {
$dec->pauseCampaign('t', 'rc', "k{$i}");
}
expect($hits)->toBe(0);
});
test('sample_rate_success=1 always logs', function () {
config(['ads.observability.sample_rate_success' => 1.0]);
$hits = 0;
Log::partialMock()->shouldReceive('channel')->andReturnUsing(function () use (&$hits) {
return new class($hits) {
public function __construct(public &$h) {}
public function info(string $m, array $c): void { $this->h++; }
public function error(string $m, array $c): void { $this->h++; }
public function warning(string $m, array $c): void { $this->h++; }
};
});
$dec = new TelemetryDecorator(r9Successful(), r9MakePlatform());
for ($i = 0; $i < 5; $i++) {
$dec->pauseCampaign('t', 'rc', "k{$i}");
}
expect($hits)->toBe(5);
});
// ============================================================
// Error path — always logged (rate 1.0 default)
// ============================================================
test('AdPlatformException path logs at error level with error_class + error_code + error_message', function () {
$captured = null;
Log::partialMock()->shouldReceive('channel')->andReturnUsing(function () use (&$captured) {
return new class($captured) {
public function __construct(public &$cap) {}
public function info(string $m, array $c): void { $this->cap = ['level' => 'info', 'ctx' => $c]; }
public function error(string $m, array $c): void { $this->cap = ['level' => 'error', 'ctx' => $c]; }
public function warning(string $m, array $c): void { $this->cap = ['level' => 'warning', 'ctx' => $c]; }
};
});
$dec = new TelemetryDecorator(r9Network(), r9MakePlatform());
try { $dec->pauseCampaign('t', 'rc', 'k'); } catch (NetworkException) {}
expect($captured)->not->toBeNull()
->and($captured['level'])->toBe('error')
->and($captured['ctx']['status'])->toBe('error')
->and($captured['ctx']['error_class'])->toBe(NetworkException::class)
->and($captured['ctx']['error_code'])->toBe('TEST_NETWORK')
->and($captured['ctx']['error_message'])->toBe('downstream exploded');
});
test('non-AdPlatformException (RuntimeException) also logs at error level', function () {
$captured = null;
Log::partialMock()->shouldReceive('channel')->andReturnUsing(function () use (&$captured) {
return new class($captured) {
public function __construct(public &$cap) {}
public function info(string $m, array $c): void { $this->cap = ['level' => 'info', 'ctx' => $c]; }
public function error(string $m, array $c): void { $this->cap = ['level' => 'error', 'ctx' => $c]; }
public function warning(string $m, array $c): void { $this->cap = ['level' => 'warning', 'ctx' => $c]; }
};
});
$dec = new TelemetryDecorator(r9Generic(), r9MakePlatform());
try { $dec->pauseCampaign('t', 'rc', 'k'); } catch (\RuntimeException) {}
expect($captured)->not->toBeNull()
->and($captured['level'])->toBe('error')
->and($captured['ctx']['error_class'])->toBe('RuntimeException')
->and($captured['ctx']['error_message'])->toBe('boom');
});
// ============================================================
// trace_id uniqueness
// ============================================================
test('each call generates a distinct trace_id', function () {
$traceIds = [];
Log::partialMock()->shouldReceive('channel')->andReturnUsing(function () use (&$traceIds) {
return new class($traceIds) {
public function __construct(public &$t) {}
public function info(string $m, array $c): void { $this->t[] = $c['trace_id']; }
public function error(string $m, array $c): void { $this->t[] = $c['trace_id']; }
public function warning(string $m, array $c): void { $this->t[] = $c['trace_id']; }
};
});
$dec = new TelemetryDecorator(r9Successful(), r9MakePlatform());
for ($i = 0; $i < 5; $i++) {
$dec->pauseCampaign('t', 'rc', "k{$i}");
}
expect($traceIds)->toHaveCount(5)
->and(array_unique($traceIds))->toHaveCount(5); // all distinct
});
// ============================================================
// Method override coverage — every network-hitting method wrapped
// ============================================================
test('every network-hitting adapter method has a traced override', function () {
$methods = [
'handleCallback', 'refreshToken', 'getAdAccounts', 'healthCheck',
'testAppCredentials', 'publishCampaign', 'pauseCampaign',
'resumeCampaign', 'pullMetrics', 'syncAudience', 'deleteAudience',
'getLeadForms', 'fetchLeads', 'createCatalog', 'syncCatalog',
'getAuthUrl',
];
$ref = new ReflectionClass(TelemetryDecorator::class);
foreach ($methods as $m) {
expect($ref->hasMethod($m))->toBeTrue("TelemetryDecorator must override {$m}");
$method = $ref->getMethod($m);
expect($method->getDeclaringClass()->getName())->toBe(TelemetryDecorator::class,
"TelemetryDecorator::{$m} must be declared on the class (not inherited)");
}
});
test('observability config defaults', function () {
$default = require base_path('config/ads.php');
expect($default['observability']['sample_rate_success'])->toBe(0.1)
->and($default['observability']['sample_rate_error'])->toBe(1.0);
});