File: /home/xedaptot/be.naniguide.com/tests/Feature/AdsR15_7CatalogUiTest.php
<?php
/**
* Ads Phase R15.7 — Catalog UI polish (backend bits).
*
* Pest tests cover the controller / service / form-request changes; pure
* visual styling is left to manual / screenshot audit.
*
* Verifies:
* 1. Create form passes $platforms (active only, current customer) to
* the view.
* 2. StoreRequest accepts ad_platform_id + consumer_key + consumer_secret
* + currency; rejects when platform belongs to another customer.
* 3. StoreRequest requires consumer_key + consumer_secret when
* source_type=woocommerce (required_if rule).
* 4. Service::create composes consumer_key + consumer_secret + currency
* into encrypted source_credentials JSON.
* 5. Service::create persists ad_platform_id binding.
* 6. Errors endpoint returns the catalog's error_log + name.
* 7. Errors endpoint enforces tenant scope (404 when uid belongs to
* another customer).
* 8. Listing partial renders the new platform / errors columns.
* 9. Listing partial flags rows with sync_status=in_progress for the
* poller via data-in-progress-count.
* 10. Errors-count badge renders a clickable button when error_log
* has rows.
*/
use App\Model\AdPlatform;
use App\Model\AdProductCatalog;
use App\Model\Customer;
use App\Services\Ads\AdProductCatalogService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(Tests\TestCase::class);
uses(DatabaseTransactions::class);
beforeEach(function () {
// Per the SaaS-subscription middleware memory: tests must use a
// DemoSeeder-seeded customer (factory() users have no subscription and
// get redirected by middleware). Pick the first available customer
// with a user attached.
$this->customer = Customer::whereHas('user')->orderBy('id')->first();
if (! $this->customer) {
$this->markTestSkipped('No seeded customer with user — run DemoSeeder first');
}
$this->user = $this->customer->user;
$this->customerId = $this->customer->local()->id;
});
function r157Platform(int $customerId, string $platformKey = 'meta', string $name = 'R15.7 Test Platform', string $status = AdPlatform::STATUS_ACTIVE): AdPlatform
{
$platform = new AdPlatform([
'uid' => 'r157plat_' . uniqid(),
'customer_id' => $customerId,
'platform' => $platformKey,
'name' => $name,
'access_token' => 'r157tok_' . uniqid(),
'platform_user_id' => 'p_' . uniqid(),
'platform_user_name' => 'r157user',
]);
$platform->status = $status;
$platform->health_status = AdPlatform::HEALTH_HEALTHY;
$platform->save();
return $platform;
}
// ---------------------------------------------------------------------------
// 1. Create form passes $platforms (active only)
// ---------------------------------------------------------------------------
test('create() passes active customer platforms to the view, excludes inactive', function () {
r157Platform($this->customerId, 'meta', 'R15.7 Active Meta');
r157Platform($this->customerId, 'google', 'R15.7 Stale Google', AdPlatform::STATUS_REVOKED);
$response = $this->actingAs($this->user)->get(route('refactor.ads.catalogs.create'));
$response->assertOk();
$response->assertSee('R15.7 Active Meta');
$response->assertDontSee('R15.7 Stale Google');
});
// ---------------------------------------------------------------------------
// 2-3. StoreRequest validation
// ---------------------------------------------------------------------------
test('StoreRequest rejects ad_platform_id belonging to another customer (tenant scope)', function () {
// Build a second customer + their own platform.
$otherCustomer = Customer::whereHas('user')->where('id', '!=', $this->customer->id)->first();
if (! $otherCustomer) {
$this->markTestSkipped('Need at least 2 seeded customers');
}
$otherPlatform = r157Platform($otherCustomer->local()->id, 'tiktok', 'Other Customer TikTok');
$response = $this->actingAs($this->user)->postJson(route('refactor.ads.catalogs.store'), [
'name' => 'Cross-tenant attack',
'source_type' => AdProductCatalog::SOURCE_MANUAL,
'ad_platform_id' => $otherPlatform->id,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['ad_platform_id']);
});
test('StoreRequest requires consumer_key + consumer_secret when source_type=woocommerce', function () {
$response = $this->actingAs($this->user)->postJson(route('refactor.ads.catalogs.store'), [
'name' => 'WC catalog R15.7',
'source_type' => AdProductCatalog::SOURCE_WOOCOMMERCE,
'source_url' => 'https://store.example.test',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['consumer_key', 'consumer_secret']);
});
test('StoreRequest passes when source_type=manual with no creds', function () {
$response = $this->actingAs($this->user)->postJson(route('refactor.ads.catalogs.store'), [
'name' => 'Manual catalog R15.7',
'source_type' => AdProductCatalog::SOURCE_MANUAL,
]);
$response->assertOk();
$response->assertJsonPath('status', 'success');
});
// ---------------------------------------------------------------------------
// 4-5. Service::create composes encrypted source_credentials + binds platform
// ---------------------------------------------------------------------------
test('Service::create composes consumer_key/consumer_secret/currency into encrypted source_credentials', function () {
$service = app(AdProductCatalogService::class);
$catalog = $service->create($this->customer->local(), [
'name' => 'WC encrypted',
'source_type' => AdProductCatalog::SOURCE_WOOCOMMERCE,
'source_url' => 'https://store.example.test',
'consumer_key' => 'ck_secret_value',
'consumer_secret' => 'cs_more_secret',
'currency' => 'EUR',
]);
expect($catalog->source_credentials)->toBe([
'consumer_key' => 'ck_secret_value',
'consumer_secret' => 'cs_more_secret',
'currency' => 'EUR',
]);
$raw = (string) DB::table('ad_product_catalogs')->where('id', $catalog->id)->value('source_credentials');
expect($raw)->not->toContain('ck_secret_value');
expect($raw)->not->toContain('cs_more_secret');
});
test('Service::create persists ad_platform_id binding', function () {
$platform = r157Platform($this->customerId, 'meta', 'Bind target');
$service = app(AdProductCatalogService::class);
$catalog = $service->create($this->customer->local(), [
'name' => 'Bound catalog',
'source_type' => AdProductCatalog::SOURCE_MANUAL,
'ad_platform_id' => $platform->id,
]);
expect($catalog->ad_platform_id)->toBe($platform->id);
});
test('Service::create with empty ad_platform_id leaves binding null', function () {
$service = app(AdProductCatalogService::class);
$catalog = $service->create($this->customer->local(), [
'name' => 'Unbound',
'source_type' => AdProductCatalog::SOURCE_MANUAL,
'ad_platform_id' => '',
]);
expect($catalog->ad_platform_id)->toBeNull();
});
// ---------------------------------------------------------------------------
// 6-7. Errors endpoint
// ---------------------------------------------------------------------------
test('errors endpoint returns the catalog name + error_log', function () {
$catalog = AdProductCatalog::create([
'uid' => 'r157err_' . uniqid(),
'customer_id' => $this->customerId,
'name' => 'Faulty Catalog R15.7',
'source_type' => AdProductCatalog::SOURCE_MANUAL,
'sync_status' => AdProductCatalog::SYNC_PARTIAL,
'error_log' => [
['stage' => 'sync', 'external_id' => 'sku-A', 'error' => 'invalid image'],
['stage' => 'source_pull', 'external_id' => 'sku-B', 'error' => 'http_404'],
],
]);
$response = $this->actingAs($this->user)->getJson(route('refactor.ads.catalogs.errors', $catalog->uid));
$response->assertOk();
$response->assertJsonPath('status', 'success');
$response->assertJsonPath('name', 'Faulty Catalog R15.7');
$response->assertJsonCount(2, 'errors');
$response->assertJsonPath('errors.0.external_id', 'sku-A');
$response->assertJsonPath('errors.1.stage', 'source_pull');
});
test('errors endpoint 404 when catalog belongs to another customer', function () {
$otherCustomer = Customer::whereHas('user')->where('id', '!=', $this->customer->id)->first();
if (! $otherCustomer) {
$this->markTestSkipped('Need at least 2 seeded customers');
}
$otherCatalog = AdProductCatalog::create([
'uid' => 'r157oth_' . uniqid(),
'customer_id' => $otherCustomer->local()->id,
'name' => 'Cross-tenant catalog',
'source_type' => AdProductCatalog::SOURCE_MANUAL,
'sync_status' => AdProductCatalog::SYNC_PENDING,
]);
$response = $this->actingAs($this->user)->getJson(route('refactor.ads.catalogs.errors', $otherCatalog->uid));
$response->assertStatus(404);
});
// ---------------------------------------------------------------------------
// 8-10. Listing partial markup
// ---------------------------------------------------------------------------
test('listing partial renders new platform + errors columns', function () {
$platform = r157Platform($this->customerId, 'meta', 'Listing Meta');
AdProductCatalog::create([
'uid' => 'r157lst_' . uniqid(),
'customer_id' => $this->customerId,
'ad_platform_id' => $platform->id,
'name' => 'Listing Test Catalog R15.7',
'source_type' => AdProductCatalog::SOURCE_WOOCOMMERCE,
'sync_status' => AdProductCatalog::SYNC_COMPLETED,
]);
$response = $this->actingAs($this->user)->get(route('refactor.ads.catalogs.listing'));
$response->assertOk();
$response->assertSee('Platform');
$response->assertSee('Errors');
$response->assertSee('Meta');
});
test('listing partial flags table with data-in-progress-count for the poller', function () {
AdProductCatalog::create([
'uid' => 'r157prog_' . uniqid(),
'customer_id' => $this->customerId,
'name' => 'Mid-flight R15.7',
'source_type' => AdProductCatalog::SOURCE_MANUAL,
'sync_status' => AdProductCatalog::SYNC_IN_PROGRESS,
]);
$response = $this->actingAs($this->user)->get(route('refactor.ads.catalogs.listing'));
$response->assertOk();
$response->assertSee('data-in-progress-count', false);
$response->assertSee('mc-catalog-syncing', false);
});
test('listing partial renders error count as a clickable badge button', function () {
AdProductCatalog::create([
'uid' => 'r157eb_' . uniqid(),
'customer_id' => $this->customerId,
'name' => 'Badge Catalog R15.7',
'source_type' => AdProductCatalog::SOURCE_MANUAL,
'sync_status' => AdProductCatalog::SYNC_PARTIAL,
'error_log' => [
['stage' => 'sync', 'external_id' => 'sku-X', 'error' => 'whatever'],
['stage' => 'sync', 'external_id' => 'sku-Y', 'error' => 'whatever'],
['stage' => 'source_pull', 'external_id' => 'sku-Z', 'error' => 'whatever'],
],
]);
$response = $this->actingAs($this->user)->get(route('refactor.ads.catalogs.listing'));
$response->assertOk();
$response->assertSee('data-action="show-catalog-errors"', false);
$response->assertSee('mc-badge-clickable', false);
});