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/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');
});