actingAs(User::factory()->create())->post(route('etsy.connect'))->assertForbidden(); } public function test_etsy_callback_rejects_mismatched_state_without_exchanging_token(): void { Http::preventStrayRequests(); $admin = User::factory()->create(['role' => 'admin']); $this->actingAs($admin)->withSession(['etsy_oauth' => ['state' => 'expected', 'verifier' => 'verifier', 'started' => time()]]) ->get(route('etsy.callback', ['state' => 'wrong', 'code' => 'code'])) ->assertRedirect(route('etsy.index'))->assertSessionHasErrors('etsy'); Http::assertNothingSent(); } public function test_etsy_callback_saves_encrypted_tokens_and_shows_orders(): void { Http::preventStrayRequests(); Http::fake([ 'https://api.etsy.com/v3/public/oauth/token' => Http::response(['access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_in' => 3600]), 'https://api.etsy.com/v3/application/users/123/shops' => Http::response(['shop_id' => 42, 'shop_name' => 'HickLab3D']), 'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => [['receipt_id' => 77, 'name' => 'Buyer']]]), 'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['results' => [['title' => '3D print', 'quantity' => 5]]]), ]); config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'redirect_uri' => 'https://example.test/etsy/callback']); $admin = User::factory()->create(['role' => 'admin']); $this->actingAs($admin)->withSession(['etsy_oauth' => ['state' => 'expected', 'verifier' => 'verifier', 'started' => time()]]) ->get(route('etsy.callback', ['state' => 'expected', 'code' => 'code'])) ->assertRedirect(route('etsy.index')); $connection = EtsyConnection::firstOrFail(); $this->assertSame('123.token', $connection->access_token); $this->assertNotSame('123.token', $connection->getRawOriginal('access_token')); $this->actingAs($admin)->get(route('etsy.index'))->assertOk()->assertSee('3D print')->assertSee('#77'); Http::assertSentCount(4); } public function test_bambuddy_replaces_local_printer_status(): void { Http::preventStrayRequests(); Http::fake([ 'https://3d.example.test/api/v1/printers/' => Http::response([['id' => 1, 'name' => 'X1 Carbon', 'status' => 'printing']]), 'https://3d.example.test/api/v1/printers/1/status' => Http::response(['connected' => true, 'state' => 'RUNNING', 'progress' => 45, 'temperatures' => ['nozzle' => 220, 'bed' => 60]]), ]); config()->set('services.bambuddy', ['url' => 'https://3d.example.test', 'api_key' => 'read-key']); $this->actingAs(User::factory()->create())->get(route('printers.index'))->assertOk()->assertSee('X1 Carbon')->assertSee('45%'); Http::assertSent(fn ($request) => $request->hasHeader('X-API-Key', 'read-key')); } public function test_shipping_quote_sends_parcel_to_parcel2go(): void { $this->pendingEtsyOrder(); Http::preventStrayRequests(); Http::fake([ 'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']), 'https://shipping.example.test/api/quotes' => Http::response(['Quotes' => [['Service' => ['Name' => 'Evri', 'Slug' => 'evri-collection', 'CollectionType' => 'Collection'], 'TotalPrice' => 5.5]]]), 'https://shipping.example.test/api/me/detail' => Http::response([]), 'https://shipping.example.test/api/me/prepay/balance' => Http::response([]), 'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => []]), ]); config()->set('services.parcel2go', ['client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api']); $admin = User::factory()->create(['role' => 'admin']); $details = ['receipt_id' => 77, 'delivery_postcode' => 'SW1A 1AA', 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10]; $this->actingAs($admin)->post(route('shipping.quote'), $details)->assertRedirect(route('shipping')); $this->actingAs($admin)->get(route('shipping'))->assertOk()->assertSee('Evri')->assertSee('£5.50')->assertDontSee('name="delivery_postcode"', false); Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/quotes' && $request['Parcels'][0]['Weight'] === 2 && $request['DeliveryAddress']['Postcode'] === 'M1 1AE' && $request['CollectionAddress']['Postcode'] === 'SW1A 1AA'); } public function test_shipping_page_works_with_public_api_scope_without_profile_access(): void { Http::preventStrayRequests(); Http::fake(['https://auth.example.test/token' => function ($request) { return $request['scope'] === 'public-api' ? Http::response(['access_token' => 'test-token']) : Http::response(['error' => 'invalid_scope'], 400); }, 'https://shipping.example.test/api/me/orders/detail' => Http::response([], 403), 'https://shipping.example.test/api/me/detail' => Http::response([], 403), 'https://shipping.example.test/api/me/prepay/balance' => Http::response([], 403), ]); config()->set('services.parcel2go', [ 'client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api', 'scope' => 'public-api', ]); $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) ->assertOk()->assertSee('Pending Etsy orders')->assertSee('Parcel2Go denied shipment history') ->assertDontSee('Parcel2Go is unavailable'); Http::assertSentCount(4); } public function test_shipping_shows_existing_shipments_when_basic_access_can_read_history(): void { Http::preventStrayRequests(); Http::fake([ 'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']), 'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => [[ 'OrderLineId' => 123, 'Courier' => 'Evri', 'Service' => 'Standard', 'Tracking' => ['Delivered' => true], 'DeliveryAddress' => ['ContactName' => 'Buyer'], 'EstimatedDeliveryDate' => '2026-10-15', 'Hash' => 'hash', ]]]), 'https://shipping.example.test/api/me/detail' => Http::response(['Forename' => 'Lee', 'Surname' => 'Hickman']), 'https://shipping.example.test/api/me/prepay/balance' => Http::response(['Balance' => 12.5]), ]); config()->set('services.parcel2go', [ 'client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api', 'scope' => 'public-api', ]); $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) ->assertOk()->assertSee('Existing shipments')->assertSee('Delivered')->assertSee('Buyer') ->assertSee('Lee Hickman')->assertSee('12.50') ->assertDontSee('Parcel2Go denied shipment history'); Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/me/detail' && $request->hasHeader('Authorization', 'Bearer test-token')); Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/me/prepay/balance' && $request->hasHeader('Authorization', 'Bearer test-token')); Http::assertSentCount(4); } public function test_shipping_rejects_a_quote_for_an_order_that_is_not_pending(): void { $this->pendingEtsyOrder(); Http::preventStrayRequests(); $admin = User::factory()->create(['role' => 'admin']); $this->actingAs($admin)->post(route('shipping.quote'), [ 'receipt_id' => 999, 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10, 'delivery_postcode' => 'SW1A 1AA', ])->assertRedirect(route('shipping'))->assertSessionHasErrors('quote'); Http::assertSentCount(1); } public function test_shipping_falls_back_to_basic_access_when_parcel2go_rejects_profile_scope(): void { Http::preventStrayRequests(); Http::fake(['https://auth.example.test/token' => function ($request) { return $request['scope'] === 'public-api' ? Http::response(['access_token' => 'test-token']) : Http::response(['error' => 'invalid_scope'], 400); }, 'https://shipping.example.test/api/me/orders/detail' => Http::response([], 403), 'https://shipping.example.test/api/me/detail' => Http::response([], 403), 'https://shipping.example.test/api/me/prepay/balance' => Http::response([], 403), ]); config()->set('services.parcel2go', [ 'client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api', 'scope' => 'public-api my-profile', ]); $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) ->assertOk()->assertSee('Parcel2Go denied shipment history') ->assertDontSee('Parcel2Go is unavailable'); Http::assertSentCount(5); } public function test_shipping_label_is_downloaded_for_an_order_in_account_history(): void { Http::preventStrayRequests(); Http::fake([ 'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']), 'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => [['OrderLineId' => 123, 'Hash' => 'order-hash']]]), 'https://shipping.example.test/api/labels/123*' => Http::response(['Base64EncodedLabels' => [base64_encode('%PDF-1.4 test label')]]), ]); config()->set('services.parcel2go', ['client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api']); $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping.label', 123)) ->assertOk()->assertHeader('content-type', 'application/pdf')->assertSee('%PDF-1.4 test label'); Http::assertSent(fn ($request) => str_contains($request->url(), '/labels/123') && str_contains($request->url(), 'hash=order-hash')); } public function test_shipping_label_uses_the_hash_from_order_links_when_hash_field_is_empty(): void { Http::preventStrayRequests(); Http::fake([ 'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']), 'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => [[ 'OrderLineId' => 123, 'Hash' => null, 'Links' => [['Name' => 'labels-a4', 'Link' => 'https://www.parcel2go.com/label/orderline?media=0&orderlineid=123&hash=link%2Bhash%3D']], ]]]), 'https://shipping.example.test/api/labels/123*' => Http::response(['Base64EncodedLabels' => [base64_encode('%PDF-1.4 test label')]]), ]); config()->set('services.parcel2go', ['client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api']); $admin = User::factory()->create(['role' => 'admin']); $this->actingAs($admin)->get(route('shipping'))->assertSee(route('shipping.label', 123), false); $this->actingAs($admin)->get(route('shipping.label', 123))->assertOk()->assertSee('%PDF-1.4 test label'); Http::assertSent(fn ($request) => str_contains($request->url(), '/labels/123') && str_contains($request->url(), 'hash=link%2Bhash%3D')); } public function test_shipping_label_can_be_requested_in_4x6_format(): void { Http::preventStrayRequests(); Http::fake([ 'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']), 'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => [['OrderLineId' => 123, 'Hash' => 'order-hash']]]), 'https://shipping.example.test/api/labels/123*' => Http::response(['Base64EncodedLabels' => [base64_encode('%PDF-1.4 test label')]]), ]); config()->set('services.parcel2go', ['client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api']); $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping.label', [123, 'size' => '4x6'])) ->assertOk()->assertHeader('content-disposition', 'attachment; filename="parcel2go-123-4x6.pdf"'); Http::assertSent(fn ($request) => str_contains($request->url(), '/labels/123') && str_contains($request->url(), 'labelMedia=Label4x6')); } public function test_collection_booking_uses_quoted_service_and_available_date(): void { $this->pendingEtsyOrder(); Http::preventStrayRequests(); Http::fake([ 'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']), 'https://shipping.example.test/api/orders' => Http::response(['OrderId' => '789', 'TotalPrice' => 8.5, 'Links' => ['payment' => 'https://www.parcel2go.com/order/payment?id=789']]), 'https://shipping.example.test/api/me/detail' => Http::response([]), 'https://shipping.example.test/api/me/prepay/balance' => Http::response([]), 'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => []]), ]); config()->set('services.parcel2go', ['client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api']); $session = [ 'shipping_quote' => ['collection_postcode' => 'SW1A 1AA', 'delivery_postcode' => 'M1 1AE', 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10, 'value' => 30], 'shipping_receipt_id' => 77, 'shipping_selected_service' => 'evri-collection', 'shipping_quotes' => [['Service' => ['Slug' => 'evri-collection', 'CollectionType' => 'Collection']]], 'shipping_collection_dates' => [['CollectionDate' => '2026-10-15T10:00:00Z']], 'shipping_sender_location' => ['sender_street' => 'Main Street', 'sender_town' => 'London'], ]; $booking = [ 'collection_date' => '2026-10-15T10:00:00Z', 'sender_name' => 'Lee Hickman', 'sender_email' => 'lee@example.test', 'sender_phone' => '07123456789', 'sender_property' => '1', 'sender_street' => 'Main Street', 'sender_town' => 'London', 'recipient_name' => 'Buyer Name', 'recipient_email' => 'buyer@example.test', 'recipient_phone' => '07123456788', 'recipient_property' => '2', 'recipient_street' => 'Market Street', 'recipient_town' => 'Manchester', 'contents' => '3D printed part', ]; $this->actingAs(User::factory()->create(['role' => 'admin']))->withSession($session) ->post(route('shipping.book-collection'), $booking)->assertRedirect(route('shipping'))->assertSessionHas('shipping_booking_result.OrderId', '789'); $this->get(route('shipping'))->assertOk()->assertSee('Collection order #789')->assertSee('Complete payment'); Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/orders' && $request['Items'][0]['Service'] === 'evri-collection' && $request['Items'][0]['Parcels'][0]['ContentsSummary'] === '3D printed part'); } public function test_collection_dates_only_accept_a_quoted_collection_service(): void { Http::preventStrayRequests(); $admin = User::factory()->create(['role' => 'admin']); $session = [ 'shipping_quote' => ['collection_postcode' => 'SW1A 1AA'], 'shipping_quotes' => [['Service' => ['Slug' => 'evri-collection', 'CollectionType' => 'Collection']]], ]; $this->actingAs($admin)->withSession($session) ->post(route('shipping.collection-dates'), ['service' => 'forged-service', 'sender_street' => 'Main Street', 'sender_town' => 'London']) ->assertSessionHasErrors('service'); Http::assertNothingSent(); } public function test_collection_dates_are_loaded_for_a_quoted_service(): void { Http::preventStrayRequests(); Http::fake([ 'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']), 'https://shipping.example.test/api/collectiondates' => Http::response(['CollectionDates' => [['CollectionDate' => '2026-10-15T10:00:00Z']]]), ]); config()->set('services.parcel2go', ['client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api']); $session = [ 'shipping_quote' => ['collection_postcode' => 'SW1A 1AA'], 'shipping_quotes' => [['Service' => ['Slug' => 'evri-collection', 'CollectionType' => 'Collection']]], ]; $this->actingAs(User::factory()->create(['role' => 'admin']))->withSession($session) ->post(route('shipping.collection-dates'), ['service' => 'evri-collection', 'sender_street' => 'Main Street', 'sender_town' => 'London']) ->assertRedirect(route('shipping'))->assertSessionHas('shipping_selected_service', 'evri-collection'); Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/collectiondates' && $request['ServiceSlug'] === 'evri-collection' && $request['Postcode'] === 'SW1A 1AA'); } public function test_uncertain_booking_requires_account_check_before_a_new_quote(): void { Http::preventStrayRequests(); $admin = User::factory()->create(['role' => 'admin']); $details = ['collection_postcode' => 'SW1A 1AA', 'delivery_postcode' => 'M1 1AE', 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10, 'value' => 30]; $this->actingAs($admin)->withSession(['shipping_booking_pending' => true]) ->post(route('shipping.quote'), $details)->assertSessionHasErrors('collection'); $this->actingAs($admin)->post(route('shipping.reset-booking'), [])->assertSessionHasErrors('checked_account'); $this->actingAs($admin)->post(route('shipping.reset-booking'), ['checked_account' => 1]) ->assertRedirect(route('shipping'))->assertSessionMissing('shipping_booking_pending'); Http::assertNothingSent(); } public function test_dryer_status_and_fault_acknowledgement_use_versioned_api(): void { Http::preventStrayRequests(); Http::fake([ 'https://dryer.example.test/api/v1/status' => Http::response(['state' => 'idle', 'sample' => ['valid' => true, 'temperatureC' => 25]]), 'https://dryer.example.test/api/v1/history*' => Http::response(['samples' => []]), 'https://dryer.example.test/api/v1/faults/acknowledge' => Http::response(['ok' => true]), ]); config()->set('services.dryer', ['url' => 'https://dryer.example.test/api/v1', 'api_key' => 'test-key']); $admin = User::factory()->create(['role' => 'admin']); $this->actingAs($admin)->get(route('dryer.status'))->assertOk()->assertJsonPath('status.state', 'idle'); $this->actingAs($admin)->postJson(route('dryer.acknowledge'))->assertOk()->assertJsonPath('ok', true); Http::assertSent(fn ($request) => $request->url() === 'https://dryer.example.test/api/v1/faults/acknowledge' && $request->hasHeader('X-API-Key', 'test-key') && str_starts_with($request['requestId'], 'dashboard-')); } public function test_dryer_rejects_temperature_above_firmware_limit_without_contacting_device(): void { Http::preventStrayRequests(); $admin = User::factory()->create(['role' => 'admin']); $this->actingAs($admin)->postJson(route('dryer.control'), [ 'dryerOn' => true, 'profile' => 'PLA', 'targetTemperature' => 80, 'dryingTime' => 240, ])->assertUnprocessable()->assertJsonValidationErrors('targetTemperature'); Http::assertNothingSent(); } private function pendingEtsyOrder(): void { config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret']); EtsyConnection::create([ 'shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_at' => now()->addHour(), ]); IntegrationSetting::create([ 'provider' => 'parcel2go', 'settings' => ['collection_postcode' => 'SW1A 1AA'], ]); Http::fake([ 'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => [[ 'receipt_id' => 77, 'name' => 'Buyer', 'zip' => 'M1 1AE', 'city' => 'Manchester', 'country_iso' => 'GB', 'grandtotal' => ['amount' => 3000, 'divisor' => 100, 'currency_code' => 'GBP'], ]]]), ]); } }