Files
HickLab3D_Dashboard/tests/Feature/ProjectListingOptionsTest.php
T

171 lines
8.4 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\EtsyConnection;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class ProjectListingOptionsTest extends TestCase
{
use LazilyRefreshDatabase;
private function connectEtsy(): void
{
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D']);
EtsyConnection::create([
'shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => 'token', 'refresh_token' => 'refresh',
'expires_at' => now()->addHour(), 'scopes' => 'listings_r listings_w transactions_r transactions_w shops_r',
]);
}
public function test_dropdown_shows_live_and_draft_listings_together_in_optgroups(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
Http::fake([
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::sequence()
->push(['count' => 1, 'results' => [['listing_id' => 101, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active']]])
->push(['count' => 1, 'results' => [['listing_id' => 202, 'shop_id' => 42, 'title' => 'Draft figurine', 'state' => 'draft']]]),
]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
->assertSee('<optgroup label="Live">', false)
->assertSee('<optgroup label="Draft">', false)
->assertSee('Dragon figurine (#101)', false)
->assertSee('Draft figurine (#202)', false);
Http::assertSentCount(2);
}
public function test_duplicate_listing_pages_never_masquerade_as_a_complete_dropdown(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
$first = array_map(fn (int $i) => ['listing_id' => $i, 'shop_id' => 42, 'title' => "Item {$i}", 'state' => 'active'], range(1, 100));
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::sequence()
->push(['count' => 150, 'results' => $first])
->push(['count' => 150, 'results' => array_slice($first, 50)])
->push(['count' => 0, 'results' => []])]);
$this->actingAs(User::factory()->create(['role' => 'admin']))
->get(route('projects.show', Project::factory()->create()))->assertOk()
->assertSee('temporarily unavailable or incomplete')->assertDontSee('<optgroup', false);
}
public function test_fetches_every_page_of_a_state_with_over_one_hundred_listings(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
$page1 = array_map(fn (int $i) => ['listing_id' => $i, 'shop_id' => 42, 'title' => "Active Item {$i}", 'state' => 'active'], range(1, 100));
$page2 = array_map(fn (int $i) => ['listing_id' => $i, 'shop_id' => 42, 'title' => "Active Item {$i}", 'state' => 'active'], range(101, 150));
Http::fake([
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::sequence()
->push(['count' => 150, 'results' => $page1])
->push(['count' => 150, 'results' => $page2])
->push(['count' => 0, 'results' => []]),
]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
->assertSee('Active Item 150 (#150)', false);
Http::assertSentCount(3);
Http::assertSent(fn ($request) => ($request['offset'] ?? null) === 0 && str_contains($request->url(), '/listings'));
Http::assertSent(fn ($request) => ($request['offset'] ?? null) === 100 && str_contains($request->url(), '/listings'));
}
public function test_fails_explicitly_rather_than_silently_truncating_when_a_state_exceeds_the_page_bound(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
// Every page claims 5000 total but only ever returns another 100 — never converges within the bound.
$hugePage = array_map(fn (int $i) => ['listing_id' => $i, 'shop_id' => 42, 'title' => "Item {$i}", 'state' => 'active'], range(1, 100));
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 5000, 'results' => $hugePage])]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
->assertSee('temporarily unavailable or incomplete')
->assertDontSee('<optgroup', false);
// 20 pages fetched (the bound) before failing explicitly — never an unbounded fetch loop.
Http::assertSentCount(20);
}
public function test_fails_explicitly_when_a_page_comes_back_short_of_the_reported_total(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 200, 'results' => []])]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
->assertSee('temporarily unavailable or incomplete')
->assertDontSee('<optgroup', false);
}
public function test_zero_results_in_both_states_is_shown_as_an_explicit_empty_state_not_an_error(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 0, 'results' => []])]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
->assertSee('No live or draft listings found')
->assertDontSee('temporarily unavailable');
}
public function test_no_etsy_connection_shows_an_explicit_message_instead_of_an_empty_dropdown(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
->assertSee('Connect the Etsy shop');
}
public function test_show_page_never_makes_a_real_network_call(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 0, 'results' => []])]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk();
}
/**
* The dropdown's rendered options are just a convenience; the server
* must never trust that a submitted listing_id was actually one of
* them. A tampered id the dropdown never offered still goes through the
* same live ownership/state re-verification as any other submission.
*/
public function test_submitting_a_listing_id_absent_from_the_rendered_dropdown_is_still_independently_verified(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
Http::fake([
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 1, 'results' => [
['listing_id' => 101, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active'],
]]),
'https://api.etsy.com/v3/application/listings/999*' => Http::response([
'listing_id' => 999, 'shop_id' => 999999, 'title' => 'Not ours', 'state' => 'active',
]),
]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()->assertDontSee('#999', false);
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 999])
->assertSessionHasErrors('listing_id');
$this->assertDatabaseCount('project_listing_links', 0);
}
}