171 lines
8.4 KiB
PHP
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);
|
|
}
|
|
}
|