HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/be.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();
});