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