File: /home/xedaptot/be.naniguide.com/tests/Feature/AdsR15_6CsvSourceTest.php
<?php
/**
* Ads Phase R15.6 — Manual CSV upload product source.
*
* Verifies:
* 1. AdProductCatalog adds SOURCE_CSV constant + entry in SOURCES list.
* 2. CsvProductSource reads a local-disk file and upserts AdProduct rows.
* 3. CsvProductSource missing required header column → STATUS_ERROR
* (whole pull aborts; partial-load is not a thing for malformed CSV).
* 4. CsvProductSource per-row missing required field → row skipped with
* error logged; remaining rows still upsert.
* 5. CsvProductSource handles BOM + CRLF line endings.
* 6. CsvProductSource handles quoted fields containing commas / newlines.
* 7. CsvProductSource second pull updates existing rows in place
* (firstOrCreate keyed by external_id).
* 8. CsvProductSource fetches from HTTPS URL via Http facade.
* 9. CsvProductSource missing source_url returns STATUS_ERROR without
* hitting Storage / HTTP.
* 10. CsvProductSource HTTPS 404 returns STATUS_ERROR.
* 11. AdProductCatalogService::pullFromSource resolves CSV by source_type.
* 12. AdProductCatalogService::sync end-to-end for source_type=csv.
*/
use App\Model\AdProduct;
use App\Model\AdProductCatalog;
use App\Services\Ads\AdProductCatalogService;
use App\Services\Ads\Sources\CsvProductSource;
use App\Services\Ads\Sources\SourcePullResult;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
uses(Tests\TestCase::class);
uses(DatabaseTransactions::class);
beforeEach(function () {
Http::preventStrayRequests();
Storage::fake('local');
});
/**
* @return array{customerId:int, catalog:AdProductCatalog}
*/
function r156Scenario(string $sourceUrl, array $catalogOverrides = []): array
{
$customerId = (int) DB::table('customers')->insertGetId([
'uid' => 'r156cus_' . uniqid(),
'timezone' => 'UTC',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
$catalog = AdProductCatalog::create(array_merge([
'uid' => 'r156cat_' . uniqid(),
'customer_id' => $customerId,
'name' => 'R15.6 CSV Catalog',
'source_type' => AdProductCatalog::SOURCE_CSV,
'source_url' => $sourceUrl,
'sync_status' => AdProductCatalog::SYNC_PENDING,
], $catalogOverrides));
return ['customerId' => $customerId, 'catalog' => $catalog->fresh()];
}
// ---------------------------------------------------------------------------
// 1. SOURCE_CSV constant
// ---------------------------------------------------------------------------
test('R15.6 adds SOURCE_CSV constant + entry in SOURCES list', function () {
expect(AdProductCatalog::SOURCE_CSV)->toBe('csv');
expect(AdProductCatalog::SOURCES)->toContain('csv');
});
// ---------------------------------------------------------------------------
// 2. Local CSV pull
// ---------------------------------------------------------------------------
test('CsvProductSource reads a local-disk CSV and upserts AdProduct rows', function () {
Storage::put('feeds/test.csv', <<<CSV
external_id,title,price,currency,image_url,description,sale_price,product_url,availability,category,brand
sku-001,Red Sneakers,49.99,USD,https://cdn.test/red.jpg,Bright red shoes,39.99,https://shop.test/red,in_stock,Apparel,Acme
sku-002,Blue Sneakers,59.99,USD,https://cdn.test/blue.jpg,,,https://shop.test/blue,out_of_stock,Apparel,Acme
CSV);
$scenario = r156Scenario('feeds/test.csv');
$source = new CsvProductSource();
$result = $source->pull($scenario['catalog']);
expect($result->status)->toBe(SourcePullResult::STATUS_SUCCESS);
expect($result->pulled)->toBe(2);
expect($result->created)->toBe(2);
$products = AdProduct::where('ad_product_catalog_id', $scenario['catalog']->id)->orderBy('external_id')->get();
expect($products)->toHaveCount(2);
expect($products[0]->external_id)->toBe('sku-001');
expect($products[0]->title)->toBe('Red Sneakers');
expect((string) $products[0]->price)->toBe('49.99');
expect((string) $products[0]->sale_price)->toBe('39.99');
expect($products[0]->category)->toBe('Apparel');
expect($products[0]->brand)->toBe('Acme');
expect($products[1]->availability)->toBe('out_of_stock');
expect($products[1]->sale_price)->toBeNull();
});
// ---------------------------------------------------------------------------
// 3. Missing header column
// ---------------------------------------------------------------------------
test('CsvProductSource missing required header column returns STATUS_ERROR (whole pull aborts)', function () {
Storage::put('feeds/bad-header.csv', <<<CSV
external_id,title,price,image_url
sku-001,Red,49.99,https://cdn.test/red.jpg
CSV);
$scenario = r156Scenario('feeds/bad-header.csv');
$result = (new CsvProductSource())->pull($scenario['catalog']);
expect($result->status)->toBe(SourcePullResult::STATUS_ERROR);
expect($result->message)->toContain('missing required columns');
expect($result->message)->toContain('currency');
expect(AdProduct::where('ad_product_catalog_id', $scenario['catalog']->id)->count())->toBe(0);
});
// ---------------------------------------------------------------------------
// 4. Per-row missing field
// ---------------------------------------------------------------------------
test('CsvProductSource per-row missing required field skips that row, remaining rows upsert', function () {
Storage::put('feeds/partial.csv', <<<CSV
external_id,title,price,currency,image_url
sku-001,Red Sneakers,49.99,USD,https://cdn.test/red.jpg
sku-002,Blue Sneakers,,USD,https://cdn.test/blue.jpg
sku-003,Green Sneakers,29.99,USD,https://cdn.test/green.jpg
CSV);
$scenario = r156Scenario('feeds/partial.csv');
$result = (new CsvProductSource())->pull($scenario['catalog']);
expect($result->status)->toBe(SourcePullResult::STATUS_PARTIAL);
expect($result->pulled)->toBe(2);
expect($result->errorCount())->toBe(1);
expect($result->errors[0]['external_id'])->toBe('sku-002');
expect($result->errors[0]['error'])->toContain('price');
$products = AdProduct::where('ad_product_catalog_id', $scenario['catalog']->id)->orderBy('external_id')->get();
expect($products)->toHaveCount(2);
expect($products->pluck('external_id')->toArray())->toBe(['sku-001', 'sku-003']);
});
// ---------------------------------------------------------------------------
// 5. BOM + CRLF
// ---------------------------------------------------------------------------
test('CsvProductSource handles BOM + CRLF line endings', function () {
$bom = "\xEF\xBB\xBF";
$body = $bom . "external_id,title,price,currency,image_url\r\nsku-001,A,5,USD,https://c/a.jpg\r\nsku-002,B,5,USD,https://c/b.jpg\r\n";
Storage::put('feeds/bom.csv', $body);
$scenario = r156Scenario('feeds/bom.csv');
$result = (new CsvProductSource())->pull($scenario['catalog']);
expect($result->status)->toBe(SourcePullResult::STATUS_SUCCESS);
expect($result->pulled)->toBe(2);
});
// ---------------------------------------------------------------------------
// 6. Quoted fields
// ---------------------------------------------------------------------------
test('CsvProductSource handles quoted fields containing commas', function () {
Storage::put('feeds/quoted.csv', 'external_id,title,price,currency,image_url' . "\n" . 'sku-001,"Red, Comfortable Sneakers",49.99,USD,https://cdn.test/red.jpg');
$scenario = r156Scenario('feeds/quoted.csv');
$result = (new CsvProductSource())->pull($scenario['catalog']);
expect($result->status)->toBe(SourcePullResult::STATUS_SUCCESS);
$product = AdProduct::where('ad_product_catalog_id', $scenario['catalog']->id)->first();
expect($product->title)->toBe('Red, Comfortable Sneakers');
});
// ---------------------------------------------------------------------------
// 7. Re-pull updates existing rows
// ---------------------------------------------------------------------------
test('CsvProductSource second pull updates existing rows by external_id', function () {
Storage::put('feeds/v1.csv', "external_id,title,price,currency,image_url\nsku-001,Old Title,10,USD,https://c/a.jpg\n");
$scenario = r156Scenario('feeds/v1.csv');
(new CsvProductSource())->pull($scenario['catalog']);
Storage::put('feeds/v1.csv', "external_id,title,price,currency,image_url\nsku-001,New Title,12,USD,https://c/a.jpg\n");
$result2 = (new CsvProductSource())->pull($scenario['catalog']);
expect($result2->created)->toBe(0);
expect($result2->updated)->toBe(1);
$products = AdProduct::where('ad_product_catalog_id', $scenario['catalog']->id)->get();
expect($products)->toHaveCount(1);
expect($products[0]->title)->toBe('New Title');
expect((string) $products[0]->price)->toBe('12.00');
});
// ---------------------------------------------------------------------------
// 8. HTTPS source URL
// ---------------------------------------------------------------------------
test('CsvProductSource fetches from HTTPS source_url', function () {
Http::fake([
'feeds.example.test/products.csv' => Http::response("external_id,title,price,currency,image_url\nsku-x,X,5,USD,https://c/x.jpg\n", 200),
]);
$scenario = r156Scenario('https://feeds.example.test/products.csv');
$result = (new CsvProductSource())->pull($scenario['catalog']);
expect($result->status)->toBe(SourcePullResult::STATUS_SUCCESS);
expect($result->pulled)->toBe(1);
});
// ---------------------------------------------------------------------------
// 9. Missing source_url
// ---------------------------------------------------------------------------
test('CsvProductSource missing source_url returns STATUS_ERROR without hitting storage or HTTP', function () {
Http::fake();
$scenario = r156Scenario('');
$result = (new CsvProductSource())->pull($scenario['catalog']);
expect($result->status)->toBe(SourcePullResult::STATUS_ERROR);
expect($result->message)->toContain('missing source_url');
Http::assertNothingSent();
});
// ---------------------------------------------------------------------------
// 10. HTTPS 404
// ---------------------------------------------------------------------------
test('CsvProductSource HTTPS 404 returns STATUS_ERROR', function () {
Http::fake([
'feeds.example.test/missing.csv' => Http::response('not found', 404),
]);
$scenario = r156Scenario('https://feeds.example.test/missing.csv');
$result = (new CsvProductSource())->pull($scenario['catalog']);
expect($result->status)->toBe(SourcePullResult::STATUS_ERROR);
expect($result->message)->toContain('could not be read');
});
// ---------------------------------------------------------------------------
// 11. Service wiring
// ---------------------------------------------------------------------------
test('AdProductCatalogService::pullFromSource resolves CSV by source_type', function () {
Storage::put('feeds/svc.csv', "external_id,title,price,currency,image_url\nsku-svc,SvcProduct,5,USD,https://c/s.jpg\n");
$scenario = r156Scenario('feeds/svc.csv');
$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);
});
// ---------------------------------------------------------------------------
// 12. End-to-end sync
// ---------------------------------------------------------------------------
test('AdProductCatalogService::sync end-to-end for source_type=csv', function () {
config([
'ads.hardening.use_session_pattern' => false,
'ads.hardening.real_catalog_sync_enabled' => false,
'ads.mock_mode' => false,
]);
Storage::put('feeds/e2e.csv', "external_id,title,price,currency,image_url\nsku-e2e,EndToEnd,5,USD,https://c/e.jpg\n");
$scenario = r156Scenario('feeds/e2e.csv');
$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('EndToEnd');
expect($scenario['catalog']->fresh()->sync_status)->toBe(AdProductCatalog::SYNC_COMPLETED);
});