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('', false) ->assertSee('', 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('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('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('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); } }