Add HL3D branding, Etsy Live/Draft paging and Projects lifecycle
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s

Replace the old touchmark/PNG identity with the supplied lockup and
monogram, segmented role-aware navigation, Live/Draft listing filters
with generation-based cache invalidation, and admin Projects covering
immutable file revisions, listing live pointers, and exact-decimal
filament/packaging/shipping costing. Additive schema only; no Etsy
publication. Isolated clone tests: 301 tests, 1162 assertions.
This commit is contained in:
2026-11-08 12:50:56 +00:00
parent c8c5822166
commit d4928789c4
83 changed files with 5808 additions and 84 deletions
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace Tests\Feature;
use App\Models\EtsyConnection;
use App\Models\Project;
use App\Models\ProjectListingLink;
use App\Models\ProjectRelease;
use App\Models\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class ProjectListingLinkTest extends TestCase
{
use LazilyRefreshDatabase;
private function connectEtsy(): EtsyConnection
{
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D']);
return 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_admin_can_attach_a_verified_listing_by_reading_it_from_etsy(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
Http::fake(['https://api.etsy.com/v3/application/listings/999*' => Http::response([
'listing_id' => 999, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active',
'last_modified_timestamp' => now()->timestamp,
])]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 999])
->assertRedirect();
$this->assertDatabaseHas('project_listing_links', [
'project_id' => $project->id, 'shop_id' => 42, 'listing_id' => 999, 'title_snapshot' => 'Dragon figurine', 'state_snapshot' => 'active',
]);
}
public function test_rejects_a_listing_that_belongs_to_a_different_shop(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
Http::fake(['https://api.etsy.com/v3/application/listings/555*' => Http::response([
'listing_id' => 555, 'shop_id' => 999999, 'title' => 'Not ours', 'state' => 'active',
])]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 555])
->assertSessionHasErrors('listing_id');
$this->assertDatabaseCount('project_listing_links', 0);
}
public function test_rejects_a_nonexistent_listing(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
Http::fake(['https://api.etsy.com/v3/application/listings/404*' => Http::response(['error' => 'not found'], 404)]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 404])
->assertSessionHasErrors('listing_id');
}
public function test_cannot_attach_the_same_listing_to_a_different_project(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
Http::fake(['https://api.etsy.com/v3/application/listings/999*' => Http::response([
'listing_id' => 999, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active',
])]);
$admin = User::factory()->create(['role' => 'admin']);
$projectA = Project::factory()->create();
$projectB = Project::factory()->create();
ProjectListingLink::factory()->for($projectA)->create(['shop_id' => 42, 'listing_id' => 999, 'variation_key' => 'all']);
$this->actingAs($admin)->post(route('projects.listings.store', $projectB), ['listing_id' => 999])
->assertSessionHasErrors('listing_id');
}
public function test_reattaching_same_listing_to_same_project_does_not_duplicate(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
Http::fake(['https://api.etsy.com/v3/application/listings/999*' => Http::response([
'listing_id' => 999, 'shop_id' => 42, 'title' => 'Dragon figurine v2', 'state' => 'active',
])]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
ProjectListingLink::factory()->for($project)->create(['shop_id' => 42, 'listing_id' => 999, 'variation_key' => 'all', 'title_snapshot' => 'old title']);
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 999])->assertRedirect();
$this->assertDatabaseCount('project_listing_links', 1);
$this->assertDatabaseHas('project_listing_links', ['listing_id' => 999, 'title_snapshot' => 'Dragon figurine v2']);
}
public function test_detaching_a_listing_preserves_its_release_history(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$link = ProjectListingLink::factory()->for($project)->create();
$this->actingAs($admin)->delete(route('projects.listings.destroy', [$project, $link]))->assertRedirect();
$link->refresh();
$this->assertNotNull($link->detached_at);
$this->assertModelExists($link);
}
public function test_attaching_a_new_unready_mapping_to_an_already_live_project_drops_it_back_to_design(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
Http::fake(['https://api.etsy.com/v3/application/listings/999*' => Http::response([
'listing_id' => 999, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active',
])]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create(['stage' => 'live']);
$existingLink = ProjectListingLink::factory()->for($project)->create(['live_release_id' => null]);
$release = ProjectRelease::factory()->for($project)->create();
$existingLink->update(['live_release_id' => $release->id]);
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 999])
->assertRedirect();
$this->assertSame('design', $project->fresh()->stage);
$this->assertDatabaseHas('project_events', ['project_id' => $project->id, 'event_type' => 'stage_changed']);
}
public function test_rejects_an_arbitrary_variation_key_since_per_variant_mapping_is_not_implemented(): void
{
Http::preventStrayRequests();
$this->connectEtsy();
Http::fake(['https://api.etsy.com/v3/application/listings/999*' => Http::response([
'listing_id' => 999, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active',
])]);
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->post(route('projects.listings.store', $project), [
'listing_id' => 999, 'variation_key' => 'color:red',
])->assertSessionHasErrors('variation_key');
$this->assertDatabaseCount('project_listing_links', 0);
}
public function test_non_admin_cannot_attach_listings(): void
{
$project = Project::factory()->create();
$this->actingAs(User::factory()->create())->post(route('projects.listings.store', $project), ['listing_id' => 1])
->assertForbidden();
}
}