File: /home/xedaptot/ai.naniguide.com/tests/Feature/AdsR0SecurityFoundationTest.php
<?php
/**
* Ads Phase R0 — Security Foundation test suite.
*
* Verifies:
* 1. Encrypted casts on AdPlatform.metadata, AdLeadFormConfig.verify_token,
* AdAutomationRule.webhook_secret (raw DB = ciphertext, Eloquent = plaintext)
* 2. idempotency_key column on ad_publish_logs (fillable + indexed query)
* 3. Exception taxonomy — each subclass carries correct `isRetryable()` default
* 4. AdapterSession binds AdPlatform + decorated chain
* 5. AbstractAdapterDecorator forwards all 8 interface methods
* 6. IdempotencyDecorator::remember() caches — 2nd call with same key skips inner
* 7. RetryWithBackoffDecorator::retryable() — retries NetworkException,
* skips ValidationException, honors retryAfterSeconds
* 8. AdPlatformManager::session() returns 6-layer chain
* 9. AdPlatformManager::session() throws PlatformKillSwitchException when killed
* 10. AdPlatformManager::adapter() (stateless) returns 3-layer chain
* 11. AbstractAdsJob — queue/tags/failed() scaffolding
*/
use App\Jobs\Ads\AbstractAdsJob;
use App\Jobs\Ads\PublishAdCampaign;
use App\Model\AdAutomationRule;
use App\Model\AdLeadFormConfig;
use App\Model\AdPlatform;
use App\Model\AdPublishLog;
use App\Services\Ads\AdapterSession;
use App\Services\Ads\AdPlatformAdapter;
use App\Services\Ads\AdPlatformException;
use App\Services\Ads\AdPlatformManager;
use App\Services\Ads\Decorators\AbstractAdapterDecorator;
use App\Services\Ads\Decorators\CircuitBreakerDecorator;
use App\Services\Ads\Decorators\IdempotencyDecorator;
use App\Services\Ads\Decorators\RateLimitDecorator;
use App\Services\Ads\Decorators\RetryWithBackoffDecorator;
use App\Services\Ads\Decorators\TelemetryDecorator;
use App\Services\Ads\Decorators\TokenRefreshDecorator;
use App\Services\Ads\Exceptions\CircuitOpenException;
use App\Services\Ads\Exceptions\InsufficientScopeException;
use App\Services\Ads\Exceptions\NetworkException;
use App\Services\Ads\Exceptions\PlatformKillSwitchException;
use App\Services\Ads\Exceptions\PolicyViolationException;
use App\Services\Ads\Exceptions\RateLimitException;
use App\Services\Ads\Exceptions\TimeoutException;
use App\Services\Ads\Exceptions\TokenExpiredException;
use App\Services\Ads\Exceptions\TokenRevokedException;
use App\Services\Ads\Exceptions\UnexpectedResponseException;
use App\Services\Ads\Exceptions\ValidationException;
use App\Services\Ads\HealthResult;
use App\Services\Ads\MockAdPlatform;
use App\Services\Ads\TestConnectionResult;
use App\Services\Ads\TokenResult;
use Illuminate\Support\Facades\Cache;
uses(Tests\TestCase::class);
// ============================================================
// 1. Encrypted casts
// ============================================================
test('AdPlatform.metadata encrypted roundtrip (plaintext in Eloquent, ciphertext in DB)', function () {
$platform = AdPlatform::first();
if (!$platform) $this->markTestSkipped('No AdPlatform seeded — run DemoSeeder');
$platform->metadata = ['test' => 'r0-metadata', 'nested' => ['k' => 'v']];
$platform->save();
// Eloquent decrypts
expect($platform->fresh()->metadata)->toBe(['test' => 'r0-metadata', 'nested' => ['k' => 'v']]);
// Raw DB is Laravel-encrypted JSON (base64 starts with "eyJpdiI6")
$raw = \DB::table('ad_platforms')->where('id', $platform->id)->value('metadata');
expect($raw)->toStartWith('eyJpdiI6');
// Cleanup
$platform->metadata = null;
$platform->save();
});
test('AdLeadFormConfig.verify_token encrypted roundtrip', function () {
$config = AdLeadFormConfig::first();
if (!$config) $this->markTestSkipped('No AdLeadFormConfig seeded');
$config->verify_token = 'secret-fb-challenge-r0';
$config->save();
expect($config->fresh()->verify_token)->toBe('secret-fb-challenge-r0');
$raw = \DB::table('ad_lead_form_configs')->where('id', $config->id)->value('verify_token');
expect($raw)->toStartWith('eyJpdiI6');
$config->verify_token = null;
$config->save();
});
test('AdAutomationRule.webhook_secret encrypted roundtrip', function () {
$rule = AdAutomationRule::first();
if (!$rule) $this->markTestSkipped('No AdAutomationRule seeded');
$rule->webhook_secret = 'hmac-r0';
$rule->save();
expect($rule->fresh()->webhook_secret)->toBe('hmac-r0');
$raw = \DB::table('ad_automation_rules')->where('id', $rule->id)->value('webhook_secret');
expect($raw)->toStartWith('eyJpdiI6');
$rule->webhook_secret = null;
$rule->save();
});
// ============================================================
// 2. Idempotency key column
// ============================================================
test('AdPublishLog.idempotency_key is fillable and queryable', function () {
expect((new AdPublishLog())->getFillable())->toContain('idempotency_key');
});
test('AdPublishLog has STATUS_REJECTED constant', function () {
expect(AdPublishLog::STATUS_REJECTED)->toBe('rejected');
});
test('ad_publish_logs table has idempotency_key column indexed', function () {
expect(\Schema::hasColumn('ad_publish_logs', 'idempotency_key'))->toBeTrue();
$indexes = \DB::select("SHOW INDEX FROM " . \DB::getTablePrefix() . "ad_publish_logs WHERE Key_name = 'idx_publishlog_idem'");
expect($indexes)->not->toBeEmpty();
});
test('ad_lead_form_configs has verify_token and webhook_registered_at columns', function () {
expect(\Schema::hasColumn('ad_lead_form_configs', 'verify_token'))->toBeTrue();
expect(\Schema::hasColumn('ad_lead_form_configs', 'webhook_registered_at'))->toBeTrue();
});
test('ad_automation_rules has webhook_secret column', function () {
expect(\Schema::hasColumn('ad_automation_rules', 'webhook_secret'))->toBeTrue();
});
// ============================================================
// 3. Exception taxonomy
// ============================================================
test('AdPlatformException base class carries all new properties', function () {
$e = new AdPlatformException(
message: 'test',
platformCode: 'CODE',
resolutionHint: 'hint',
response: ['raw' => 'data'],
retryAfterSeconds: 42,
context: ['trace_id' => 'abc'],
);
expect($e->getMessage())->toBe('test');
expect($e->getPlatformCode())->toBe('CODE');
expect($e->getResolutionHint())->toBe('hint');
expect($e->getResponse())->toBe(['raw' => 'data']);
expect($e->getRetryAfter())->toBe(42);
expect($e->getContext())->toBe(['trace_id' => 'abc']);
expect($e->isRetryable())->toBeFalse();
});
test('NetworkException is retryable', function () {
expect((new NetworkException('net down'))->isRetryable())->toBeTrue();
});
test('TimeoutException extends NetworkException and is retryable', function () {
$e = new TimeoutException('timeout');
expect($e)->toBeInstanceOf(NetworkException::class);
expect($e->isRetryable())->toBeTrue();
});
test('RateLimitException is retryable and carries retryAfter', function () {
$e = new RateLimitException('429', retryAfterSeconds: 60);
expect($e->isRetryable())->toBeTrue();
expect($e->getRetryAfter())->toBe(60);
});
test('TokenExpiredException is retryable (triggers refresh decorator)', function () {
expect((new TokenExpiredException('expired'))->isRetryable())->toBeTrue();
});
test('TokenRevokedException is NOT retryable', function () {
expect((new TokenRevokedException('revoked'))->isRetryable())->toBeFalse();
});
test('ValidationException is NOT retryable', function () {
expect((new ValidationException('bad payload'))->isRetryable())->toBeFalse();
});
test('PolicyViolationException is NOT retryable', function () {
expect((new PolicyViolationException('policy'))->isRetryable())->toBeFalse();
});
test('UnexpectedResponseException is retryable', function () {
expect((new UnexpectedResponseException('schema drift'))->isRetryable())->toBeTrue();
});
test('CircuitOpenException is retryable with retryAfter', function () {
$e = new CircuitOpenException('breaker', retryAfterSeconds: 30);
expect($e->isRetryable())->toBeTrue();
expect($e->getRetryAfter())->toBe(30);
});
test('InsufficientScopeException carries missing scopes list', function () {
$e = new InsufficientScopeException('need scopes', missingScopes: ['ads_management', 'business_management']);
expect($e->getMissingScopes())->toBe(['ads_management', 'business_management']);
expect($e->getResolutionHint())->toContain('ads_management, business_management');
});
test('PlatformKillSwitchException carries platform + reason', function () {
$e = new PlatformKillSwitchException(platform: 'meta', reason: 'Billing dispute');
expect($e->getPlatform())->toBe('meta');
expect($e->getMessage())->toContain('meta')->toContain('Billing dispute');
expect($e->getPlatformCode())->toBe('KILL_SWITCH');
});
// ============================================================
// 4. AdapterSession
// ============================================================
test('AdapterSession binds platform and chain as readonly', function () {
$platform = AdPlatform::first();
if (!$platform) $this->markTestSkipped('No AdPlatform seeded');
$chain = new TelemetryDecorator(app(MockAdPlatform::class));
$session = new AdapterSession($platform, $chain);
expect($session->platform)->toBe($platform);
expect($session->chain)->toBe($chain);
// Readonly — reassignment should fail
expect(fn () => $session->platform = AdPlatform::find(2))
->toThrow(\Error::class);
});
// ============================================================
// 5. AbstractAdapterDecorator forwarding
// ============================================================
test('AbstractAdapterDecorator forwards all 8 interface methods to inner', function () {
// Use ProvidesR1StubImplementations trait so the anonymous class covers all
// 18 interface methods — the 10 R1 methods throw LogicException if called,
// but this test only exercises the 8 phase-A methods so they're untouched.
$inner = new class implements AdPlatformAdapter {
use \App\Services\Ads\ProvidesR1StubImplementations;
public array $calls = [];
public function getAuthUrl(string $redirectUri, array $scopes, string $state): string {
$this->calls[] = 'getAuthUrl';
return 'https://example.com/auth';
}
public function handleCallback(string $code, string $redirectUri): TokenResult {
$this->calls[] = 'handleCallback';
return new TokenResult(accessToken: 'x');
}
public function refreshToken(string $refreshToken): TokenResult {
$this->calls[] = 'refreshToken';
return new TokenResult(accessToken: 'y');
}
public function getAdAccounts(string $accessToken): array {
$this->calls[] = 'getAdAccounts';
return [];
}
public function healthCheck(string $accessToken): HealthResult {
$this->calls[] = 'healthCheck';
return new HealthResult(status: 'healthy');
}
public function getPlatformType(): string { return 'mock'; }
public function getDefaultScopes(): array { return []; }
public function testAppCredentials(array $creds): TestConnectionResult {
$this->calls[] = 'testAppCredentials';
return new TestConnectionResult(status: TestConnectionResult::STATUS_SUCCESS, message: 'ok');
}
};
// Anonymous concrete decorator (no overrides — pure forwarding)
$decorator = new class($inner) extends AbstractAdapterDecorator {};
$decorator->getAuthUrl('cb', [], 's');
$decorator->handleCallback('code', 'cb');
$decorator->refreshToken('r');
$decorator->getAdAccounts('t');
$decorator->healthCheck('t');
$decorator->testAppCredentials([]);
expect($inner->calls)->toBe(['getAuthUrl', 'handleCallback', 'refreshToken', 'getAdAccounts', 'healthCheck', 'testAppCredentials']);
});
// ============================================================
// 6. IdempotencyDecorator
// ============================================================
test('IdempotencyDecorator::remember caches result by key', function () {
Cache::flush();
$inner = app(MockAdPlatform::class);
$decorator = new IdempotencyDecorator($inner);
$counter = 0;
$key = 'test-key-' . uniqid();
$callback = function () use (&$counter) {
$counter++;
return ['result' => 'v1'];
};
$r1 = $decorator->remember('publishCampaign', $key, $callback);
$r2 = $decorator->remember('publishCampaign', $key, $callback);
expect($counter)->toBe(1); // inner called only once
expect($r1)->toBe($r2);
expect($r1)->toBe(['result' => 'v1']);
});
test('IdempotencyDecorator::forget clears cached entry', function () {
Cache::flush();
$inner = app(MockAdPlatform::class);
$decorator = new IdempotencyDecorator($inner);
$counter = 0;
$key = 'test-forget-' . uniqid();
$cb = function () use (&$counter) { $counter++; return 'x'; };
$decorator->remember('op', $key, $cb);
$decorator->forget('op', $key);
$decorator->remember('op', $key, $cb);
expect($counter)->toBe(2);
});
// ============================================================
// 7. RetryWithBackoffDecorator
// ============================================================
test('RetryWithBackoffDecorator retries NetworkException up to max attempts', function () {
$inner = app(MockAdPlatform::class);
$decorator = new RetryWithBackoffDecorator($inner);
$calls = 0;
$callback = function () use (&$calls) {
$calls++;
if ($calls < 3) {
throw new NetworkException('transient', retryAfterSeconds: 0);
}
return 'success';
};
$result = $decorator->retryable('test', $callback);
expect($result)->toBe('success');
expect($calls)->toBe(3);
});
test('RetryWithBackoffDecorator does NOT retry ValidationException', function () {
$inner = app(MockAdPlatform::class);
$decorator = new RetryWithBackoffDecorator($inner);
$calls = 0;
$callback = function () use (&$calls) {
$calls++;
throw new ValidationException('400');
};
expect(fn () => $decorator->retryable('test', $callback))
->toThrow(ValidationException::class);
expect($calls)->toBe(1);
});
test('RetryWithBackoffDecorator gives up after max attempts', function () {
config(['ads.platforms.mock.retry' => ['max_attempts' => 2, 'base_delay_ms' => 0]]);
$inner = app(MockAdPlatform::class);
$decorator = new RetryWithBackoffDecorator($inner);
$calls = 0;
$callback = function () use (&$calls) {
$calls++;
throw new NetworkException('always fails', retryAfterSeconds: 0);
};
expect(fn () => $decorator->retryable('test', $callback))
->toThrow(NetworkException::class);
expect($calls)->toBe(2);
});
// ============================================================
// 8-10. AdPlatformManager session + kill switch
// ============================================================
test('AdPlatformManager::session returns 6-layer decorator chain', function () {
$platform = AdPlatform::first();
if (!$platform) $this->markTestSkipped('No AdPlatform seeded');
$manager = app(AdPlatformManager::class);
$session = $manager->session($platform);
expect($session)->toBeInstanceOf(AdapterSession::class);
expect($session->chain)->toBeInstanceOf(TelemetryDecorator::class);
// Unwrap and verify each layer
$chain = $session->chain;
$layers = [];
while (true) {
$layers[] = class_basename($chain);
$ref = new \ReflectionClass($chain);
if (!$ref->hasProperty('inner')) break;
$prop = $ref->getProperty('inner');
$prop->setAccessible(true);
$chain = $prop->getValue($chain);
if ($chain === null) break;
}
expect($layers)->toEqual([
'TelemetryDecorator',
'TokenRefreshDecorator',
'CircuitBreakerDecorator',
'RateLimitDecorator',
'RetryWithBackoffDecorator',
'IdempotencyDecorator',
class_basename($chain), // raw adapter
]);
});
test('AdPlatformManager::session throws PlatformKillSwitchException when kill switch enabled', function () {
$platform = AdPlatform::first();
if (!$platform) $this->markTestSkipped('No AdPlatform seeded');
config(["ads.kill_switches.{$platform->platform}" => true, "ads.kill_switches.{$platform->platform}_reason" => 'maintenance']);
$manager = app(AdPlatformManager::class);
try {
$manager->session($platform);
$this->fail('Expected PlatformKillSwitchException');
} catch (PlatformKillSwitchException $e) {
expect($e->getPlatform())->toBe($platform->platform);
expect($e->getMessage())->toContain('maintenance');
} finally {
config(["ads.kill_switches.{$platform->platform}" => false]);
}
});
test('AdPlatformManager::adapter (stateless) returns 3-layer chain', function () {
$manager = app(AdPlatformManager::class);
$adapter = $manager->adapter('meta');
// Outer: Telemetry → Retry → Idempotency → raw
expect($adapter)->toBeInstanceOf(TelemetryDecorator::class);
$chain = $adapter;
$layers = [];
while (true) {
$layers[] = class_basename($chain);
$ref = new \ReflectionClass($chain);
if (!$ref->hasProperty('inner')) break;
$prop = $ref->getProperty('inner');
$prop->setAccessible(true);
$chain = $prop->getValue($chain);
if ($chain === null) break;
}
expect($layers[0])->toBe('TelemetryDecorator');
expect($layers[1])->toBe('RetryWithBackoffDecorator');
expect($layers[2])->toBe('IdempotencyDecorator');
expect($layers)->toHaveCount(4); // 3 decorators + raw adapter
});
// ============================================================
// 11. AbstractAdsJob
// ============================================================
test('AbstractAdsJob safe default: queue=default so existing workers pick up ads jobs', function () {
// R0 hotfix: default queue is "default" (not "ads") so Acelle self-install customers
// running plain `queue:work` without --queue flag don't see ads jobs hang.
// Customers opt in to queue isolation via ADS_QUEUE_NAME=ads in .env.
$job = new PublishAdCampaign(1, [1, 2]);
expect($job->queue)->toBe('default');
expect($job->tries)->toBe(5);
expect($job->backoff)->toBe([60, 300, 900, 3600, 14400]);
expect($job->timeout)->toBe(300);
});
test('AbstractAdsJob opt-in: queue routing honors ADS_QUEUE_NAME env', function () {
config(['ads.queues.default' => 'ads']);
$job = new PublishAdCampaign(1, [1]);
expect($job->queue)->toBe('ads');
config(['ads.queues.default' => 'default']); // restore
});
test('AbstractAdsJob::tags includes class basename + ads marker', function () {
$job = new PublishAdCampaign(1, [1]);
$tags = $job->tags();
expect($tags)->toContain('ads');
expect($tags)->toContain('PublishAdCampaign');
});
test('AbstractAdsJob forces failed() via abstract signature', function () {
$reflector = new \ReflectionClass(AbstractAdsJob::class);
$failedMethod = $reflector->getMethod('failed');
expect($failedMethod->isAbstract())->toBeTrue();
});