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/ai.naniguide.com/tests/Feature/AdsR12_3LinkedInRealImplementationTest.php
<?php

/**
 * Ads Phase R12.3 — LinkedInAdPlatform real LinkedIn Marketing API test suite.
 *
 * Uses Http::fake() to stub api.linkedin.com responses.  No live API.
 *
 * Verifies:
 *   1. Feature flag `ads.hardening.real_linkedin_enabled` defaults OFF
 *   2. Flag OFF → every R1 method delegates to MockAdPlatform (mock_camp_ /
 *      mock_aud_ shape, no HTTP request made)
 *   3. Flag ON → each of 10 methods hits the correct endpoint with expected
 *      payload shape + the LinkedIn-Version + Restli-Protocol-Version headers
 *      and maps responses to the right Result VO
 *   4. Error mapping taxonomy: 401 + "revoked"/"consent" → TokenRevoked; 401
 *      otherwise → TokenExpired; 429 → RateLimit + retry-after; 422 +
 *      policy/review keyword → PolicyViolation; 422 generic → Validation;
 *      400 → Validation; 403 → PolicyViolation; 404 → ResourceNotFound;
 *      5xx → NetworkException; unknown → UnexpectedResponse
 *   5. deleteAudience: 404 / "not found" → STATUS_DELETED (GDPR idempotent
 *      via PARTIAL_UPDATE archive)
 *   6. createCatalog / syncCatalog throw LogicException with R15 hint when
 *      flag ON (LinkedIn catalogs need Showcase/Pages API)
 *   7. URN discipline: bare numeric IDs at the boundary, URNs constructed
 *      internally for `account` field + URL-encoded for `q=owner` queries;
 *      `extractTrailingId()` recovers IDs from URNs
 *   8. PARTIAL_UPDATE pattern: pause/resume + deleteAudience send
 *      `X-RestLi-Method: PARTIAL_UPDATE` header
 */

use App\Dto\Ads\PublishCampaignDto;
use App\Dto\Ads\SyncAudienceDto;
use App\Services\Ads\Exceptions\NetworkException;
use App\Services\Ads\Exceptions\PolicyViolationException;
use App\Services\Ads\Exceptions\RateLimitException;
use App\Services\Ads\Exceptions\ResourceNotFoundException;
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\LinkedInAdPlatform;
use App\Services\Ads\Results\AudienceSyncResult;
use App\Services\Ads\Results\LeadFetchResult;
use App\Services\Ads\Results\MetricsResult;
use App\Services\Ads\Results\PublishResult;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;

uses(Tests\TestCase::class);

beforeEach(function () {
    config(['ads.hardening.real_linkedin_enabled' => true]);
    config(['ads.platforms.linkedin.api_version' => '202404']);
    config(['ads.platforms.linkedin.default_account_id' => '502840441']);
    Http::preventStrayRequests();
});

function liDto(string $name = 'LinkedIn R12.3 Campaign'): PublishCampaignDto
{
    return new PublishCampaignDto(
        campaignId: 'c-' . uniqid(),
        name: $name,
        objective: 'leads',
        dailyBudget: 25.00,
        lifetimeBudget: null,
        budgetCurrency: 'USD',
        bidStrategy: 'CPC',
        scheduleStart: null,
        scheduleEnd: null,
    );
}

// ============================================================
// 1. Flag default
// ============================================================

test('ads.hardening.real_linkedin_enabled defaults to false', function () {
    $defaults = require base_path('config/ads.php');
    expect($defaults['hardening']['real_linkedin_enabled'] ?? true)->toBeFalse();
});

// ============================================================
// 2. Flag OFF → mock delegation (no HTTP fired)
// ============================================================

test('flag OFF — publishCampaign delegates to MockAdPlatform (no HTTP)', function () {
    config(['ads.hardening.real_linkedin_enabled' => false]);
    Http::fake();

    $result = app(LinkedInAdPlatform::class)->publishCampaign('tok', '502840441', liDto(), 'idem-1');

    expect($result->remoteCampaignId)->toStartWith('mock_camp_');
    Http::assertNothingSent();
});

test('flag OFF — pullMetrics delegates to Mock', function () {
    config(['ads.hardening.real_linkedin_enabled' => false]);
    Http::fake();

    $result = app(LinkedInAdPlatform::class)->pullMetrics(
        'tok',
        '502840441',
        Carbon::parse('2026-04-01'),
        Carbon::parse('2026-04-02'),
        ['camp_a'],
        'UTC',
    );

    expect($result)->toBeInstanceOf(MetricsResult::class);
    Http::assertNothingSent();
});

test('flag OFF — syncAudience delegates to Mock', function () {
    config(['ads.hardening.real_linkedin_enabled' => false]);
    Http::fake();

    $dto = new SyncAudienceDto(
        platformAudienceId: null,
        name: 'List',
        description: null,
        hashedEmails: ['hash1', 'hash2'],
    );
    $result = app(LinkedInAdPlatform::class)->syncAudience('tok', '502840441', $dto, 'idem');

    expect($result->platformAudienceId)->toStartWith('mock_aud_');
    Http::assertNothingSent();
});

// ============================================================
// 3. Real API — happy paths (one per method)
// ============================================================

test('publishCampaign — POST /rest/adAccounts/{id}/adCampaigns reads campaign id from x-restli-id header', function () {
    Http::fake([
        'api.linkedin.com/rest/adAccounts/502840441/adCampaigns' => Http::response(
            null,
            201,
            ['x-restli-id' => '7777']
        ),
    ]);

    $result = app(LinkedInAdPlatform::class)->publishCampaign('tok', '502840441', liDto(), 'idem-pub');

    expect($result)->toBeInstanceOf(PublishResult::class)
        ->and($result->status)->toBe(PublishResult::STATUS_PENDING)
        ->and($result->remoteCampaignId)->toBe('7777')
        ->and($result->metadata['real'])->toBeTrue()
        ->and($result->metadata['account_id'])->toBe('502840441');

    Http::assertSent(function ($request) {
        $payload = $request->data();
        return str_contains($request->url(), '/adAccounts/502840441/adCampaigns')
            && ($payload['account'] ?? null) === 'urn:li:sponsoredAccount:502840441'
            && ($payload['objectiveType'] ?? null) === 'LEAD_GENERATION'
            && ($payload['status'] ?? null) === 'PAUSED'
            && isset($payload['dailyBudget']['amount'])
            && $payload['dailyBudget']['amount'] === '25';
    });
});

test('publishCampaign — sends LinkedIn-Version + X-Restli-Protocol-Version headers', function () {
    Http::fake(['*' => Http::response(null, 201, ['x-restli-id' => '1'])]);

    app(LinkedInAdPlatform::class)->publishCampaign('test_token_xyz', '502840441', liDto(), 'idem');

    Http::assertSent(function ($request) {
        return $request->hasHeader('Authorization', 'Bearer test_token_xyz')
            && $request->hasHeader('LinkedIn-Version', '202404')
            && $request->hasHeader('X-Restli-Protocol-Version', '2.0.0');
    });
});

test('publishCampaign — strips urn:li: prefix from account id', function () {
    Http::fake(['*' => Http::response(null, 201, ['x-restli-id' => '1'])]);

    app(LinkedInAdPlatform::class)->publishCampaign('tok', 'urn:li:sponsoredAccount:502840441', liDto(), 'idem');

    Http::assertSent(function ($request) {
        return str_contains($request->url(), '/adAccounts/502840441/adCampaigns');
    });
});

test('publishCampaign — null daily budget omits dailyBudget field', function () {
    Http::fake(['*' => Http::response(null, 201, ['x-restli-id' => '1'])]);

    $dto = new PublishCampaignDto(
        campaignId: 'c1',
        name: 'No-budget',
        objective: 'awareness',
        dailyBudget: null,
        lifetimeBudget: null,
        budgetCurrency: 'USD',
        bidStrategy: null,
        scheduleStart: null,
        scheduleEnd: null,
    );

    app(LinkedInAdPlatform::class)->publishCampaign('tok', '502840441', $dto, 'idem');

    Http::assertSent(function ($request) {
        $data = $request->data();
        return !array_key_exists('dailyBudget', $data)
            && !array_key_exists('totalBudget', $data)
            && ($data['objectiveType'] ?? null) === 'BRAND_AWARENESS';
    });
});

test('pauseCampaign — PARTIAL_UPDATE patch sets status PAUSED', function () {
    Http::fake([
        'api.linkedin.com/rest/adAccounts/502840441/adCampaigns/7777' => Http::response(null, 204),
    ]);

    $result = app(LinkedInAdPlatform::class)->pauseCampaign('tok', '7777', 'idem-p');

    expect($result->status)->toBe(PublishResult::STATUS_PAUSED)
        ->and($result->remoteCampaignId)->toBe('7777');

    Http::assertSent(function ($r) {
        $patch = $r->data()['patch']['$set'] ?? [];
        return $r->hasHeader('X-RestLi-Method', 'PARTIAL_UPDATE')
            && ($patch['status'] ?? null) === 'PAUSED';
    });
});

test('resumeCampaign — PARTIAL_UPDATE patch sets status ACTIVE', function () {
    Http::fake(['*' => Http::response(null, 204)]);

    $result = app(LinkedInAdPlatform::class)->resumeCampaign('tok', '7777', 'idem-r');

    expect($result->status)->toBe(PublishResult::STATUS_ACTIVE);
    Http::assertSent(fn ($r) => ($r->data()['patch']['$set']['status'] ?? null) === 'ACTIVE');
});

test('pullMetrics — GET /rest/adAnalytics with q=analytics + pivot=CAMPAIGN', function () {
    Http::fake([
        'api.linkedin.com/rest/adAnalytics*' => Http::response([
            'elements' => [
                [
                    'pivotValues' => ['urn:li:sponsoredCampaign:7777'],
                    'dateRange' => [
                        'start' => ['year' => 2026, 'month' => 4, 'day' => 1],
                    ],
                    'impressions' => 1000,
                    'clicks' => 50,
                    'costInLocalCurrency' => '12.50',
                    'externalWebsiteConversions' => 3,
                ],
                [
                    'pivotValues' => ['urn:li:sponsoredCampaign:7777'],
                    'dateRange' => [
                        'start' => ['year' => 2026, 'month' => 4, 'day' => 2],
                    ],
                    'impressions' => 1200,
                    'clicks' => 60,
                    'costInLocalCurrency' => '15.00',
                ],
            ],
        ], 200),
    ]);

    $result = app(LinkedInAdPlatform::class)->pullMetrics(
        'tok',
        '502840441',
        Carbon::parse('2026-04-01'),
        Carbon::parse('2026-04-02'),
        ['7777'],
        'UTC',
    );

    expect($result->count())->toBe(2);
    $first = $result->rows[0];
    expect($first->remoteCampaignId)->toBe('7777')
        ->and($first->impressions)->toBe(1000)
        ->and($first->clicks)->toBe(50)
        ->and($first->spend)->toBe(12.5)
        ->and($first->date)->toBe('2026-04-01')
        ->and($first->cpc)->toBe(0.25); // 12.50 / 50

    Http::assertSent(function ($r) {
        $url = $r->url();
        return str_contains($url, 'q=analytics')
            && str_contains($url, 'pivot=CAMPAIGN')
            && str_contains($url, 'timeGranularity=DAILY');
    });
});

test('pullMetrics — empty remoteCampaignIds short-circuits without HTTP', function () {
    Http::fake();
    $result = app(LinkedInAdPlatform::class)->pullMetrics('tok', '502840441', Carbon::now(), Carbon::now(), [], 'UTC');
    expect($result->count())->toBe(0);
    Http::assertNothingSent();
});

test('syncAudience — POST /rest/dmpSegments creates segment when no platformAudienceId', function () {
    Http::fake([
        'api.linkedin.com/rest/dmpSegments' => Http::response(null, 201, ['x-restli-id' => 'seg_555']),
    ]);
    $dto = new SyncAudienceDto(
        platformAudienceId: null,
        name: 'List A',
        description: 'Test',
        hashedEmails: [],
    );

    $result = app(LinkedInAdPlatform::class)->syncAudience('tok', '502840441', $dto, 'idem-a');

    expect($result)->toBeInstanceOf(AudienceSyncResult::class)
        ->and($result->platformAudienceId)->toBe('seg_555')
        ->and($result->status)->toBe(AudienceSyncResult::STATUS_SUCCESS);

    Http::assertSent(function ($r) {
        $data = $r->data();
        return ($data['account'] ?? null) === 'urn:li:sponsoredAccount:502840441'
            && ($data['type'] ?? null) === 'USER'
            && ($data['sourcePlatform'] ?? null) === 'API'
            && str_starts_with(($data['sourceSegmentId'] ?? ''), 'acelle:');
    });
});

test('syncAudience — STATUS_PARTIAL when hashed emails present (upload deferred)', function () {
    Http::fake(['*' => Http::response(null, 201, ['x-restli-id' => 'seg_777'])]);
    $dto = new SyncAudienceDto(
        platformAudienceId: null,
        name: 'List',
        description: null,
        hashedEmails: ['h1', 'h2', 'h3'],
    );

    $result = app(LinkedInAdPlatform::class)->syncAudience('tok', '502840441', $dto, 'idem');

    expect($result->status)->toBe(AudienceSyncResult::STATUS_PARTIAL)
        ->and($result->uploadedCount)->toBe(3);
});

test('syncAudience — reuses provided platformAudienceId, skips create', function () {
    Http::fake();
    $dto = new SyncAudienceDto(
        platformAudienceId: 'existing_seg_999',
        name: 'List',
        description: null,
        hashedEmails: [],
    );

    $result = app(LinkedInAdPlatform::class)->syncAudience('tok', '502840441', $dto, 'idem');

    expect($result->platformAudienceId)->toBe('existing_seg_999');
    Http::assertNothingSent();
});

test('deleteAudience — PARTIAL_UPDATE archives segment with status=ARCHIVED', function () {
    Http::fake(['api.linkedin.com/rest/dmpSegments/seg_555' => Http::response(null, 204)]);

    $result = app(LinkedInAdPlatform::class)->deleteAudience('tok', 'seg_555');

    expect($result->status)->toBe(AudienceSyncResult::STATUS_DELETED);
    Http::assertSent(function ($r) {
        $patch = $r->data()['patch']['$set'] ?? [];
        return $r->hasHeader('X-RestLi-Method', 'PARTIAL_UPDATE')
            && ($patch['status'] ?? null) === 'ARCHIVED';
    });
});

test('deleteAudience — 404 not-found treated as STATUS_DELETED (GDPR idempotent)', function () {
    Http::fake(['*' => Http::response(['message' => 'segment not found', 'status' => 404], 404)]);

    $result = app(LinkedInAdPlatform::class)->deleteAudience('tok', 'seg_missing');

    expect($result->status)->toBe(AudienceSyncResult::STATUS_DELETED);
});

test('getLeadForms — GET /rest/leadForms with q=owner returns normalized form shape', function () {
    Http::fake([
        'api.linkedin.com/rest/leadForms*' => Http::response([
            'elements' => [
                [
                    'id' => 'form_111',
                    'name' => 'Contact Us',
                    'status' => 'ACTIVE',
                    'questions' => [['questionId' => 1, 'inputType' => 'EMAIL']],
                    'createdAt' => '2026-04-01T00:00:00',
                ],
                [
                    'id' => 'form_222',
                    'name' => 'Newsletter',
                    'status' => 'DRAFT',
                    'questions' => [],
                ],
            ],
        ], 200),
    ]);

    $forms = app(LinkedInAdPlatform::class)->getLeadForms('tok', '502840441');

    expect($forms)->toBeArray()->toHaveCount(2)
        ->and($forms[0]['id'])->toBe('form_111')
        ->and($forms[0]['name'])->toBe('Contact Us')
        ->and($forms[1]['status'])->toBe('DRAFT');

    Http::assertSent(function ($r) {
        $url = $r->url();
        return str_contains($url, 'q=owner')
            && str_contains($url, 'sponsoredAccount');
    });
});

test('fetchLeads — GET /rest/leadFormResponses extracts platform_lead_id + answers', function () {
    Http::fake([
        'api.linkedin.com/rest/leadFormResponses*' => Http::response([
            'elements' => [
                [
                    'id' => 'lead_1',
                    'submittedAt' => 1745539200000, // 2026-04-25 (in ms)
                    'answers' => [
                        ['questionId' => 'EMAIL', 'rawAnswer' => '[email protected]'],
                        ['questionId' => 'FULL_NAME', 'rawAnswer' => 'Alice'],
                    ],
                ],
            ],
            'paging' => ['count' => 1, 'start' => 0, 'total' => 1],
        ], 200),
    ]);

    $result = app(LinkedInAdPlatform::class)->fetchLeads('tok', 'form_111', null);

    expect($result)->toBeInstanceOf(LeadFetchResult::class);
    expect($result->leads)->toHaveCount(1);
    expect($result->leads[0]['platform_lead_id'])->toBe('lead_1');
    expect($result->leads[0]['fields']['EMAIL'])->toBe('[email protected]');
    expect($result->leads[0]['fields']['FULL_NAME'])->toBe('Alice');
    expect($result->nextCursor)->toBeNull();
});

test('fetchLeads — multi-page total > current sets nextCursor to accountId:nextStart', function () {
    Http::fake([
        '*' => Http::response([
            'elements' => [
                ['id' => 'lead_1', 'submittedAt' => 1745539200000, 'answers' => []],
            ],
            'paging' => ['count' => 1, 'start' => 0, 'total' => 5],
        ], 200),
    ]);

    $result = app(LinkedInAdPlatform::class)->fetchLeads('tok', 'form_111', null);

    expect($result->nextCursor)->toBe('502840441:1');
});

test('fetchLeads — missing default_account_id without cursor throws clear error', function () {
    config(['ads.platforms.linkedin.default_account_id' => null]);

    expect(fn () => app(LinkedInAdPlatform::class)->fetchLeads('tok', 'form_111', null))
        ->toThrow(\InvalidArgumentException::class, 'accountId');
});

// R15.4 retargeted these two tests: LinkedIn catalog methods used to throw
// `LogicException` pointing at R15; that placeholder is now filled.  With
// the catalog flag OFF (default for this test file) the methods delegate
// to MockAdPlatform; flag-ON behavior + error mapping is covered by
// AdsR15_4LinkedInDynamicAdsCatalogTest.  Keep two assertions here that
// the independent flag matrix R15.4 introduced holds: real_linkedin_enabled
// alone does NOT enable real catalog calls.
test('catalog flag OFF — createCatalog delegates to mock even when real_linkedin_enabled is ON', function () {
    config([
        'ads.hardening.real_linkedin_enabled' => true,
        'ads.hardening.real_linkedin_catalog_enabled' => false,
    ]);
    Http::fake();
    $id = app(LinkedInAdPlatform::class)->createCatalog('tok', '502840441', 'cat');
    expect($id)->toBeString()->and($id)->not->toBeEmpty();
    Http::assertNothingSent();
});

test('catalog flag OFF — syncCatalog delegates to mock even when real_linkedin_enabled is ON', function () {
    config([
        'ads.hardening.real_linkedin_enabled' => true,
        'ads.hardening.real_linkedin_catalog_enabled' => false,
    ]);
    Http::fake();
    $result = app(LinkedInAdPlatform::class)->syncCatalog('tok', 'cat_1', [], 'idem');
    expect($result)->toBeInstanceOf(\App\Services\Ads\Results\CatalogSyncResult::class);
    Http::assertNothingSent();
});

test('flag OFF — createCatalog falls through to mock without throwing', function () {
    config(['ads.hardening.real_linkedin_enabled' => false]);
    Http::fake();

    $catId = app(LinkedInAdPlatform::class)->createCatalog('tok', '502840441', 'cat');

    expect($catId)->toBeString();
    expect(strlen($catId))->toBeGreaterThan(0);
    Http::assertNothingSent();
});

test('pause/resume — missing default_account_id throws InvalidArgumentException', function () {
    config(['ads.platforms.linkedin.default_account_id' => null]);

    expect(fn () => app(LinkedInAdPlatform::class)->pauseCampaign('tok', '7777', 'idem'))
        ->toThrow(\InvalidArgumentException::class, 'default_account_id');
});

// ============================================================
// 4. Error mapping taxonomy
// ============================================================

test('error: 401 generic → TokenExpiredException', function () {
    Http::fake(['*' => Http::response(['message' => 'Empty oauth2_access_token', 'serviceErrorCode' => 401, 'status' => 401], 401)]);

    expect(fn () => app(LinkedInAdPlatform::class)->pullMetrics('tok', '502840441', Carbon::now(), Carbon::now(), ['x'], 'UTC'))
        ->toThrow(TokenExpiredException::class);
});

test('error: 401 + revoke keyword → TokenRevokedException', function () {
    Http::fake(['*' => Http::response(['message' => 'User revoked access', 'serviceErrorCode' => 65601, 'status' => 401], 401)]);

    expect(fn () => app(LinkedInAdPlatform::class)->pullMetrics('tok', '502840441', Carbon::now(), Carbon::now(), ['x'], 'UTC'))
        ->toThrow(TokenRevokedException::class);
});

test('error: 429 → RateLimitException with retry-after', function () {
    Http::fake(['*' => Http::response(
        ['message' => 'Resource level throttle limit reached', 'serviceErrorCode' => 429, 'status' => 429],
        429,
        ['Retry-After' => '3600']
    )]);

    $caught = null;
    try {
        app(LinkedInAdPlatform::class)->pullMetrics('tok', '502840441', Carbon::now(), Carbon::now(), ['x'], 'UTC');
    } catch (RateLimitException $e) {
        $caught = $e;
    }
    expect($caught)->toBeInstanceOf(RateLimitException::class);
    expect($caught?->getRetryAfter())->toBe(3600);
});

test('error: 422 + policy keyword → PolicyViolationException', function () {
    Http::fake(['*' => Http::response(['message' => 'Creative violates policy guidelines', 'serviceErrorCode' => 4220, 'status' => 422], 422)]);

    expect(fn () => app(LinkedInAdPlatform::class)->publishCampaign('tok', '502840441', liDto(), 'idem'))
        ->toThrow(PolicyViolationException::class);
});

test('error: 422 generic → ValidationException', function () {
    Http::fake(['*' => Http::response(['message' => 'Field budget is required', 'serviceErrorCode' => 4221, 'status' => 422], 422)]);

    expect(fn () => app(LinkedInAdPlatform::class)->publishCampaign('tok', '502840441', liDto(), 'idem'))
        ->toThrow(ValidationException::class);
});

test('error: 400 → ValidationException', function () {
    Http::fake(['*' => Http::response(['message' => 'Bad request shape', 'serviceErrorCode' => 400, 'status' => 400], 400)]);

    expect(fn () => app(LinkedInAdPlatform::class)->publishCampaign('tok', '502840441', liDto(), 'idem'))
        ->toThrow(ValidationException::class);
});

test('error: 403 → PolicyViolationException', function () {
    Http::fake(['*' => Http::response(['message' => 'Permission denied for ad account', 'serviceErrorCode' => 403, 'status' => 403], 403)]);

    expect(fn () => app(LinkedInAdPlatform::class)->getLeadForms('tok', '502840441'))
        ->toThrow(PolicyViolationException::class);
});

test('error: 404 → ResourceNotFoundException', function () {
    Http::fake(['*' => Http::response(['message' => 'Campaign not found', 'serviceErrorCode' => 404, 'status' => 404], 404)]);

    expect(fn () => app(LinkedInAdPlatform::class)->resumeCampaign('tok', '7777', 'idem'))
        ->toThrow(ResourceNotFoundException::class);
});

test('error: 503 → NetworkException', function () {
    Http::fake(['*' => Http::response(['message' => 'service unavailable'], 503)]);

    expect(fn () => app(LinkedInAdPlatform::class)->pullMetrics('tok', '502840441', Carbon::now(), Carbon::now(), ['x'], 'UTC'))
        ->toThrow(NetworkException::class);
});

test('error: unknown 418 → UnexpectedResponseException', function () {
    Http::fake(['*' => Http::response(['message' => "I'm a teapot"], 418)]);

    expect(fn () => app(LinkedInAdPlatform::class)->pullMetrics('tok', '502840441', Carbon::now(), Carbon::now(), ['x'], 'UTC'))
        ->toThrow(UnexpectedResponseException::class);
});

// ============================================================
// 5. Method-override coverage (no R1 stub leakage)
// ============================================================

test('every R1-extended method is overridden on LinkedInAdPlatform (no trait fallthrough at runtime)', function () {
    $reflection = new ReflectionClass(LinkedInAdPlatform::class);
    $methods = [
        'publishCampaign', 'pauseCampaign', 'resumeCampaign', 'pullMetrics',
        'syncAudience', 'deleteAudience', 'getLeadForms', 'fetchLeads',
        'createCatalog', 'syncCatalog',
    ];
    foreach ($methods as $method) {
        $declared = $reflection->getMethod($method)->getDeclaringClass()->getName();
        expect($declared)->toBe(LinkedInAdPlatform::class, "method {$method} not overridden");
    }
});