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