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/AdsR15_5WooCommerceSourceTest.php
<?php

/**
 * Ads Phase R15.5 — WooCommerce product source connector.
 *
 * Verifies:
 *   1. Migration applied — `source_credentials` + `last_source_pulled_at`
 *      columns present on `ad_product_catalogs`.
 *   2. AdProductCatalog casts `source_credentials` as `encrypted:json` —
 *      stored ciphertext at rest, decrypted via attribute access.
 *   3. WooCommerceProductSource pulls a single page of products and
 *      upserts AdProduct rows with the correct field mapping (sku →
 *      external_id, name → title, permalink → url, etc.).
 *   4. WooCommerceProductSource paginates — sends ?page=2 once page 1
 *      returns a full per_page batch.
 *   5. WooCommerceProductSource respects `last_source_pulled_at` via
 *      ?modified_after filter on the second run (delta sync).
 *   6. WooCommerceProductSource extracts brand from attributes named
 *      "Brand" (case-insensitive).
 *   7. WooCommerceProductSource maps stock_status outofstock →
 *      availability=out_of_stock.
 *   8. WooCommerceProductSource missing creds returns STATUS_ERROR
 *      without making any HTTP call.
 *   9. WooCommerceProductSource HTTP 401 returns STATUS_ERROR with
 *      WC error message and aborts pagination.
 *  10. WooCommerceProductSource updates last_source_pulled_at on success.
 *  11. AdProductCatalogService::pullFromSource wires WooCommerce by
 *      source_type; appends pull errors into catalog.error_log.
 *  12. AdProductCatalogService::sync invokes pull-from-source for
 *      source_type=woocommerce before mock fallback.
 */

use App\Model\AdProduct;
use App\Model\AdProductCatalog;
use App\Services\Ads\AdProductCatalogService;
use App\Services\Ads\Sources\SourcePullResult;
use App\Services\Ads\Sources\WooCommerceProductSource;
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);

beforeEach(function () {
    Http::preventStrayRequests();
});

/**
 * @return array{customerId:int, catalog:AdProductCatalog}
 */
function r155Scenario(array $catalogOverrides = [], array $creds = ['consumer_key' => 'ck_abc', 'consumer_secret' => 'cs_xyz']): array
{
    $customerId = (int) DB::table('customers')->insertGetId([
        'uid' => 'r155cus_' . uniqid(),
        'timezone' => 'UTC',
        'status' => 'active',
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    $catalog = AdProductCatalog::create(array_merge([
        'uid' => 'r155cat_' . uniqid(),
        'customer_id' => $customerId,
        'name' => 'R15.5 WC Catalog',
        'source_type' => AdProductCatalog::SOURCE_WOOCOMMERCE,
        'source_url' => 'https://store.example.test',
        'source_credentials' => $creds,
        'sync_status' => AdProductCatalog::SYNC_PENDING,
    ], $catalogOverrides));

    return ['customerId' => $customerId, 'catalog' => $catalog->fresh()];
}

// ---------------------------------------------------------------------------
// 1-2. Migration + cast
// ---------------------------------------------------------------------------

test('R15.5 migration adds source_credentials and last_source_pulled_at columns', function () {
    expect(Schema::hasColumn('ad_product_catalogs', 'source_credentials'))->toBeTrue();
    expect(Schema::hasColumn('ad_product_catalogs', 'last_source_pulled_at'))->toBeTrue();
});

test('AdProductCatalog casts source_credentials as encrypted:json', function () {
    $catalog = new AdProductCatalog();
    expect($catalog->getCasts())->toHaveKey('source_credentials');
    expect($catalog->getCasts()['source_credentials'])->toBe('encrypted:json');
});

test('source_credentials roundtrips through encryption — DB row is ciphertext, model returns decrypted array', function () {
    $scenario = r155Scenario();
    $catalog = $scenario['catalog'];
    expect($catalog->source_credentials)->toBe(['consumer_key' => 'ck_abc', 'consumer_secret' => 'cs_xyz']);

    // Raw DB row should NOT be the plaintext json.
    $raw = (string) DB::table('ad_product_catalogs')->where('id', $catalog->id)->value('source_credentials');
    expect($raw)->not->toContain('ck_abc');
    expect($raw)->not->toContain('cs_xyz');
});

// ---------------------------------------------------------------------------
// 3. Single-page pull + field mapping
// ---------------------------------------------------------------------------

test('WooCommerceProductSource pulls products and maps WC fields to AdProduct', function () {
    Http::fake([
        'store.example.test/wp-json/wc/v3/products*' => Http::response([
            [
                'id' => 101,
                'name' => 'Red Sneakers',
                'sku' => 'sku-red',
                'price' => '49.99',
                'regular_price' => '49.99',
                'sale_price' => '39.99',
                'short_description' => '<p>Bright red running shoes</p>',
                'description' => 'Long form...',
                'permalink' => 'https://store.example.test/p/red',
                'images' => [['src' => 'https://store.example.test/wp-content/uploads/red.jpg']],
                'stock_status' => 'instock',
                'status' => 'publish',
                'date_modified' => '2026-04-30T12:00:00',
                'categories' => [['name' => 'Apparel']],
                'attributes' => [['name' => 'Brand', 'options' => ['Acme']]],
            ],
        ], 200, ['X-WP-TotalPages' => '1', 'X-WP-Total' => '1']),
    ]);

    $scenario = r155Scenario();
    $source = new WooCommerceProductSource();
    $result = $source->pull($scenario['catalog']);

    expect($result->status)->toBe(SourcePullResult::STATUS_SUCCESS);
    expect($result->pulled)->toBe(1);
    expect($result->created)->toBe(1);
    expect($result->updated)->toBe(0);

    $product = AdProduct::where('ad_product_catalog_id', $scenario['catalog']->id)->first();
    expect($product)->not->toBeNull();
    expect($product->external_id)->toBe('sku-red');
    expect($product->title)->toBe('Red Sneakers');
    expect($product->description)->toBe('Bright red running shoes');
    expect((string) $product->price)->toBe('49.99');
    expect((string) $product->sale_price)->toBe('39.99');
    expect($product->image_url)->toBe('https://store.example.test/wp-content/uploads/red.jpg');
    expect($product->url)->toBe('https://store.example.test/p/red');
    expect($product->availability)->toBe('in_stock');
    expect($product->category)->toBe('Apparel');
    expect($product->brand)->toBe('Acme');
    expect($product->custom['wc_id'])->toBe(101);
});

// ---------------------------------------------------------------------------
// 4. Pagination
// ---------------------------------------------------------------------------

test('WooCommerceProductSource paginates — fetches page 2 when page 1 returns full per_page', function () {
    $page1 = [];
    for ($i = 0; $i < 100; $i++) {
        $page1[] = ['id' => $i + 1, 'name' => 'P' . $i, 'sku' => 'sku_' . $i, 'price' => '5.00', 'permalink' => 'https://x/' . $i, 'stock_status' => 'instock', 'status' => 'publish'];
    }
    $page2 = [
        ['id' => 999, 'name' => 'Last', 'sku' => 'sku_last', 'price' => '5.00', 'permalink' => 'https://x/last', 'stock_status' => 'instock', 'status' => 'publish'],
    ];

    $callCount = 0;
    $observedPages = [];
    Http::fake([
        'store.example.test/wp-json/wc/v3/products*' => function ($request) use (&$callCount, &$observedPages, $page1, $page2) {
            $callCount++;
            parse_str(parse_url($request->url(), PHP_URL_QUERY) ?? '', $query);
            $page = (int) ($query['page'] ?? 1);
            $observedPages[] = $page;
            $body = $page === 1 ? $page1 : ($page === 2 ? $page2 : []);
            return Http::response($body, 200, ['X-WP-TotalPages' => '2']);
        },
    ]);

    $scenario = r155Scenario();
    $source = new WooCommerceProductSource();
    $result = $source->pull($scenario['catalog']);

    expect($callCount)->toBe(2);
    expect($observedPages)->toBe([1, 2]);
    expect($result->pulled)->toBe(101);
    expect($result->created)->toBe(101);
});

// ---------------------------------------------------------------------------
// 5. Delta sync
// ---------------------------------------------------------------------------

test('WooCommerceProductSource sends modified_after on subsequent runs', function () {
    $observedQuery = [];
    Http::fake([
        'store.example.test/wp-json/wc/v3/products*' => function ($request) use (&$observedQuery) {
            parse_str(parse_url($request->url(), PHP_URL_QUERY) ?? '', $observedQuery);
            return Http::response([], 200);
        },
    ]);

    $scenario = r155Scenario(['last_source_pulled_at' => '2026-04-01 00:00:00']);
    $source = new WooCommerceProductSource();
    $source->pull($scenario['catalog']);

    expect($observedQuery)->toHaveKey('modified_after');
    expect($observedQuery['modified_after'])->toContain('2026-04-01');
});

// ---------------------------------------------------------------------------
// 6. Brand attribute extraction
// ---------------------------------------------------------------------------

test('WooCommerceProductSource extracts brand from attributes (case-insensitive)', function () {
    Http::fake([
        'store.example.test/wp-json/wc/v3/products*' => Http::response([
            ['id' => 1, 'name' => 'P', 'sku' => 'p', 'price' => '5', 'permalink' => 'https://x/p', 'stock_status' => 'instock', 'status' => 'publish',
                'attributes' => [['name' => 'Color', 'options' => ['Red']], ['name' => 'brand', 'options' => ['Nike']]]],
        ], 200, ['X-WP-TotalPages' => '1']),
    ]);

    $scenario = r155Scenario();
    (new WooCommerceProductSource())->pull($scenario['catalog']);
    $product = AdProduct::where('ad_product_catalog_id', $scenario['catalog']->id)->first();
    expect($product->brand)->toBe('Nike');
});

// ---------------------------------------------------------------------------
// 7. Stock-status mapping
// ---------------------------------------------------------------------------

test('WooCommerceProductSource maps stock_status outofstock to availability=out_of_stock', function () {
    Http::fake([
        'store.example.test/wp-json/wc/v3/products*' => Http::response([
            ['id' => 1, 'name' => 'P', 'sku' => 'p', 'price' => '5', 'permalink' => 'https://x/p', 'stock_status' => 'outofstock', 'status' => 'publish'],
        ], 200, ['X-WP-TotalPages' => '1']),
    ]);

    $scenario = r155Scenario();
    (new WooCommerceProductSource())->pull($scenario['catalog']);
    $product = AdProduct::where('ad_product_catalog_id', $scenario['catalog']->id)->first();
    expect($product->availability)->toBe('out_of_stock');
});

// ---------------------------------------------------------------------------
// 8. Missing creds short-circuits
// ---------------------------------------------------------------------------

test('WooCommerceProductSource missing creds returns STATUS_ERROR without HTTP', function () {
    Http::fake();
    $scenario = r155Scenario(creds: ['consumer_key' => '', 'consumer_secret' => '']);
    $result = (new WooCommerceProductSource())->pull($scenario['catalog']);
    expect($result->status)->toBe(SourcePullResult::STATUS_ERROR);
    expect($result->message)->toContain('consumer_key or consumer_secret');
    Http::assertNothingSent();
});

// ---------------------------------------------------------------------------
// 9. Auth error
// ---------------------------------------------------------------------------

test('WooCommerceProductSource HTTP 401 returns STATUS_ERROR and aborts pagination', function () {
    $callCount = 0;
    Http::fake([
        'store.example.test/wp-json/wc/v3/products*' => function () use (&$callCount) {
            $callCount++;
            return Http::response(['code' => 'woocommerce_rest_cannot_view', 'message' => 'Sorry, you cannot list resources.'], 401);
        },
    ]);

    $scenario = r155Scenario();
    $result = (new WooCommerceProductSource())->pull($scenario['catalog']);

    expect($result->status)->toBe(SourcePullResult::STATUS_ERROR);
    expect($result->message)->toContain('401');
    expect($result->message)->toContain('Sorry');
    expect($callCount)->toBe(1);
});

// ---------------------------------------------------------------------------
// 10. last_source_pulled_at update
// ---------------------------------------------------------------------------

test('WooCommerceProductSource updates last_source_pulled_at on success', function () {
    Http::fake([
        'store.example.test/wp-json/wc/v3/products*' => Http::response([], 200, ['X-WP-TotalPages' => '0']),
    ]);

    $scenario = r155Scenario();
    expect($scenario['catalog']->last_source_pulled_at)->toBeNull();

    (new WooCommerceProductSource())->pull($scenario['catalog']);
    $fresh = $scenario['catalog']->fresh();
    expect($fresh->last_source_pulled_at)->not->toBeNull();
});

// ---------------------------------------------------------------------------
// 11. Service wiring + error_log
// ---------------------------------------------------------------------------

test('AdProductCatalogService::pullFromSource resolves WooCommerce by source_type', function () {
    Http::fake([
        'store.example.test/wp-json/wc/v3/products*' => Http::response([
            ['id' => 1, 'name' => 'P', 'sku' => 'p', 'price' => '5', 'permalink' => 'https://x/p', 'stock_status' => 'instock', 'status' => 'publish'],
        ], 200, ['X-WP-TotalPages' => '1']),
    ]);

    $scenario = r155Scenario();
    $service = app(AdProductCatalogService::class);
    $result = $service->pullFromSource($scenario['catalog']);

    expect($result)->toBeInstanceOf(SourcePullResult::class);
    expect($result->status)->toBe(SourcePullResult::STATUS_SUCCESS);
    expect($result->pulled)->toBe(1);
});

test('AdProductCatalogService::pullFromSource on unknown source_type returns STATUS_ERROR', function () {
    Http::fake();
    $scenario = r155Scenario(['source_type' => AdProductCatalog::SOURCE_MANUAL]);
    $service = app(AdProductCatalogService::class);
    $result = $service->pullFromSource($scenario['catalog']);
    expect($result->status)->toBe(SourcePullResult::STATUS_ERROR);
    expect($result->message)->toContain('No ProductSource registered');
    Http::assertNothingSent();
});

// ---------------------------------------------------------------------------
// 12. End-to-end sync triggers pull-from-source
// ---------------------------------------------------------------------------

test('AdProductCatalogService::sync invokes pull-from-source for source_type=woocommerce', function () {
    config([
        'ads.hardening.use_session_pattern' => false,
        'ads.hardening.real_catalog_sync_enabled' => false,
        'ads.mock_mode' => false,
    ]);
    Http::fake([
        'store.example.test/wp-json/wc/v3/products*' => Http::response([
            ['id' => 1, 'name' => 'WC Sneakers', 'sku' => 'sk-1', 'price' => '5', 'permalink' => 'https://x/1', 'stock_status' => 'instock', 'status' => 'publish'],
        ], 200, ['X-WP-TotalPages' => '1']),
    ]);

    $scenario = r155Scenario();
    $service = app(AdProductCatalogService::class);
    $service->sync($scenario['catalog']);

    $product = AdProduct::where('ad_product_catalog_id', $scenario['catalog']->id)->first();
    expect($product)->not->toBeNull();
    expect($product->title)->toBe('WC Sneakers');

    $fresh = $scenario['catalog']->fresh();
    expect($fresh->sync_status)->toBe(AdProductCatalog::SYNC_COMPLETED);
});