Add HL3D branding, Etsy Live/Draft paging and Projects lifecycle
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
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:
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BrandAssetsTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private const EXPECTED_SHA256 = [
|
||||
'hl3d-lockup-dark-background.svg' => '609328a1f100fa5ed12fc5c59116c6e00e79faf9c22dfce745b0a8c05b3d0fa2',
|
||||
'hl3d-lockup-light-background.svg' => '9561e36ece9abb8596921cdb530cb2bebc4f475dffc7150aed3c526811fe3af6',
|
||||
'hl3d-lockup-mono-black.svg' => '4776441a3aba2b4c28768edb50717e4f6ac4e92e9fa8398c4b8b954170d2ecdd',
|
||||
'hl3d-lockup-mono-white.svg' => 'cd55fb8d459f145bd0c9ace5f3cec0f2be875cdb6d5700f32dccd47e64af623a',
|
||||
'hl3d-monogram-dark-background.svg' => '3ec17182fa2a408cfcaf8a1f6fa04cfd2d23ce661ebe8b94c8a1f4023224d3f8',
|
||||
'hl3d-monogram-light-background.svg' => '134493cbe99c171bf6b10fa93c3910fc9ea6a52806f17455c06762b3fedeca13',
|
||||
'hl3d-touchmark.svg' => 'add1001f0344d3a577bcd37dd66568a2c63a7e8b81ba1dc723e8aebc00cf81bc',
|
||||
'hl3d-touchmark-white.svg' => '364c63cc996fe96b7ca6ae96dfed59fc6b2924c000438f16ee130931971739b1',
|
||||
];
|
||||
|
||||
public function test_copied_brand_assets_match_supplied_checksums_exactly(): void
|
||||
{
|
||||
foreach (self::EXPECTED_SHA256 as $filename => $sha256) {
|
||||
$path = public_path('branding/'.$filename);
|
||||
$this->assertFileExists($path, "Missing supplied brand asset: {$filename}");
|
||||
$this->assertSame($sha256, hash_file('sha256', $path), "Checksum mismatch for {$filename}");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_dashboard_does_not_fetch_external_fonts(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get(route('dashboard'))->assertOk()
|
||||
->assertDontSee('fonts.googleapis.com', false)
|
||||
->assertDontSee('fonts.gstatic.com', false)
|
||||
->assertDontSee('fonts.bunny.net', false);
|
||||
}
|
||||
|
||||
public function test_login_page_uses_approved_light_lockup_and_no_external_fonts(): void
|
||||
{
|
||||
$this->get(route('login'))->assertOk()
|
||||
->assertSee('branding/hl3d-lockup-light-background.svg', false)
|
||||
->assertDontSee('fonts.googleapis.com', false)
|
||||
->assertDontSee('fonts.bunny.net', false);
|
||||
}
|
||||
|
||||
public function test_layout_uses_monogram_for_sidebar_and_favicon(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get(route('dashboard'))->assertOk()
|
||||
->assertSee('branding/hl3d-monogram-dark-background.svg', false)
|
||||
->assertDontSee('hicklab3d-touchmark.svg', false)
|
||||
->assertDontSee('e3f504', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\EtsyConnection;
|
||||
use App\Models\User;
|
||||
use App\Services\Etsy;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EtsyListingFilterTest 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_default_state_is_active_listings(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 1, 'results' => [['listing_id' => 1, 'shop_id' => 42, 'title' => 'Active item', 'state' => 'active']]]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index'))->assertOk()->assertSee('Active item')->assertSee('Live');
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/listings') && ($request['state'] ?? null) === 'active');
|
||||
}
|
||||
|
||||
public function test_draft_state_can_be_requested(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 1, 'results' => [['listing_id' => 2, 'shop_id' => 42, 'title' => 'Draft item', 'state' => 'draft']]]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'draft']))->assertOk()->assertSee('Draft item');
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/listings') && ($request['state'] ?? null) === 'draft');
|
||||
}
|
||||
|
||||
public function test_invalid_state_is_rejected(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'inactive']))->assertOk()->assertSee('is not a supported listing state');
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/listings'));
|
||||
}
|
||||
|
||||
public function test_invalid_page_is_rejected(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['page' => 0]))->assertOk()->assertSee('is not a valid page number');
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/listings'));
|
||||
}
|
||||
|
||||
public function test_pagination_preserves_state_and_advances_offset(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 60, 'results' => [['listing_id' => 3, 'shop_id' => 42, 'title' => 'Page two item', 'state' => 'draft']]]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'draft', 'page' => 2]))->assertOk()
|
||||
->assertSee('Page two item')->assertSee('page=1&state=draft', false)->assertSee('page=3&state=draft', false);
|
||||
Http::assertSent(fn ($request) => ($request['offset'] ?? null) === 20 && ($request['limit'] ?? null) === 20 && ($request['state'] ?? null) === 'draft');
|
||||
}
|
||||
|
||||
public function test_changing_state_resets_to_first_page(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 1, 'results' => []]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
// No explicit page param when switching state: offset must be 0, not carried over from a prior page.
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'draft']))->assertOk();
|
||||
Http::assertSent(fn ($request) => ($request['offset'] ?? null) === 0);
|
||||
}
|
||||
|
||||
public function test_listing_cache_is_separated_by_state(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::sequence()
|
||||
->push(['count' => 1, 'results' => [['listing_id' => 1, 'shop_id' => 42, 'title' => 'Active item', 'state' => 'active']]])
|
||||
->push(['count' => 1, 'results' => [['listing_id' => 2, 'shop_id' => 42, 'title' => 'Draft item', 'state' => 'draft']]]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'active']))->assertOk()->assertSee('Active item');
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'draft']))->assertOk()->assertSee('Draft item');
|
||||
// Revisiting active state should still show the active item from cache, not the draft one.
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'active']))->assertOk()->assertSee('Active item')->assertDontSee('Draft item');
|
||||
// Receipts share one cache key regardless of state, so only the first call hits the network;
|
||||
// listings are keyed per state, so active and draft each fetch once and then serve from cache.
|
||||
Http::assertSentCount(3);
|
||||
}
|
||||
|
||||
public function test_zero_results_shows_empty_state_not_an_error(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 0, 'results' => []]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'draft']))->assertOk()
|
||||
->assertSee('No draft listings returned.')
|
||||
->assertDontSee('temporarily unavailable');
|
||||
}
|
||||
|
||||
public function test_receipts_remain_visible_when_listings_fail(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => [
|
||||
['receipt_id' => 1, 'name' => 'Buyer', 'create_timestamp' => now()->timestamp],
|
||||
]]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response([], 503),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index'))->assertOk()
|
||||
->assertSee('#1')->assertSee('temporarily unavailable');
|
||||
}
|
||||
|
||||
public function test_extreme_and_array_query_parameters_never_reach_listings_api(): void
|
||||
{
|
||||
$this->connectEtsy();
|
||||
Http::fake(['*receipts*' => Http::response(['results' => []])]);
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']));
|
||||
foreach ([['page' => '99999999999999999999999'], ['state' => ['draft']], ['page' => ['1']]] as $query) {
|
||||
$this->get(route('etsy.index', $query))->assertOk()->assertSee('not a');
|
||||
}
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/listings'));
|
||||
}
|
||||
|
||||
public function test_listing_results_include_snapshot_age(): void
|
||||
{
|
||||
$this->connectEtsy();
|
||||
Http::fake(['*listings*' => Http::response(['count' => 0, 'results' => []])]);
|
||||
$result = app(Etsy::class)->listListings(EtsyConnection::firstOrFail(), 'draft', 25, 25);
|
||||
$this->assertIsInt($result['fetched_at']);
|
||||
$this->assertFalse($result['stale']);
|
||||
}
|
||||
|
||||
public function test_guest_is_redirected(): void
|
||||
{
|
||||
$this->get(route('etsy.index'))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_non_admin_forbidden(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create())->get(route('etsy.index'))->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LegacyRetentionTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_additive_project_migrations_do_not_disturb_legacy_filament_rows(): void
|
||||
{
|
||||
$userId = DB::table('users')->insertGetId([
|
||||
'username' => 'legacy-user', 'email' => 'legacy@example.test', 'password' => 'x', 'role' => 'user',
|
||||
]);
|
||||
$filamentId = DB::table('filamentTracker')->insertGetId([
|
||||
'userId' => $userId, 'filamentName' => 'Legacy PLA', 'amazonUrl' => 'https://example.test/x',
|
||||
'filamentWeight' => 1000, 'brand' => 'LegacyBrand', 'material' => 'PLA', 'color' => 'Black', 'itemDiameter' => 1.75,
|
||||
]);
|
||||
DB::table('filamentPriceHistory')->insert([
|
||||
'filamentId' => $filamentId, 'price' => 19.99, 'recordedAt' => now(),
|
||||
]);
|
||||
|
||||
// Re-running migrate must be a no-op for already-applied migrations and
|
||||
// must not touch legacy rows that predate the project/costing tables.
|
||||
Artisan::call('migrate', ['--force' => true]);
|
||||
|
||||
$this->assertDatabaseHas('filamentTracker', ['id' => $filamentId, 'filamentName' => 'Legacy PLA', 'userId' => $userId]);
|
||||
$this->assertDatabaseHas('filamentPriceHistory', ['filamentId' => $filamentId, 'price' => 19.99]);
|
||||
$this->assertDatabaseCount('filamentTracker', 1);
|
||||
}
|
||||
|
||||
public function test_project_tables_are_additive_and_coexist_with_legacy_tables(): void
|
||||
{
|
||||
$this->assertTrue(Schema::hasTable('filamentTracker'));
|
||||
$this->assertTrue(Schema::hasTable('filamentPriceHistory'));
|
||||
$this->assertTrue(Schema::hasTable('bambuPrinters'));
|
||||
$this->assertTrue(Schema::hasTable('projects'));
|
||||
$this->assertTrue(Schema::hasTable('project_cost_snapshots'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NavigationTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_regular_user_only_sees_non_admin_groups(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('dashboard'));
|
||||
|
||||
$response->assertOk()->assertSee('Workspace')->assertSee('Workshop')
|
||||
->assertDontSee('Projects')->assertDontSee('Commerce')->assertDontSee('Administration');
|
||||
}
|
||||
|
||||
public function test_admin_sees_every_navigation_group_with_no_empty_group(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('dashboard'));
|
||||
|
||||
$response->assertOk()
|
||||
->assertSee('Workspace')->assertSee('Projects')
|
||||
->assertSee('Workshop')->assertSee('Print farm')->assertSee('Dryer')
|
||||
->assertSee('Commerce')->assertSee('Etsy')->assertSee('Shipping')
|
||||
->assertSee('Administration')->assertSee('Settings')->assertSee('Users');
|
||||
}
|
||||
|
||||
public function test_active_route_gets_aria_current(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.index'))
|
||||
->assertOk()
|
||||
->assertSee('aria-current="page"', false);
|
||||
}
|
||||
|
||||
public function test_mobile_drawer_markup_is_present_and_accessible(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get(route('dashboard'))->assertOk()
|
||||
->assertSee('id="mobile-nav"', false)
|
||||
->assertSee('popover="auto"', false)
|
||||
->assertSee('popovertarget="mobile-nav"', false)
|
||||
->assertSee('aria-label="Navigation menu"', false);
|
||||
}
|
||||
|
||||
public function test_desktop_sidebar_is_hidden_below_the_lg_breakpoint(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$html = $this->actingAs($user)->get(route('dashboard'))->assertOk()->getContent();
|
||||
|
||||
// The bug: on a 390px viewport the desktop <aside> rendered as a full
|
||||
// visible block (no `hidden`), pushing the real page header down to
|
||||
// ~y673. It must not render at all below the `lg` breakpoint; the
|
||||
// separate #mobile-nav popover is the only mobile navigation surface.
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<aside[^>]*data-sidebar[^>]*class="[^"]*\bhidden\b[^"]*\blg:block\b[^"]*"/',
|
||||
$html,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_sidebar_header_uses_the_supplied_lockup_when_expanded_and_monogram_when_collapsed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$html = $this->actingAs($user)->get(route('dashboard'))->assertOk()->getContent();
|
||||
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<img[^>]*src="[^"]*hl3d-lockup-dark-background\.svg"[^>]*data-sidebar-expanded-mark/',
|
||||
$html,
|
||||
);
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<img[^>]*src="[^"]*hl3d-monogram-dark-background\.svg"[^>]*class="[^"]*\bhidden\b[^"]*"[^>]*data-sidebar-collapsed-mark/',
|
||||
$html,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectCostSnapshot;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectCompletionTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_shipping_allocation_is_applied_to_batch_not_just_recorded(): void
|
||||
{
|
||||
$project = Project::factory()->create(['currency' => 'GBP']);
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 4,
|
||||
'lines' => [['category' => 'shipping', 'description' => 'Two units per parcel', 'quantity' => '1', 'quantity_unit' => 'parcel', 'unit_rate' => '5', 'units_per_parcel' => 2]],
|
||||
])->assertRedirect();
|
||||
$this->assertSame('10.0000', ProjectCostSnapshot::firstOrFail()->shipping_total);
|
||||
$this->assertSame('2.5000', ProjectCostSnapshot::firstOrFail()->perUnitGrandTotal());
|
||||
}
|
||||
|
||||
public function test_cost_currency_cannot_diverge_from_project(): void
|
||||
{
|
||||
$project = Project::factory()->create(['currency' => 'GBP']);
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'USD', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '10', 'quantity_unit' => 'g', 'unit_rate' => '20']],
|
||||
])->assertSessionHasErrors('currency');
|
||||
$this->assertDatabaseCount('project_cost_snapshots', 0);
|
||||
}
|
||||
|
||||
public function test_costing_and_revert_have_usable_labeled_forms(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('Add cost line')->assertSee('Saleable batch yield')->assertSee('Units per parcel')->assertSee('Fusion document');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectCostSnapshot;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectCostSnapshotTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_admin_can_record_a_cost_snapshot_with_multiple_material_lines(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id,
|
||||
'basis' => 'estimate',
|
||||
'currency' => 'GBP',
|
||||
'batch_units' => 4,
|
||||
'lines' => [
|
||||
['category' => 'filament', 'description' => 'PLA black', 'quantity' => '200', 'quantity_unit' => 'g', 'unit_rate' => '18.000000', 'waste_quantity' => '10'],
|
||||
['category' => 'filament', 'description' => 'PETG clear', 'quantity' => '50', 'quantity_unit' => 'g', 'unit_rate' => '22.000000'],
|
||||
['category' => 'packaging', 'description' => 'Box', 'quantity' => '1', 'quantity_unit' => 'unit', 'unit_rate' => '0.650000'],
|
||||
['category' => 'shipping', 'description' => 'Parcel', 'quantity' => '1', 'quantity_unit' => 'parcel', 'unit_rate' => '3.990000', 'units_per_parcel' => '4'],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$snapshot = ProjectCostSnapshot::first();
|
||||
$this->assertNotNull($snapshot);
|
||||
// (200g + 10g waste) * £18.00/kg = £3.78, plus 50g * £22.00/kg = £1.10.
|
||||
$this->assertSame('4.8800', (string) $snapshot->filament_total);
|
||||
$this->assertSame(4, $snapshot->lines()->count());
|
||||
}
|
||||
|
||||
public function test_missing_category_is_not_costed_rather_than_zero(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '0', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']],
|
||||
]);
|
||||
|
||||
$snapshot = ProjectCostSnapshot::first();
|
||||
$this->assertSame('0.0000', (string) $snapshot->filament_total);
|
||||
$this->assertNull($snapshot->packaging_total);
|
||||
$this->assertNull($snapshot->shipping_total);
|
||||
}
|
||||
|
||||
public function test_negative_quantity_is_rejected(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '-5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']],
|
||||
])->assertSessionHasErrors('lines');
|
||||
$this->assertDatabaseCount('project_cost_snapshots', 0);
|
||||
}
|
||||
|
||||
public function test_zero_or_negative_batch_yield_is_rejected(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 0,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']],
|
||||
])->assertSessionHasErrors('batch_units');
|
||||
}
|
||||
|
||||
public function test_invalid_currency_is_rejected(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'ZZZ', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']],
|
||||
])->assertSessionHasErrors('currency');
|
||||
}
|
||||
|
||||
public function test_actual_snapshot_never_overwrites_the_estimate(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '100', 'quantity_unit' => 'g', 'unit_rate' => '20.000000']],
|
||||
]);
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'actual', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '110', 'quantity_unit' => 'g', 'unit_rate' => '21.000000']],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('project_cost_snapshots', 2);
|
||||
$this->assertDatabaseHas('project_cost_snapshots', ['basis' => 'estimate', 'filament_total' => '2.0000']);
|
||||
$this->assertDatabaseHas('project_cost_snapshots', ['basis' => 'actual', 'filament_total' => '2.3100']);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_record_cost_snapshots(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs(User::factory()->create())->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']],
|
||||
])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_forged_line_total_in_the_request_is_ignored_in_favour_of_the_computed_value(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [[
|
||||
'category' => 'filament', 'description' => 'PLA', 'quantity' => '100', 'quantity_unit' => 'g',
|
||||
'unit_rate' => '20.000000', 'line_total' => '0.0001',
|
||||
]],
|
||||
]);
|
||||
|
||||
$snapshot = ProjectCostSnapshot::first();
|
||||
// 100g * £20.00/kg = £2.00, regardless of the forged line_total in the payload.
|
||||
$this->assertSame('2.0000', (string) $snapshot->filament_total);
|
||||
$this->assertSame('2.0000', (string) $snapshot->lines()->first()->line_total);
|
||||
}
|
||||
|
||||
public function test_quantity_unit_must_match_the_category_allowlist(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '100', 'quantity_unit' => 'kg', 'unit_rate' => '20.000000']],
|
||||
])->assertSessionHasErrors('lines');
|
||||
$this->assertDatabaseCount('project_cost_snapshots', 0);
|
||||
}
|
||||
|
||||
public function test_shipping_line_requires_a_positive_units_per_parcel(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'shipping', 'description' => 'Parcel', 'quantity' => '1', 'quantity_unit' => 'parcel', 'unit_rate' => '4.990000']],
|
||||
])->assertSessionHasErrors('lines.0.units_per_parcel');
|
||||
$this->assertDatabaseCount('project_cost_snapshots', 0);
|
||||
}
|
||||
|
||||
public function test_combined_lines_exceeding_storable_precision_are_rejected_as_validation_not_a_server_error(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [
|
||||
['category' => 'packaging', 'description' => 'Box A', 'quantity' => '1000000', 'quantity_unit' => 'unit', 'unit_rate' => '6000.000000'],
|
||||
['category' => 'packaging', 'description' => 'Box B', 'quantity' => '1000000', 'quantity_unit' => 'unit', 'unit_rate' => '6000.000000'],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('lines');
|
||||
$response->assertStatus(302);
|
||||
$this->assertDatabaseCount('project_cost_snapshots', 0);
|
||||
}
|
||||
|
||||
public function test_cannot_cost_a_revision_from_a_different_project(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$projectA = Project::factory()->create();
|
||||
$projectB = Project::factory()->create();
|
||||
$revisionOfB = ProjectRevision::factory()->for($projectB)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $projectA), [
|
||||
'revision_id' => $revisionOfB->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']],
|
||||
])->assertNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectListingLink;
|
||||
use App\Models\ProjectRelease;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectLifecycleTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_guest_is_redirected_to_login(): void
|
||||
{
|
||||
$this->get(route('projects.index'))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_regular_user_cannot_view_projects(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create())->get(route('projects.index'))->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_admin_can_view_empty_project_index(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.index'))->assertOk()->assertSee('Projects');
|
||||
}
|
||||
|
||||
public function test_admin_can_create_a_project(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.store'), [
|
||||
'reference' => 'PRJ-0001',
|
||||
'title' => 'Articulated dragon',
|
||||
'notes' => 'Test product',
|
||||
'currency' => 'GBP',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseHas('projects', ['reference' => 'PRJ-0001', 'title' => 'Articulated dragon', 'stage' => 'design']);
|
||||
}
|
||||
|
||||
public function test_regular_user_cannot_create_a_project(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create())->post(route('projects.store'), [
|
||||
'reference' => 'PRJ-0002', 'title' => 'Nope', 'currency' => 'GBP',
|
||||
])->assertForbidden();
|
||||
$this->assertDatabaseMissing('projects', ['reference' => 'PRJ-0002']);
|
||||
}
|
||||
|
||||
public function test_reference_must_be_unique(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
Project::factory()->create(['reference' => 'PRJ-DUP']);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.store'), [
|
||||
'reference' => 'PRJ-DUP', 'title' => 'Dup', 'currency' => 'GBP',
|
||||
])->assertSessionHasErrors('reference');
|
||||
}
|
||||
|
||||
public function test_currency_must_be_a_valid_iso_code(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.store'), [
|
||||
'reference' => 'PRJ-FX', 'title' => 'Bad currency', 'currency' => 'XYZ',
|
||||
])->assertSessionHasErrors('currency');
|
||||
}
|
||||
|
||||
public function test_admin_can_view_project_detail(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['title' => 'Detail view project']);
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()->assertSee('Detail view project');
|
||||
}
|
||||
|
||||
public function test_admin_can_edit_project_details(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['title' => 'Old title']);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['title' => 'New title'])->assertRedirect();
|
||||
$this->assertSame('New title', $project->fresh()->title);
|
||||
}
|
||||
|
||||
public function test_stage_can_move_forward_and_backward_reversibly(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['stage' => 'design']);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'ready'])->assertRedirect();
|
||||
$this->assertSame('ready', $project->fresh()->stage);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'design'])->assertRedirect();
|
||||
$this->assertSame('design', $project->fresh()->stage);
|
||||
}
|
||||
|
||||
public function test_moving_to_live_requires_an_approved_released_listing_mapping(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['stage' => 'ready']);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'live'])
|
||||
->assertSessionHasErrors('stage');
|
||||
$this->assertSame('ready', $project->fresh()->stage);
|
||||
}
|
||||
|
||||
public function test_moving_to_live_succeeds_once_a_listing_has_a_live_release(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['stage' => 'ready']);
|
||||
ProjectListingLink::factory()->for($project)->create(['live_release_id' => null]);
|
||||
$release = ProjectRelease::factory()->for($project)->create();
|
||||
$project->listingLinks()->first()->update(['live_release_id' => $release->id]);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'live'])->assertRedirect();
|
||||
$this->assertSame('live', $project->fresh()->stage);
|
||||
}
|
||||
|
||||
public function test_moving_to_live_is_rejected_unless_every_non_detached_mapping_is_released(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['stage' => 'ready']);
|
||||
$releasedLink = ProjectListingLink::factory()->for($project)->create(['live_release_id' => null]);
|
||||
$release = ProjectRelease::factory()->for($project)->create();
|
||||
$releasedLink->update(['live_release_id' => $release->id]);
|
||||
ProjectListingLink::factory()->for($project)->create(['live_release_id' => null]);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'live'])
|
||||
->assertSessionHasErrors('stage');
|
||||
$this->assertSame('ready', $project->fresh()->stage);
|
||||
}
|
||||
|
||||
public function test_moving_to_live_ignores_detached_mappings_without_a_release(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['stage' => 'ready']);
|
||||
$releasedLink = ProjectListingLink::factory()->for($project)->create(['live_release_id' => null]);
|
||||
$release = ProjectRelease::factory()->for($project)->create();
|
||||
$releasedLink->update(['live_release_id' => $release->id]);
|
||||
ProjectListingLink::factory()->for($project)->create(['live_release_id' => null, 'detached_at' => now()]);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'live'])->assertRedirect();
|
||||
$this->assertSame('live', $project->fresh()->stage);
|
||||
}
|
||||
|
||||
public function test_rejects_unknown_stage(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'launched'])
|
||||
->assertSessionHasErrors('stage');
|
||||
}
|
||||
|
||||
public function test_admin_can_archive_a_project_without_deleting_it(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.archive', $project))->assertRedirect();
|
||||
$project->refresh();
|
||||
$this->assertNotNull($project->archived_at);
|
||||
$this->assertModelExists($project);
|
||||
}
|
||||
|
||||
public function test_archiving_records_a_project_event(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.archive', $project));
|
||||
|
||||
$this->assertDatabaseHas('project_events', ['project_id' => $project->id, 'event_type' => 'archived']);
|
||||
}
|
||||
|
||||
public function test_project_creation_records_an_event(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.store'), [
|
||||
'reference' => 'PRJ-EVT', 'title' => 'Event test', 'currency' => 'GBP',
|
||||
]);
|
||||
|
||||
$project = Project::where('reference', 'PRJ-EVT')->first();
|
||||
$this->assertDatabaseHas('project_events', ['project_id' => $project->id, 'event_type' => 'created']);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectCostSnapshot;
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\ProjectListingLink;
|
||||
use App\Models\ProjectRelease;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectReleaseTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
private function approvedRevisionWithCost(Project $project, int $revisionNumber = 1): array
|
||||
{
|
||||
$revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => $revisionNumber]);
|
||||
ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
$snapshot = ProjectCostSnapshot::factory()->for($project)->for($revision, 'revision')->create([
|
||||
'grand_total' => '10.0000', 'filament_total' => '10.0000',
|
||||
'packaging_total' => '0.0000', 'shipping_total' => '0.0000',
|
||||
]);
|
||||
|
||||
return [$revision, $snapshot];
|
||||
}
|
||||
|
||||
public function test_admin_can_release_an_approved_revision_to_a_listing(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$revision, $snapshot] = $this->approvedRevisionWithCost($project);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $revision->id, 'cost_snapshot_id' => $snapshot->id, 'lock_version' => 0,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$link->refresh();
|
||||
$this->assertNotNull($link->live_release_id);
|
||||
$this->assertSame(1, $link->lock_version);
|
||||
$this->assertDatabaseHas('project_releases', ['project_id' => $project->id, 'revision_id' => $revision->id, 'action' => 'release']);
|
||||
}
|
||||
|
||||
public function test_uploading_v2_does_not_move_the_live_pointer(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$v1, $snapshot1] = $this->approvedRevisionWithCost($project, 1);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $v1->id, 'cost_snapshot_id' => $snapshot1->id, 'lock_version' => 0,
|
||||
]);
|
||||
$link->refresh();
|
||||
$firstReleaseId = $link->live_release_id;
|
||||
|
||||
[$v2, $snapshot2] = $this->approvedRevisionWithCost($project, 2);
|
||||
|
||||
$link->refresh();
|
||||
$this->assertSame($firstReleaseId, $link->live_release_id);
|
||||
}
|
||||
|
||||
public function test_releasing_v2_then_reverting_restores_v1_with_new_history_entry(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$v1, $snapshot1] = $this->approvedRevisionWithCost($project, 1);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $v1->id, 'cost_snapshot_id' => $snapshot1->id, 'lock_version' => 0,
|
||||
]);
|
||||
$link->refresh();
|
||||
$firstRelease = $link->live_release_id;
|
||||
|
||||
[$v2, $snapshot2] = $this->approvedRevisionWithCost($project, 2);
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $v2->id, 'cost_snapshot_id' => $snapshot2->id, 'lock_version' => $link->lock_version,
|
||||
]);
|
||||
$link->refresh();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.revert', [$project, $link]), [
|
||||
'release_id' => $firstRelease, 'lock_version' => $link->lock_version,
|
||||
])->assertRedirect();
|
||||
|
||||
$link->refresh();
|
||||
$revertRelease = ProjectRelease::find($link->live_release_id);
|
||||
$this->assertSame('revert', $revertRelease->action);
|
||||
$this->assertSame($v1->id, $revertRelease->revision_id);
|
||||
$this->assertDatabaseCount('project_releases', 3);
|
||||
}
|
||||
|
||||
public function test_optimistic_concurrency_rejects_stale_lock_version(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$v1, $snapshot1] = $this->approvedRevisionWithCost($project, 1);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $v1->id, 'cost_snapshot_id' => $snapshot1->id, 'lock_version' => 0,
|
||||
]);
|
||||
|
||||
[$v2, $snapshot2] = $this->approvedRevisionWithCost($project, 2);
|
||||
$response = $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $v2->id, 'cost_snapshot_id' => $snapshot2->id, 'lock_version' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(409);
|
||||
}
|
||||
|
||||
public function test_cannot_release_a_revision_from_a_different_project(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$projectA = Project::factory()->create();
|
||||
$projectB = Project::factory()->create();
|
||||
[$revisionOfB] = $this->approvedRevisionWithCost($projectB);
|
||||
$link = ProjectListingLink::factory()->for($projectA)->create(['lock_version' => 0]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$projectA, $link]), [
|
||||
'revision_id' => $revisionOfB->id, 'cost_snapshot_id' => 999999, 'lock_version' => 0,
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_cannot_release_an_unapproved_revision(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$draft = ProjectRevision::factory()->for($project)->create(['status' => 'draft']);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $draft->id, 'cost_snapshot_id' => 999999, 'lock_version' => 0,
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_cannot_release_to_a_listing_link_from_another_project(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$projectA = Project::factory()->create();
|
||||
$projectB = Project::factory()->create();
|
||||
[$revisionOfA] = $this->approvedRevisionWithCost($projectA);
|
||||
$linkOfB = ProjectListingLink::factory()->for($projectB)->create(['lock_version' => 0]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$projectA, $linkOfB]), [
|
||||
'revision_id' => $revisionOfA->id, 'lock_version' => 0,
|
||||
])->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_release(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0]);
|
||||
|
||||
$this->actingAs(User::factory()->create())->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => 1, 'lock_version' => 0,
|
||||
])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_release_requires_a_cost_snapshot(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => 1]);
|
||||
ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $revision->id, 'lock_version' => 0,
|
||||
])->assertSessionHasErrors('cost_snapshot_id');
|
||||
$this->assertDatabaseCount('project_releases', 0);
|
||||
}
|
||||
|
||||
public function test_release_rejects_an_incomplete_cost_snapshot(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => 1]);
|
||||
ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
$incomplete = ProjectCostSnapshot::factory()->for($project)->for($revision, 'revision')->create([
|
||||
'grand_total' => '10.0000', 'filament_total' => '10.0000',
|
||||
'packaging_total' => null, 'shipping_total' => null,
|
||||
]);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $revision->id, 'cost_snapshot_id' => $incomplete->id, 'lock_version' => 0,
|
||||
])->assertSessionHasErrors('cost_snapshot_id');
|
||||
$this->assertDatabaseCount('project_releases', 0);
|
||||
}
|
||||
|
||||
public function test_release_rejects_a_detached_listing_link(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$revision, $snapshot] = $this->approvedRevisionWithCost($project);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'detached_at' => now()]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $revision->id, 'cost_snapshot_id' => $snapshot->id, 'lock_version' => 0,
|
||||
])->assertSessionHasErrors('listing_link');
|
||||
$this->assertDatabaseCount('project_releases', 0);
|
||||
}
|
||||
|
||||
public function test_revert_rejects_a_detached_listing_link(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$v1, $snapshot1] = $this->approvedRevisionWithCost($project);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $v1->id, 'cost_snapshot_id' => $snapshot1->id, 'lock_version' => 0,
|
||||
]);
|
||||
$link->refresh();
|
||||
$firstRelease = $link->live_release_id;
|
||||
$link->update(['detached_at' => now()]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.revert', [$project, $link]), [
|
||||
'release_id' => $firstRelease, 'lock_version' => $link->lock_version,
|
||||
])->assertSessionHasErrors('listing_link');
|
||||
}
|
||||
|
||||
public function test_release_is_recorded_atomically_with_its_audit_event(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$revision, $snapshot] = $this->approvedRevisionWithCost($project);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
Schema::drop('project_events');
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $revision->id, 'cost_snapshot_id' => $snapshot->id, 'lock_version' => 0,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('project_releases', 0);
|
||||
$link->refresh();
|
||||
$this->assertNull($link->live_release_id);
|
||||
$this->assertSame(0, $link->lock_version);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectRevisionFileTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
private function stl(string $name = 'part.stl'): UploadedFile
|
||||
{
|
||||
$content = "solid cube\nfacet normal 0 0 0\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n";
|
||||
|
||||
return UploadedFile::fake()->createWithContent($name, $content);
|
||||
}
|
||||
|
||||
public function test_admin_can_upload_a_new_revision_with_files(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'change_notes' => 'First cut',
|
||||
'files' => [$this->stl('body.stl')],
|
||||
'roles' => ['body'],
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 1, 'status' => 'draft']);
|
||||
$revision = ProjectRevision::first();
|
||||
$file = ProjectFile::where('revision_id', $revision->id)->first();
|
||||
$this->assertNotNull($file);
|
||||
$this->assertSame('body.stl', $file->original_filename);
|
||||
$this->assertNotSame('body.stl', $file->storage_key);
|
||||
Storage::disk('project_files')->assertExists($file->storage_key);
|
||||
}
|
||||
|
||||
public function test_revision_numbers_increment_per_project(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
Storage::fake('project_files');
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl()], 'roles' => ['body'],
|
||||
]);
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl()], 'roles' => ['body'],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 1]);
|
||||
$this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 2]);
|
||||
}
|
||||
|
||||
public function test_rejects_upload_exceeding_file_count_limit(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$files = [];
|
||||
$roles = [];
|
||||
for ($i = 0; $i < 11; $i++) {
|
||||
$files[] = $this->stl("part{$i}.stl");
|
||||
$roles[] = 'body';
|
||||
}
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => $files, 'roles' => $roles,
|
||||
])->assertSessionHasErrors('files');
|
||||
}
|
||||
|
||||
public function test_rejects_malicious_zip_disguised_as_3mf(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$path = tempnam(sys_get_temp_dir(), 'zip');
|
||||
file_put_contents($path, random_bytes(64));
|
||||
$file = new UploadedFile($path, 'evil.3mf', null, null, true);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$file], 'roles' => ['body'],
|
||||
])->assertSessionHasErrors('files.0');
|
||||
|
||||
$this->assertDatabaseCount('project_files', 0);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_failed_validation_leaves_no_orphaned_stored_bytes(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$path = tempnam(sys_get_temp_dir(), 'zip');
|
||||
file_put_contents($path, random_bytes(64));
|
||||
$badFile = new UploadedFile($path, 'evil.3mf', null, null, true);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl('good.stl'), $badFile], 'roles' => ['body', 'body'],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('project_files', 0);
|
||||
$this->assertCount(0, Storage::disk('project_files')->allFiles());
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_non_validation_failure_mid_upload_cleans_up_stored_bytes(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$calls = 0;
|
||||
ProjectFile::creating(function () use (&$calls) {
|
||||
$calls++;
|
||||
if ($calls === 2) {
|
||||
throw new \RuntimeException('Simulated DB failure mid-upload.');
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl('a.stl'), $this->stl('b.stl')],
|
||||
'roles' => ['body', 'body'],
|
||||
]);
|
||||
} finally {
|
||||
ProjectFile::flushEventListeners();
|
||||
}
|
||||
|
||||
$this->assertDatabaseCount('project_files', 0);
|
||||
$this->assertCount(0, Storage::disk('project_files')->allFiles());
|
||||
}
|
||||
|
||||
public function test_rejects_a_batch_whose_combined_size_is_excessive(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$files = [];
|
||||
$roles = [];
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$files[] = UploadedFile::fake()->create("big{$i}.stl", 90 * 1024, 'model/stl');
|
||||
$roles[] = 'body';
|
||||
}
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => $files, 'roles' => $roles,
|
||||
])->assertSessionHasErrors('files');
|
||||
$this->assertDatabaseCount('project_files', 0);
|
||||
}
|
||||
|
||||
public function test_approving_a_revision_locks_in_approver_and_timestamp(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl()], 'roles' => ['body'],
|
||||
]);
|
||||
$revision = ProjectRevision::first();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.approve', [$project, $revision]))->assertRedirect();
|
||||
|
||||
$revision->refresh();
|
||||
$this->assertSame('approved', $revision->status);
|
||||
$this->assertSame($admin->id, $revision->approved_by);
|
||||
$this->assertNotNull($revision->approved_at);
|
||||
}
|
||||
|
||||
public function test_approved_revision_cannot_be_modified_by_adding_more_files(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => 1]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl()], 'roles' => ['body'], 'revision_id' => $revision->id,
|
||||
]);
|
||||
|
||||
// A correction always creates a new revision rather than mutating the approved one.
|
||||
$this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 2]);
|
||||
$this->assertSame(0, ProjectFile::where('revision_id', $revision->id)->count());
|
||||
}
|
||||
|
||||
public function test_guest_cannot_download_project_file(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$file = ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
|
||||
$this->get(route('projects.files.download', [$project, $file]))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_cannot_download_a_file_through_a_different_projects_url(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$projectA = Project::factory()->create();
|
||||
$projectB = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($projectA)->create();
|
||||
$file = ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.files.download', [$projectB, $file]))->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_admin_can_download_an_owned_project_file(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl('mypart.stl')], 'roles' => ['body'],
|
||||
]);
|
||||
$file = ProjectFile::first();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.files.download', [$project, $file]))
|
||||
->assertOk()
|
||||
->assertHeader('content-disposition', 'attachment; filename=mypart.stl');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\ProjectListingLink;
|
||||
use App\Models\ProjectRelease;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectSecurityTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_disabled_admin_is_logged_out_before_reaching_project_routes(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin', 'disabled' => true]);
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.index'))
|
||||
->assertRedirect(route('login'));
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_project_notes_with_script_tags_are_rendered_escaped_not_executed(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['notes' => '<script>alert(1)</script>']);
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('projects.show', $project));
|
||||
|
||||
$response->assertOk()
|
||||
->assertDontSee('<script>alert(1)</script>', false)
|
||||
->assertSee('<script>alert(1)</script>', false);
|
||||
}
|
||||
|
||||
public function test_revision_change_notes_with_html_are_escaped_in_the_project_view(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
ProjectRevision::factory()->for($project)->create(['change_notes' => '<img src=x onerror=alert(1)>']);
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertDontSee('<img src=x onerror=alert(1)>', false);
|
||||
}
|
||||
|
||||
public function test_every_mutating_project_endpoint_rejects_guests(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$file = ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
$listingLink = ProjectListingLink::factory()->for($project)->create();
|
||||
$release = ProjectRelease::factory()->for($project)->create();
|
||||
|
||||
$this->post(route('projects.store'), [])->assertRedirect(route('login'));
|
||||
$this->patch(route('projects.update', $project), [])->assertRedirect(route('login'));
|
||||
$this->post(route('projects.archive', $project))->assertRedirect(route('login'));
|
||||
$this->post(route('projects.revisions.store', $project), [])->assertRedirect(route('login'));
|
||||
$this->post(route('projects.revisions.approve', [$project, $revision]))->assertRedirect(route('login'));
|
||||
$this->get(route('projects.files.download', [$project, $file]))->assertRedirect(route('login'));
|
||||
$this->post(route('projects.listings.store', $project), [])->assertRedirect(route('login'));
|
||||
$this->delete(route('projects.listings.destroy', [$project, $listingLink]))->assertRedirect(route('login'));
|
||||
$this->post(route('projects.cost-snapshots.store', $project), [])->assertRedirect(route('login'));
|
||||
$this->post(route('projects.listings.release', [$project, $listingLink]), [])->assertRedirect(route('login'));
|
||||
$this->post(route('projects.listings.revert', [$project, $listingLink]), ['release_id' => $release->id])->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_every_mutating_project_endpoint_rejects_non_admin_users(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$file = ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
$listingLink = ProjectListingLink::factory()->for($project)->create();
|
||||
$release = ProjectRelease::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($user)->post(route('projects.store'), [])->assertForbidden();
|
||||
$this->actingAs($user)->patch(route('projects.update', $project), [])->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.archive', $project))->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.revisions.store', $project), [])->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.revisions.approve', [$project, $revision]))->assertForbidden();
|
||||
$this->actingAs($user)->get(route('projects.files.download', [$project, $file]))->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.listings.store', $project), [])->assertForbidden();
|
||||
$this->actingAs($user)->delete(route('projects.listings.destroy', [$project, $listingLink]))->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.cost-snapshots.store', $project), [])->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.listings.release', [$project, $listingLink]), [])->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.listings.revert', [$project, $listingLink]), ['release_id' => $release->id])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_disabled_admin_cannot_use_any_mutating_project_endpoint(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin', 'disabled' => true]);
|
||||
$project = Project::factory()->create();
|
||||
$listingLink = ProjectListingLink::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.store'), [])->assertRedirect(route('login'));
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $listingLink]), [])->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_cross_project_revision_id_is_rejected_rather_than_silently_creating_a_new_revision(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$projectA = Project::factory()->create();
|
||||
$projectB = Project::factory()->create();
|
||||
$foreignRevision = ProjectRevision::factory()->for($projectB)->create(['revision_number' => 1]);
|
||||
Storage::fake('project_files');
|
||||
$stl = UploadedFile::fake()->createWithContent(
|
||||
'part.stl',
|
||||
"solid cube\nfacet normal 0 0 0\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n",
|
||||
);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $projectA), [
|
||||
'files' => [$stl], 'roles' => ['body'], 'revision_id' => $foreignRevision->id,
|
||||
])->assertRedirect();
|
||||
|
||||
// A revision_id that doesn't belong to this project must never be written to;
|
||||
// it must fall through to creating a fresh revision scoped to this project.
|
||||
$this->assertDatabaseHas('project_revisions', ['project_id' => $projectA->id, 'revision_number' => 1]);
|
||||
$this->assertSame(0, $foreignRevision->fresh()->files()->count());
|
||||
}
|
||||
|
||||
public function test_missing_csrf_token_is_rejected_when_verification_actually_runs(): void
|
||||
{
|
||||
$this->app['env'] = 'local';
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.archive', $project));
|
||||
|
||||
$response->assertStatus(419);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Support\FixedPoint;
|
||||
use InvalidArgumentException;
|
||||
use OverflowException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FixedPointTest extends TestCase
|
||||
{
|
||||
public function test_parses_decimal_string_to_scaled_integer(): void
|
||||
{
|
||||
$this->assertSame(1500, FixedPoint::toScaled('1.5', 3));
|
||||
$this->assertSame(-1500, FixedPoint::toScaled('-1.5', 3));
|
||||
$this->assertSame(1000000, FixedPoint::toScaled('1', 6));
|
||||
$this->assertSame(0, FixedPoint::toScaled('0', 4));
|
||||
}
|
||||
|
||||
public function test_rejects_malformed_decimal_strings(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::toScaled('1.5.3', 3);
|
||||
}
|
||||
|
||||
public function test_rejects_non_numeric_strings(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::toScaled('abc', 3);
|
||||
}
|
||||
|
||||
public function test_rejects_values_exceeding_bound(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::toScaled('100000000', 6, maxAbs: 1_000_000);
|
||||
}
|
||||
|
||||
public function test_rejects_negative_values_when_disallowed(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::toScaled('-5', 3, allowNegative: false);
|
||||
}
|
||||
|
||||
public function test_multiplies_two_scaled_values_and_rescales_with_round_half_up(): void
|
||||
{
|
||||
// 2.5 grams (scale 3) * 4.0 rate per kg (scale 6) => product scale 9, then /1000 for g->kg => rescale to scale 4.
|
||||
$grams = FixedPoint::toScaled('2.500', 3);
|
||||
$rate = FixedPoint::toScaled('4.000000', 6);
|
||||
$productScale9 = FixedPoint::multiply($grams, $rate);
|
||||
// raw cost = 2.5g * (4.0/1000 per g) = 0.01 -> scale 4 => 100
|
||||
$result = FixedPoint::rescale($productScale9, 9 + 3, 4);
|
||||
$this->assertSame(100, $result);
|
||||
$this->assertSame('0.0100', FixedPoint::format($result, 4));
|
||||
}
|
||||
|
||||
public function test_rescale_rounds_half_up(): void
|
||||
{
|
||||
// 0.00005 at scale 5 rescaled to scale 4 should round up to 0.0001 (not banker's rounding).
|
||||
$this->assertSame(1, FixedPoint::rescale(5, 5, 4));
|
||||
$this->assertSame(0, FixedPoint::rescale(4, 5, 4));
|
||||
$this->assertSame(-1, FixedPoint::rescale(-5, 5, 4));
|
||||
}
|
||||
|
||||
public function test_add_and_subtract_scaled_values(): void
|
||||
{
|
||||
$a = FixedPoint::toScaled('10.1234', 4);
|
||||
$b = FixedPoint::toScaled('0.0001', 4);
|
||||
$this->assertSame(101235, FixedPoint::add($a, $b));
|
||||
$this->assertSame(101233, FixedPoint::subtract($a, $b));
|
||||
}
|
||||
|
||||
public function test_add_detects_overflow(): void
|
||||
{
|
||||
$this->expectException(OverflowException::class);
|
||||
FixedPoint::add(PHP_INT_MAX, 1);
|
||||
}
|
||||
|
||||
public function test_multiply_detects_overflow(): void
|
||||
{
|
||||
$this->expectException(OverflowException::class);
|
||||
FixedPoint::multiply(PHP_INT_MAX, 2);
|
||||
}
|
||||
|
||||
public function test_divide_scaled_value_by_positive_integer_with_round_half_up(): void
|
||||
{
|
||||
$amount = FixedPoint::toScaled('10.0000', 4);
|
||||
$this->assertSame(FixedPoint::toScaled('3.3333', 4), FixedPoint::divideByInt($amount, 3));
|
||||
}
|
||||
|
||||
public function test_divide_by_zero_is_rejected(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::divideByInt(FixedPoint::toScaled('1', 4), 0);
|
||||
}
|
||||
|
||||
public function test_format_renders_fixed_decimal_places_without_floats(): void
|
||||
{
|
||||
$this->assertSame('1234.5000', FixedPoint::format(FixedPoint::toScaled('1234.5', 4), 4));
|
||||
$this->assertSame('-0.0100', FixedPoint::format(FixedPoint::toScaled('-0.01', 4), 4));
|
||||
$this->assertSame('0.0000', FixedPoint::format(0, 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ManufacturingFileValidator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class ManufacturingFileValidatorTest extends TestCase
|
||||
{
|
||||
private ManufacturingFileValidator $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->validator = new ManufacturingFileValidator;
|
||||
}
|
||||
|
||||
private function uploadedFile(string $path, string $clientName): UploadedFile
|
||||
{
|
||||
return new UploadedFile($path, $clientName, null, null, true);
|
||||
}
|
||||
|
||||
public function test_accepts_valid_ascii_stl(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\nfacet normal 0 0 0\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n");
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
|
||||
$this->assertSame('stl', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_accepts_valid_binary_stl(): void
|
||||
{
|
||||
$path = $this->makeBinaryStl(triangles: 2);
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'part.stl'));
|
||||
|
||||
$this->assertSame('stl', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_binary_stl_with_triangle_count_mismatch(): void
|
||||
{
|
||||
$path = $this->makeBinaryStl(triangles: 2, declareExtraTriangle: true);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'part.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_ascii_stl_with_zero_facets(): void
|
||||
{
|
||||
// The exact review example: a single line with "solid" and "endsolid"
|
||||
// tokens but no actual facet/vertex data must not be accepted.
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid garbage endsolid\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'garbage.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_accepts_ascii_stl_with_multiple_facets(): void
|
||||
{
|
||||
$facet = "facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n";
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\n".$facet.$facet."endsolid cube\n");
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
|
||||
$this->assertSame('stl', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_ascii_stl_missing_outer_loop(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\nfacet normal 0 0 1\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_ascii_stl_with_non_finite_vertex_value(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\nfacet normal 0 0 1\nouter loop\nvertex nan 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_ascii_stl_missing_a_vertex(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nendloop\nendfacet\nendsolid cube\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_binary_stl_with_zero_triangles(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, str_repeat('A', 80).pack('V', 0));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'empty.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_binary_stl_with_non_finite_values(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
$handle = fopen($path, 'wb');
|
||||
fwrite($handle, str_repeat('A', 80));
|
||||
fwrite($handle, pack('V', 1));
|
||||
fwrite($handle, str_repeat(pack('f', NAN), 12));
|
||||
fwrite($handle, pack('v', 0));
|
||||
fclose($handle);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'nan.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_binary_stl_with_absurd_triangle_count(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
$header = str_repeat('A', 80);
|
||||
file_put_contents($path, $header.pack('V', 100_000_000));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'huge.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_file_with_mismatched_extension_and_content(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'fake');
|
||||
file_put_contents($path, "G1 X10 Y10\nG1 X20 Y20\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'fake.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_unsupported_extension(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'exe');
|
||||
file_put_contents($path, 'MZ'.str_repeat("\x00", 100));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'payload.exe'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_accepts_plain_text_gcode(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'gcode');
|
||||
file_put_contents($path, "; generated by slicer\nG28\nG1 X0 Y0 Z0.2 F3000\nM104 S200\n");
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'part.gcode'));
|
||||
|
||||
$this->assertSame('gcode', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_gcode_containing_binary_bytes(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'gcode');
|
||||
file_put_contents($path, "G1 X0\0\x01\x02 binary disguised as text");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'part.gcode'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_accepts_valid_3mf_archive(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'part.3mf'));
|
||||
|
||||
$this->assertSame('3mf', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_model_missing_required_resources_or_build_elements(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'incomplete.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_model_with_malformed_not_well_formed_xml(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'malformed.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_relationship_with_single_quoted_external_target(): void
|
||||
{
|
||||
// The old validator only regex-matched double-quoted Target="..." attributes,
|
||||
// so a single-quoted external target slipped straight through unparsed.
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>',
|
||||
'_rels/.rels' => "<?xml version='1.0'?><Relationships xmlns='http://schemas.openxmlformats.org/package/2006/relationships'><Relationship Target='https://evil.example.test/payload' Id='rel0' Type='y'/></Relationships>",
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_relationship_with_external_target_mode_regardless_of_target_value(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="safe/internal/part.xml" TargetMode="External" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_nested_rels_part_with_external_target(): void
|
||||
{
|
||||
// Per-part relationship files (not just the root _rels/.rels) must be
|
||||
// inspected too; a nested one pointing outside the package must still
|
||||
// be rejected.
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
'3D/_rels/3dmodel.model.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="http://evil.example.test/texture.png" Id="rel1" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_an_encrypted_entry(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"><resources></resources><build></build></model>');
|
||||
$zip->addFromString('secret.bin', 'payload');
|
||||
$zip->setEncryptionName('secret.bin', ZipArchive::EM_AES_256, 'password');
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_entry_exceeding_the_per_entry_xml_size_cap(): void
|
||||
{
|
||||
// Low-compressibility padding keeps this under the ratio-bomb threshold
|
||||
// while still being an individually oversized XML-ish part.
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"><resources></resources><build></build></model>');
|
||||
$zip->addFromString('_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>');
|
||||
$zip->addFromString('huge.xml', '<x>'.base64_encode(random_bytes(18 * 1024 * 1024)).'</x>');
|
||||
$zip->setCompressionName('huge.xml', ZipArchive::CM_STORE);
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'huge.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_a_duplicate_entry_path(): void
|
||||
{
|
||||
// ZipArchive's own API dedupes entries by name, so a genuine duplicate
|
||||
// (two local file headers + two central directory records sharing one
|
||||
// name — a known zip-parser confusion vector) has to be hand-built.
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
file_put_contents($path, $this->rawZipWithDuplicateEntryName());
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'dup.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function rawZipWithDuplicateEntryName(): string
|
||||
{
|
||||
// An otherwise entirely valid, acceptable 3MF archive, plus a duplicate
|
||||
// pair of an unrelated entry name — so this only fails if duplicate
|
||||
// detection itself exists, not for any other unrelated reason.
|
||||
$entries = [
|
||||
['[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>'],
|
||||
['3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>'],
|
||||
['_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>'],
|
||||
['same.txt', 'first'],
|
||||
['same.txt', 'second'],
|
||||
];
|
||||
|
||||
$local = '';
|
||||
$central = '';
|
||||
$offset = 0;
|
||||
foreach ($entries as [$name, $content]) {
|
||||
$crc = crc32($content);
|
||||
$len = strlen($content);
|
||||
$nameLen = strlen($name);
|
||||
$lfh = pack('VvvvvvVVVvv', 0x04034B50, 20, 0, 0, 0, 0, $crc, $len, $len, $nameLen, 0).$name.$content;
|
||||
$cdfh = pack('VvvvvvvVVVvvvvvVV', 0x02014B50, 20, 20, 0, 0, 0, 0, $crc, $len, $len, $nameLen, 0, 0, 0, 0, 0, $offset).$name;
|
||||
$local .= $lfh;
|
||||
$central .= $cdfh;
|
||||
$offset += strlen($lfh);
|
||||
}
|
||||
|
||||
$eocd = pack('VvvvvVVv', 0x06054B50, 0, 0, count($entries), count($entries), strlen($central), $offset, 0);
|
||||
|
||||
return $local.$central.$eocd;
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_missing_required_model_part(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'broken.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_path_traversal_entry(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="x"></model>',
|
||||
'../../../etc/evil.txt' => 'pwned',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_absolute_path_entry(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"></model>');
|
||||
// ZipArchive normalises leading slashes away, so write the entry via a raw local file header hack:
|
||||
// fall back to asserting the validator rejects entries containing backslashes / drive letters instead.
|
||||
$zip->addFromString('C:\\Windows\\evil.dll', 'pwned');
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_model_part_with_doctype_declaration(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><!DOCTYPE model [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><model xmlns="x">&xxe;</model>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'xxe.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_relationship_with_external_target(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="x"></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="https://evil.example.test/payload" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_symlink_entry(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"></model>');
|
||||
$zip->addFromString('evil-link', '/etc/passwd');
|
||||
$zip->setExternalAttributesName('evil-link', ZipArchive::OPSYS_UNIX, 0120777 << 16);
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_too_many_entries(): void
|
||||
{
|
||||
$entries = ['[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>', '3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="x"></model>'];
|
||||
for ($i = 0; $i < 5000; $i++) {
|
||||
$entries["junk/{$i}.txt"] = 'x';
|
||||
}
|
||||
$path = $this->make3mf($entries);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'bomb.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_entry_with_extreme_compression_ratio(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"></model>');
|
||||
$zip->addFromString('bomb.bin', str_repeat("\0", 20 * 1024 * 1024));
|
||||
$zip->setCompressionName('bomb.bin', ZipArchive::CM_DEFLATE, 9);
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'bomb.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_file_exceeding_maximum_size(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
$handle = fopen($path, 'wb');
|
||||
fwrite($handle, str_repeat('A', 80));
|
||||
fwrite($handle, pack('V', 1));
|
||||
fseek($handle, (100 * 1024 * 1024) + 200);
|
||||
fwrite($handle, 'x');
|
||||
fclose($handle);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'huge.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function makeBinaryStl(int $triangles, bool $declareExtraTriangle = false): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
$handle = fopen($path, 'wb');
|
||||
fwrite($handle, str_repeat('A', 80));
|
||||
fwrite($handle, pack('V', $declareExtraTriangle ? $triangles + 1 : $triangles));
|
||||
for ($i = 0; $i < $triangles; $i++) {
|
||||
fwrite($handle, str_repeat(pack('f', 0.0), 12));
|
||||
fwrite($handle, pack('v', 0));
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
*/
|
||||
private function make3mf(array $entries): string
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
foreach ($entries as $name => $contents) {
|
||||
$zip->addFromString($name, $contents);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ProjectCosting;
|
||||
use InvalidArgumentException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectCostingTest extends TestCase
|
||||
{
|
||||
public function test_filament_line_converts_grams_to_kilograms_before_pricing(): void
|
||||
{
|
||||
// 250g at £20.00/kg = £5.00
|
||||
$line = ProjectCosting::lineTotal('filament', '250', 'g', '20.000000');
|
||||
$this->assertSame('5.0000', $line);
|
||||
}
|
||||
|
||||
public function test_filament_line_includes_explicit_waste_without_double_counting(): void
|
||||
{
|
||||
// 250g consumed + 10g explicit purge/support waste = 260g at £20.00/kg
|
||||
$line = ProjectCosting::lineTotal('filament', '250', 'g', '20.000000', waste: '10');
|
||||
$this->assertSame('5.2000', $line);
|
||||
}
|
||||
|
||||
public function test_packaging_line_is_quantity_times_unit_rate(): void
|
||||
{
|
||||
$line = ProjectCosting::lineTotal('packaging', '3', 'unit', '0.750000');
|
||||
$this->assertSame('2.2500', $line);
|
||||
}
|
||||
|
||||
public function test_shipping_line_total_is_quantity_times_parcel_rate(): void
|
||||
{
|
||||
$line = ProjectCosting::lineTotal('shipping', '1', 'parcel', '4.990000');
|
||||
$this->assertSame('4.9900', $line);
|
||||
}
|
||||
|
||||
public function test_shipping_unit_contribution_divides_rate_by_units_per_parcel_not_batch_yield(): void
|
||||
{
|
||||
// One £4.99 parcel covering 2 saleable units ships at £2.495 per unit,
|
||||
// independent of however many units the manufacturing batch itself yielded.
|
||||
$this->assertSame('2.4950', ProjectCosting::shippingUnitContribution('4.990000', 2));
|
||||
}
|
||||
|
||||
public function test_shipping_unit_contribution_rejects_zero_or_negative_units_per_parcel(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::shippingUnitContribution('4.990000', 0);
|
||||
}
|
||||
|
||||
public function test_filament_rejects_a_quantity_unit_not_in_the_category_allowlist(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('filament', '250', 'G', '20.000000');
|
||||
}
|
||||
|
||||
public function test_filament_rejects_kilograms_as_the_quantity_unit(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('filament', '0.25', 'kg', '20.000000');
|
||||
}
|
||||
|
||||
public function test_packaging_rejects_a_quantity_unit_not_in_the_category_allowlist(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('packaging', '1', 'units', '0.650000');
|
||||
}
|
||||
|
||||
public function test_shipping_rejects_a_quantity_unit_not_in_the_category_allowlist(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('shipping', '1', 'box', '4.990000');
|
||||
}
|
||||
|
||||
public function test_aggregate_rejects_a_category_total_exceeding_the_storable_decimal_precision(): void
|
||||
{
|
||||
// decimal(14,4) tops out around 9,999,999,999.9999; two large-but-individually-valid
|
||||
// lines can still sum past that bound, so the aggregate step must re-check it.
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::aggregate([
|
||||
['category' => 'filament', 'line_total' => '9999999999.0000'],
|
||||
['category' => 'filament', 'line_total' => '9999999999.0000'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_aggregate_rejects_a_grand_total_exceeding_the_storable_decimal_precision(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::aggregate([
|
||||
['category' => 'filament', 'line_total' => '9999999999.0000'],
|
||||
['category' => 'packaging', 'line_total' => '9999999999.0000'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_zero_quantity_is_a_valid_intentional_zero(): void
|
||||
{
|
||||
$this->assertSame('0.0000', ProjectCosting::lineTotal('packaging', '0', 'unit', '5.000000'));
|
||||
}
|
||||
|
||||
public function test_negative_quantity_is_rejected(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('packaging', '-1', 'unit', '5.000000');
|
||||
}
|
||||
|
||||
public function test_negative_rate_is_rejected(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('packaging', '1', 'unit', '-5.000000');
|
||||
}
|
||||
|
||||
public function test_absurd_quantity_overflows_and_is_rejected(): void
|
||||
{
|
||||
$this->expectException(\Throwable::class);
|
||||
ProjectCosting::lineTotal('filament', '999999999999', 'g', '999999999999.000000');
|
||||
}
|
||||
|
||||
public function test_aggregate_distinguishes_missing_category_from_intentional_zero(): void
|
||||
{
|
||||
$totals = ProjectCosting::aggregate([
|
||||
['category' => 'filament', 'line_total' => '5.0000'],
|
||||
['category' => 'packaging', 'line_total' => '0.0000'],
|
||||
]);
|
||||
|
||||
$this->assertSame('5.0000', $totals['filament_total']);
|
||||
$this->assertSame('0.0000', $totals['packaging_total']);
|
||||
$this->assertNull($totals['shipping_total']);
|
||||
$this->assertSame('5.0000', $totals['grand_total']);
|
||||
}
|
||||
|
||||
public function test_aggregate_grand_total_is_null_when_nothing_costed(): void
|
||||
{
|
||||
$totals = ProjectCosting::aggregate([]);
|
||||
|
||||
$this->assertNull($totals['filament_total']);
|
||||
$this->assertNull($totals['packaging_total']);
|
||||
$this->assertNull($totals['shipping_total']);
|
||||
$this->assertNull($totals['grand_total']);
|
||||
}
|
||||
|
||||
public function test_aggregate_sums_multiple_lines_per_category(): void
|
||||
{
|
||||
$totals = ProjectCosting::aggregate([
|
||||
['category' => 'filament', 'line_total' => '5.0000'],
|
||||
['category' => 'filament', 'line_total' => '2.5000'],
|
||||
]);
|
||||
|
||||
$this->assertSame('7.5000', $totals['filament_total']);
|
||||
}
|
||||
|
||||
public function test_per_unit_divides_grand_total_by_batch_yield_with_rounding(): void
|
||||
{
|
||||
$this->assertSame('3.3333', ProjectCosting::perUnit('10.0000', 3));
|
||||
}
|
||||
|
||||
public function test_per_unit_rejects_zero_or_negative_yield(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::perUnit('10.0000', 0);
|
||||
}
|
||||
|
||||
public function test_rejects_unknown_category(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('labour', '1', 'unit', '1.000000');
|
||||
}
|
||||
|
||||
public function test_rejects_mixed_currency_summation(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::assertSameCurrency(['GBP', 'USD']);
|
||||
}
|
||||
|
||||
public function test_allows_single_consistent_currency(): void
|
||||
{
|
||||
ProjectCosting::assertSameCurrency(['GBP', 'GBP', 'GBP']);
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function test_validates_iso_currency_allowlist(): void
|
||||
{
|
||||
$this->assertTrue(ProjectCosting::isValidCurrency('GBP'));
|
||||
$this->assertFalse(ProjectCosting::isValidCurrency('XYZ'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user