File: /home/xedaptot/be.naniguide.com/tests/Feature/AdsR15_0CatalogSubstrateTest.php
<?php
/**
* Ads Phase R15.0 — Product Catalog substrate.
*
* Verifies:
* 1. Migration applied — `ad_platform_id`, `platform_catalog_id`,
* `last_idempotency_key`, `error_log` on catalogs; `platform_retailer_ids`,
* `last_synced_at`, `last_sync_error` on products.
* 2. Model fillable + casts for new columns.
* 3. SyncCatalogDto::fromCatalog projects products into the primitives shape
* adapter expects, drops Eloquent-only fields.
* 4. ProductImageValidator HEAD-checks each URL and tags `_image_error` on
* bad rows; missing scheme, 404, connection error all routed to stable
* error codes.
* 5. Service flag-OFF preserves the legacy mock-fixture path even with a
* bound platform (legacy customers see no behavior change).
* 6. Service flag-ON without ad_platform_id falls back to mock path
* (additive: catalogs without binding never accidentally hit a real
* platform).
* 7. Service flag-ON + bound platform routes through the decorator chain —
* `createCatalog` once, `syncCatalog` with the DTO + idempotency key.
* 8. Idempotency key is deterministic: same inputs → same key, but a price
* / image / availability change rotates it.
* 9. Per-product retailer IDs persisted on success.
* 10. Per-product errors persisted on partial failure (`last_sync_error`),
* catalog `error_log` JSON populated, status flips to PARTIAL.
* 11. Image-validator-rejected products never reach the adapter (filtered
* from DTO) but DO show up in catalog error_log.
*/
use App\Dto\Ads\SyncCatalogDto;
use App\Model\AdPlatform;
use App\Model\AdProduct;
use App\Model\AdProductCatalog;
use App\Services\Ads\AdPlatformManager;
use App\Services\Ads\AdProductCatalogService;
use App\Services\Ads\MockAdPlatform;
use App\Services\Ads\ProductImageValidator;
use App\Services\Ads\Results\CatalogSyncResult;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
uses(Tests\TestCase::class);
uses(DatabaseTransactions::class);
/**
* Build customer + catalog + N products. Optionally binds a platform.
*
* @return array{customerId:int, catalog:AdProductCatalog, platform:?AdPlatform}
*/
function r15Scenario(bool $bindPlatform = false, int $productCount = 3, array $catalogOverrides = []): array
{
$customerId = (int) DB::table('customers')->insertGetId([
'uid' => 'r15cus_' . uniqid(),
'timezone' => 'UTC',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
$platform = null;
if ($bindPlatform) {
$platform = new AdPlatform([
'uid' => 'r15plat_' . uniqid(),
'customer_id' => $customerId,
'platform' => 'meta',
'name' => 'R15 Test Platform',
'access_token' => 'r15tok_' . uniqid(),
'platform_user_id' => 'plat_' . uniqid(),
'platform_user_name' => 'r15user',
]);
$platform->status = AdPlatform::STATUS_ACTIVE;
$platform->health_status = AdPlatform::HEALTH_HEALTHY;
$platform->save();
}
$catalog = AdProductCatalog::create(array_merge([
'uid' => 'r15cat_' . uniqid(),
'customer_id' => $customerId,
'ad_platform_id' => $platform?->id,
'name' => 'R15 Test Catalog',
'source_type' => AdProductCatalog::SOURCE_MANUAL,
'sync_status' => AdProductCatalog::SYNC_PENDING,
], $catalogOverrides));
for ($i = 0; $i < $productCount; $i++) {
AdProduct::create([
'ad_product_catalog_id' => $catalog->id,
'external_id' => 'r15prod_' . $i,
'title' => "R15 Product {$i}",
'price' => 10.00 + $i,
'currency' => 'USD',
'availability' => 'in_stock',
'image_url' => "https://example.test/img/{$i}.jpg",
'url' => "https://example.test/p/{$i}",
]);
}
return ['customerId' => $customerId, 'catalog' => $catalog->fresh(), 'platform' => $platform];
}
// ---------------------------------------------------------------------------
// 1. Migration applied — columns present
// ---------------------------------------------------------------------------
test('R15 migration adds platform-binding columns to ad_product_catalogs', function () {
expect(Schema::hasColumn('ad_product_catalogs', 'ad_platform_id'))->toBeTrue();
expect(Schema::hasColumn('ad_product_catalogs', 'platform_catalog_id'))->toBeTrue();
expect(Schema::hasColumn('ad_product_catalogs', 'last_idempotency_key'))->toBeTrue();
expect(Schema::hasColumn('ad_product_catalogs', 'error_log'))->toBeTrue();
});
test('R15 migration adds per-product platform-state columns to ad_products', function () {
expect(Schema::hasColumn('ad_products', 'platform_retailer_ids'))->toBeTrue();
expect(Schema::hasColumn('ad_products', 'last_synced_at'))->toBeTrue();
expect(Schema::hasColumn('ad_products', 'last_sync_error'))->toBeTrue();
});
// ---------------------------------------------------------------------------
// 2. Model fillable + casts
// ---------------------------------------------------------------------------
test('AdProductCatalog fillable carries new R15 columns', function () {
$catalog = new AdProductCatalog();
foreach (['ad_platform_id', 'platform_catalog_id', 'last_idempotency_key', 'error_log'] as $col) {
expect($catalog->getFillable())->toContain($col);
}
});
test('AdProductCatalog casts error_log as json', function () {
$catalog = new AdProductCatalog();
expect($catalog->getCasts())->toHaveKey('error_log');
expect($catalog->getCasts()['error_log'])->toBe('json');
});
test('AdProduct casts platform_retailer_ids as json + last_synced_at as datetime', function () {
$p = new AdProduct();
expect($p->getCasts())->toHaveKey('platform_retailer_ids');
expect($p->getCasts()['platform_retailer_ids'])->toBe('json');
expect($p->getCasts())->toHaveKey('last_synced_at');
expect($p->getCasts()['last_synced_at'])->toBe('datetime');
});
test('AdProductCatalog adds SOURCE_WOOCOMMERCE constant + SYNC_PARTIAL constant', function () {
expect(AdProductCatalog::SOURCE_WOOCOMMERCE)->toBe('woocommerce');
expect(AdProductCatalog::SOURCES)->toContain('woocommerce');
expect(AdProductCatalog::SYNC_PARTIAL)->toBe('partial');
});
// ---------------------------------------------------------------------------
// 3. DTO projection
// ---------------------------------------------------------------------------
test('SyncCatalogDto::fromCatalog projects products to primitives-only shape', function () {
$scenario = r15Scenario(bindPlatform: false, productCount: 2);
$catalog = $scenario['catalog'];
$dto = SyncCatalogDto::fromCatalog($catalog, 'idempkey_test_123');
expect($dto->platformCatalogId)->toBeNull();
expect($dto->name)->toBe('R15 Test Catalog');
expect($dto->idempotencyKey)->toBe('idempkey_test_123');
expect($dto->products)->toHaveCount(2);
expect($dto->products[0])->toHaveKeys(['external_id', 'title', 'price', 'currency', 'image_url', 'product_url', 'availability']);
expect($dto->products[0]['external_id'])->toBe('r15prod_0');
});
test('SyncCatalogDto::toArray omits the full product list (PII / log-bound)', function () {
$scenario = r15Scenario(bindPlatform: false, productCount: 2);
$dto = SyncCatalogDto::fromCatalog($scenario['catalog'], 'k');
$arr = $dto->toArray();
expect($arr)->toHaveKey('product_count')->and($arr['product_count'])->toBe(2);
expect($arr)->not->toHaveKey('products');
});
// ---------------------------------------------------------------------------
// 4. Image validator
// ---------------------------------------------------------------------------
test('ProductImageValidator passes URLs that respond 200 OK', function () {
Http::fake(['*' => Http::response('', 200)]);
$v = new ProductImageValidator(timeoutMs: 1000, enabled: true);
$result = $v->validate([
['external_id' => '1', 'image_url' => 'https://cdn.test/a.jpg'],
]);
expect($result[0])->not->toHaveKey('_image_error');
});
test('ProductImageValidator marks 404 / 5xx as http_<code>', function () {
Http::fake(['*' => Http::response('', 404)]);
$v = new ProductImageValidator(timeoutMs: 1000, enabled: true);
$result = $v->validate([['external_id' => '1', 'image_url' => 'https://cdn.test/missing.jpg']]);
expect($result[0]['_image_error'])->toBe('http_404');
});
test('ProductImageValidator rejects unsupported schemes', function () {
$v = new ProductImageValidator(timeoutMs: 1000, enabled: true);
expect($v->checkUrl('ftp://nope.test/x.jpg'))->toBe('invalid_scheme');
expect($v->checkUrl('javascript:alert(1)'))->toBe('invalid_scheme');
});
test('ProductImageValidator marks missing image_url with stable code', function () {
$v = new ProductImageValidator(timeoutMs: 1000, enabled: true);
$result = $v->validate([['external_id' => '1', 'image_url' => null]]);
expect($result[0]['_image_error'])->toBe('missing_image_url');
});
test('ProductImageValidator passthrough when disabled', function () {
$v = new ProductImageValidator(timeoutMs: 1000, enabled: false);
$result = $v->validate([['external_id' => '1', 'image_url' => 'totally-broken']]);
expect($result[0])->not->toHaveKey('_image_error');
});
// ---------------------------------------------------------------------------
// 5-7. Service routing — flag matrix
// ---------------------------------------------------------------------------
test('service flag-OFF preserves legacy mock fixture path even with bound platform', function () {
config([
'ads.hardening.use_session_pattern' => false,
'ads.hardening.real_catalog_sync_enabled' => false,
'ads.mock_mode' => true,
]);
$scenario = r15Scenario(bindPlatform: true, productCount: 0);
$catalog = $scenario['catalog'];
$service = app(AdProductCatalogService::class);
$service->sync($catalog);
$catalog->refresh();
expect($catalog->sync_status)->toBe(AdProductCatalog::SYNC_COMPLETED);
expect($catalog->products()->count())->toBe(3); // mock seed = 3 sample products
expect($catalog->platform_catalog_id)->toBeNull(); // never reached the adapter
});
test('service flag-ON without ad_platform_id binding falls back to mock path', function () {
config([
'ads.hardening.use_session_pattern' => true,
'ads.hardening.real_catalog_sync_enabled' => true,
'ads.mock_mode' => true,
]);
$scenario = r15Scenario(bindPlatform: false, productCount: 0);
$catalog = $scenario['catalog'];
$service = app(AdProductCatalogService::class);
$service->sync($catalog);
$catalog->refresh();
expect($catalog->ad_platform_id)->toBeNull();
expect($catalog->sync_status)->toBe(AdProductCatalog::SYNC_COMPLETED);
expect($catalog->platform_catalog_id)->toBeNull();
});
test('service flag-ON + bound platform routes createCatalog + syncCatalog through chain', function () {
config([
'ads.hardening.use_session_pattern' => true,
'ads.hardening.real_catalog_sync_enabled' => true,
'ads.mock_mode' => false,
'ads.catalog.validate_images' => false,
]);
$scenario = r15Scenario(bindPlatform: true, productCount: 2);
$catalog = $scenario['catalog'];
$platform = $scenario['platform'];
// Spy the adapter chain. Replace the manager binding so resolveRawAdapter
// returns our spy regardless of platform.platform value.
$spy = new class extends MockAdPlatform {
public ?string $createCalledWith = null;
public ?string $syncCalledWithKey = null;
public ?array $syncCalledWithProducts = null;
public ?string $syncCalledWithCatalogId = null;
public function createCatalog(string $accessToken, string $adAccountId, string $name): string
{
$this->createCalledWith = $name;
return 'remote_cat_xyz';
}
public function syncCatalog(string $accessToken, string $catalogId, array $products, string $idempotencyKey): CatalogSyncResult
{
$this->syncCalledWithKey = $idempotencyKey;
$this->syncCalledWithProducts = $products;
$this->syncCalledWithCatalogId = $catalogId;
return new CatalogSyncResult(
status: CatalogSyncResult::STATUS_SUCCESS,
syncedCount: count($products),
errorCount: 0,
errors: [],
);
}
};
app()->bind(\App\Services\Ads\MetaAdPlatform::class, fn () => $spy);
$service = app(AdProductCatalogService::class);
$service->sync($catalog);
expect($spy->createCalledWith)->toBe('R15 Test Catalog');
expect($spy->syncCalledWithCatalogId)->toBe('remote_cat_xyz');
expect($spy->syncCalledWithProducts)->toHaveCount(2);
expect($spy->syncCalledWithKey)->toMatch('/^[a-f0-9]{64}$/');
$catalog->refresh();
expect($catalog->platform_catalog_id)->toBe('remote_cat_xyz');
expect($catalog->last_idempotency_key)->toBe($spy->syncCalledWithKey);
expect($catalog->sync_status)->toBe(AdProductCatalog::SYNC_COMPLETED);
});
// ---------------------------------------------------------------------------
// 8. Idempotency key determinism
// ---------------------------------------------------------------------------
test('idempotency key is sha256 + deterministic across calls', function () {
config([
'ads.hardening.use_session_pattern' => true,
'ads.hardening.real_catalog_sync_enabled' => true,
'ads.catalog.validate_images' => false,
]);
$scenario = r15Scenario(bindPlatform: true, productCount: 2);
$catalog = $scenario['catalog'];
$service = app(AdProductCatalogService::class);
$reflect = new \ReflectionMethod($service, 'buildIdempotencyKey');
$reflect->setAccessible(true);
$payload = [
['external_id' => 'a', 'image_url' => 'https://x.test/a', 'price' => '1.00', 'availability' => 'in_stock'],
['external_id' => 'b', 'image_url' => 'https://x.test/b', 'price' => '2.00', 'availability' => 'in_stock'],
];
$k1 = $reflect->invoke($service, $catalog, $payload);
$k2 = $reflect->invoke($service, $catalog, $payload);
expect($k1)->toBe($k2);
expect($k1)->toMatch('/^[a-f0-9]{64}$/');
});
test('idempotency key rotates when product price changes', function () {
$scenario = r15Scenario(bindPlatform: true, productCount: 2);
$catalog = $scenario['catalog'];
$service = app(AdProductCatalogService::class);
$reflect = new \ReflectionMethod($service, 'buildIdempotencyKey');
$reflect->setAccessible(true);
$base = [['external_id' => 'a', 'image_url' => 'https://x/a', 'price' => '1.00', 'availability' => 'in_stock']];
$changed = [['external_id' => 'a', 'image_url' => 'https://x/a', 'price' => '2.00', 'availability' => 'in_stock']];
expect($reflect->invoke($service, $catalog, $base))
->not->toBe($reflect->invoke($service, $catalog, $changed));
});
// ---------------------------------------------------------------------------
// 9-10. Result persistence — success + partial failure
// ---------------------------------------------------------------------------
test('per-product retailer IDs persisted + last_synced_at stamped on success', function () {
config([
'ads.hardening.use_session_pattern' => true,
'ads.hardening.real_catalog_sync_enabled' => true,
'ads.catalog.validate_images' => false,
]);
$scenario = r15Scenario(bindPlatform: true, productCount: 2);
$catalog = $scenario['catalog'];
$spy = new class extends MockAdPlatform {
public function createCatalog(string $accessToken, string $adAccountId, string $name): string
{
return 'cat_ok';
}
public function syncCatalog(string $accessToken, string $catalogId, array $products, string $idempotencyKey): CatalogSyncResult
{
return new CatalogSyncResult(
status: CatalogSyncResult::STATUS_SUCCESS,
syncedCount: count($products),
errorCount: 0,
);
}
};
app()->bind(\App\Services\Ads\MetaAdPlatform::class, fn () => $spy);
app(AdProductCatalogService::class)->sync($catalog);
foreach ($catalog->products()->get() as $p) {
expect($p->platform_retailer_ids)->toHaveKey('meta');
expect($p->platform_retailer_ids['meta'])->toBe($p->external_id);
expect($p->last_synced_at)->not->toBeNull();
expect($p->last_sync_error)->toBeNull();
}
});
test('per-product errors persisted + catalog flips to PARTIAL on partial failure', function () {
config([
'ads.hardening.use_session_pattern' => true,
'ads.hardening.real_catalog_sync_enabled' => true,
'ads.catalog.validate_images' => false,
]);
$scenario = r15Scenario(bindPlatform: true, productCount: 2);
$catalog = $scenario['catalog'];
$spy = new class extends MockAdPlatform {
public function createCatalog(string $accessToken, string $adAccountId, string $name): string
{
return 'cat_partial';
}
public function syncCatalog(string $accessToken, string $catalogId, array $products, string $idempotencyKey): CatalogSyncResult
{
return new CatalogSyncResult(
status: CatalogSyncResult::STATUS_PARTIAL,
syncedCount: 1,
errorCount: 1,
errors: [['external_id' => 'r15prod_1', 'error' => 'banned_keyword']],
);
}
};
app()->bind(\App\Services\Ads\MetaAdPlatform::class, fn () => $spy);
app(AdProductCatalogService::class)->sync($catalog);
$catalog->refresh();
expect($catalog->sync_status)->toBe(AdProductCatalog::SYNC_PARTIAL);
expect($catalog->error_log)->toBeArray();
expect($catalog->error_log)->toHaveCount(1);
expect($catalog->error_log[0]['external_id'])->toBe('r15prod_1');
$bad = AdProduct::where('external_id', 'r15prod_1')->first();
expect($bad->last_sync_error)->toBe('banned_keyword');
$good = AdProduct::where('external_id', 'r15prod_0')->first();
expect($good->last_sync_error)->toBeNull();
expect($good->platform_retailer_ids)->toHaveKey('meta');
});
// ---------------------------------------------------------------------------
// 11. Image-rejected products never reach the adapter
// ---------------------------------------------------------------------------
test('image-validator-rejected products are filtered from DTO + recorded in error_log', function () {
config([
'ads.hardening.use_session_pattern' => true,
'ads.hardening.real_catalog_sync_enabled' => true,
'ads.catalog.validate_images' => true,
]);
Http::fake([
'*example.test/img/0.jpg' => Http::response('', 404), // bad
'*example.test/img/1.jpg' => Http::response('', 200), // good
]);
$scenario = r15Scenario(bindPlatform: true, productCount: 2);
$catalog = $scenario['catalog'];
$spy = new class extends MockAdPlatform {
/** @var array<int,array<string,mixed>> */
public array $sentToAdapter = [];
public function createCatalog(string $accessToken, string $adAccountId, string $name): string
{
return 'cat_filter';
}
public function syncCatalog(string $accessToken, string $catalogId, array $products, string $idempotencyKey): CatalogSyncResult
{
$this->sentToAdapter = $products;
return new CatalogSyncResult(
status: CatalogSyncResult::STATUS_SUCCESS,
syncedCount: count($products),
errorCount: 0,
);
}
};
app()->bind(\App\Services\Ads\MetaAdPlatform::class, fn () => $spy);
app(AdProductCatalogService::class)->sync($catalog);
expect($spy->sentToAdapter)->toHaveCount(1);
expect($spy->sentToAdapter[0]['external_id'])->toBe('r15prod_1');
$catalog->refresh();
expect($catalog->error_log)->toBeArray();
$errIds = array_column($catalog->error_log, 'external_id');
expect($errIds)->toContain('r15prod_0');
expect($catalog->sync_status)->toBe(AdProductCatalog::SYNC_PARTIAL);
});
// ---------------------------------------------------------------------------
// 12. Bound platform missing → throws (defensive — should never happen)
// ---------------------------------------------------------------------------
test('syncToPlatform throws InvalidArgumentException when ad_platform_id is null', function () {
$scenario = r15Scenario(bindPlatform: false);
$catalog = $scenario['catalog'];
expect(fn () => app(AdProductCatalogService::class)->syncToPlatform($catalog))
->toThrow(\InvalidArgumentException::class, 'no ad_platform_id binding');
});