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");
}
});