mcp(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']); $this->assertSame('2.0', $response['jsonrpc']); $this->assertSame(1, $response['id']); $names = array_column($response['result']['tools'], 'name'); $this->assertSame([ 'etsy_shop_status', 'etsy_list_listings', 'etsy_get_listing', 'etsy_create_draft_listing', 'etsy_update_listing', 'etsy_publish_listing', 'etsy_get_listing_inventory', 'etsy_update_listing_inventory', 'etsy_list_receipts', 'etsy_get_receipt', 'etsy_mark_receipt_shipped', 'etsy_list_receipt_messages', 'etsy_get_receipt_messages', ], $names); $this->assertNotContains('etsy_delete_listing', $names); $this->assertNotContains('etsy_send_message', $names); } public function test_initialize_and_initialized_notification(): void { $response = $this->mcp([ 'jsonrpc' => '2.0', 'id' => 'init-1', 'method' => 'initialize', 'params' => ['protocolVersion' => '2024-11-05', 'capabilities' => new \stdClass, 'clientInfo' => ['name' => 'test']], ]); $this->assertSame('2024-11-05', $response['result']['protocolVersion']); $this->assertSame('etsy', $response['result']['serverInfo']['name']); $this->assertArrayHasKey('tools', $response['result']['capabilities']); $this->assertNull($this->mcpRaw(['jsonrpc' => '2.0', 'method' => 'notifications/initialized'])); } public function test_shop_status_reports_disconnected_shop_without_tokens(): void { config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D']); $result = $this->mcpCall('etsy_shop_status'); $this->assertFalse($result['isError']); $this->assertTrue($result['data']['configured']); $this->assertFalse($result['data']['connected']); $this->assertSame('HickLab3D', $result['data']['configured_shop_name']); $this->assertContains('listings_w', $result['data']['required_scopes']); $this->assertStringNotContainsString('key', json_encode($result['rpc'])); $this->assertStringNotContainsString('secret', json_encode($result['rpc'])); $this->assertArrayNotHasKey('access_token', $result['data']); $this->assertArrayNotHasKey('refresh_token', $result['data']); } public function test_shop_status_asks_admin_to_reconnect_when_scopes_are_missing(): void { $this->connectShop('transactions_r listings_r'); $result = $this->mcpCall('etsy_shop_status'); $this->assertTrue($result['data']['connected']); $this->assertTrue($result['data']['reconnect_required']); $this->assertSame(42, $result['data']['shop_id']); $this->assertSame('HickLab3D', $result['data']['shop_name']); $this->assertContains('listings_w', $result['data']['missing_scopes']); $this->assertStringContainsString('reconnect', strtolower($result['data']['reconnect_reason'])); $this->assertArrayNotHasKey('access_token', $result['data']); } public function test_create_draft_listing_posts_a_draft_and_never_publishes(): void { Http::preventStrayRequests(); Http::fake([ 'https://api.etsy.com/v3/application/shops/42/listings' => Http::response([ 'listing_id' => 99, 'title' => 'Widget', 'state' => 'draft', 'quantity' => 3, 'shop_id' => 42, ], 201), ]); $this->connectShop(); $result = $this->mcpCall('etsy_create_draft_listing', [ 'title' => 'Widget', 'description' => 'A printed widget', 'price' => 12.5, 'quantity' => 3, 'who_made' => 'i_did', 'when_made' => 'made_to_order', 'taxonomy_id' => 1234, 'state' => 'active', ]); $this->assertFalse($result['isError']); $this->assertSame(99, $result['data']['listing_id']); $this->assertSame('draft', $result['data']['state']); Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/shops/42/listings' && $request->method() === 'POST' && $request['title'] === 'Widget' && (int) $request['quantity'] === 3 && ! array_key_exists('state', $request->data())); Http::assertSentCount(1); } public function test_publish_listing_requires_confirm_and_patches_active_when_given(): void { Http::preventStrayRequests(); $this->connectShop(); $denied = $this->mcpCall('etsy_publish_listing', ['listing_id' => 99]); $this->assertTrue($denied['isError']); Http::assertNothingSent(); Http::fake([ 'https://api.etsy.com/v3/application/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 42]), 'https://api.etsy.com/v3/application/shops/42/listings/99' => Http::response([ 'listing_id' => 99, 'state' => 'active', 'shop_id' => 42, 'title' => 'Widget', ]), ]); $published = $this->mcpCall('etsy_publish_listing', ['listing_id' => 99, 'confirm' => true]); $this->assertFalse($published['isError']); $this->assertSame('active', $published['data']['state']); Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/shops/42/listings/99' && $request->method() === 'PATCH' && $request['state'] === 'active'); } #[TestWith(['etsy_update_listing', ['title' => 'Wrong shop']])] #[TestWith(['etsy_publish_listing', ['confirm' => true]])] public function test_listing_mutations_verify_ownership_before_patch(string $tool, array $payload): void { $this->connectShop(); Http::fake([ '*/application/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 7]), '*/shops/42/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 42]), ]); $result = $this->mcpCall($tool, ['listing_id' => 99, ...$payload]); $this->assertTrue($result['isError']); Http::assertNotSent(fn ($request): bool => $request->method() === 'PATCH'); } #[TestWith([0])] #[TestWith([-1])] #[TestWith([1.5])] #[TestWith(['99'])] public function test_listing_ids_must_be_positive_integers_before_http(mixed $listingId): void { $this->connectShop(); Http::fake(['*' => Http::response(['listing_id' => 99, 'shop_id' => 42])]); $result = $this->mcpCall('etsy_get_listing', ['listing_id' => $listingId]); $this->assertTrue($result['isError']); Http::assertNothingSent(); } public function test_update_listing_rejects_unknown_fields_before_http(): void { $this->connectShop(); Http::fake(['*' => Http::response(['listing_id' => 99, 'shop_id' => 42])]); $result = $this->mcpCall('etsy_update_listing', ['listing_id' => 99, 'unexpected_field' => true]); $this->assertTrue($result['isError']); Http::assertNothingSent(); } #[TestWith(['price', 12.5])] #[TestWith(['quantity', 3])] #[TestWith(['who_made', 'i_did'])] #[TestWith(['when_made', 'made_to_order'])] #[TestWith(['shipping_profile_id', 123])] #[TestWith(['materials', ['PLA']])] #[TestWith(['type', 'physical'])] #[TestWith(['shop_id', 42])] public function test_update_listing_rejects_undeclared_fields_before_http(string $field, mixed $value): void { $this->connectShop(); Http::preventStrayRequests(); Http::fake([ 'https://api.etsy.com/v3/application/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 42]), 'https://api.etsy.com/v3/application/shops/42/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 42]), ]); $result = $this->mcpCall('etsy_update_listing', ['listing_id' => 99, 'title' => 'Widget', $field => $value]); $this->assertTrue($result['isError']); $this->assertStringContainsString('Invalid listing payload:', $result['data']); Http::assertNothingSent(); } public function test_update_listing_rejects_publishing_without_the_publish_tool(): void { Http::preventStrayRequests(); $this->connectShop(); $result = $this->mcpCall('etsy_update_listing', ['listing_id' => 99, 'state' => 'active', 'title' => 'Nope']); $this->assertTrue($result['isError']); $this->assertStringContainsString('etsy_publish_listing', is_string($result['data']) ? $result['data'] : json_encode($result['data'])); Http::assertNothingSent(); } public function test_list_listings_and_receipts_use_filters_not_dashboard_defaults(): void { Http::preventStrayRequests(); Http::fake([ 'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response([ 'count' => 1, 'results' => [['listing_id' => 7, 'title' => 'Draft hat', 'state' => 'draft', 'quantity' => 1, 'shop_id' => 42]], ]), 'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response([ 'count' => 1, 'results' => [['receipt_id' => 15, 'status' => 'Paid', 'was_paid' => true, 'was_shipped' => true, 'name' => 'Buyer', 'grandtotal' => ['amount' => 500, 'divisor' => 100, 'currency_code' => 'GBP']]], ]), ]); $this->connectShop(); $listings = $this->mcpCall('etsy_list_listings', ['state' => 'draft', 'limit' => 10, 'offset' => 0]); $receipts = $this->mcpCall('etsy_list_receipts', ['was_paid' => true, 'was_shipped' => true, 'limit' => 25]); $this->assertSame(7, $listings['data']['results'][0]['listing_id']); $this->assertSame(15, $receipts['data']['results'][0]['receipt_id']); Http::assertSent(fn ($request) => str_contains($request->url(), '/listings') && $request['state'] === 'draft' && (int) $request['limit'] === 10); Http::assertSent(fn ($request) => str_contains($request->url(), '/receipts') && $request['was_paid'] === 'true' && $request['was_shipped'] === 'true'); } public function test_mark_receipt_shipped_posts_tracking(): void { Http::preventStrayRequests(); Http::fake([ 'https://api.etsy.com/v3/application/shops/42/receipts/15/tracking' => Http::response([ 'receipt_id' => 15, 'tracking_code' => 'ABC123', 'carrier_name' => 'evri', 'shop_id' => 42, ]), ]); $this->connectShop(); $result = $this->mcpCall('etsy_mark_receipt_shipped', [ 'receipt_id' => 15, 'tracking_code' => 'ABC123', 'carrier_name' => 'evri', ]); $this->assertFalse($result['isError']); Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/shops/42/receipts/15/tracking' && $request->method() === 'POST' && $request['tracking_code'] === 'ABC123' && $request['carrier_name'] === 'evri'); } #[TestWith(['etsy_update_listing_inventory', ['listing_id' => 99, 'quantity' => -1]])] #[TestWith(['etsy_update_listing_inventory', ['listing_id' => 99, 'quantity' => 1.5]])] #[TestWith(['etsy_update_listing_inventory', ['listing_id' => 99, 'products' => [['offerings' => 'invalid']]]])] #[TestWith(['etsy_publish_listing', ['listing_id' => 99, 'confirm' => 'yes']])] #[TestWith(['etsy_mark_receipt_shipped', ['receipt_id' => 15, 'tracking_code' => 'ABC', 'carrier_name' => 'evri', 'unexpected' => true]])] public function test_mutation_schema_violations_are_rejected_before_http(string $tool, array $arguments): void { $this->connectShop(); Http::fake(['*' => Http::response(['listing_id' => 99, 'shop_id' => 42])]); $result = $this->mcpCall($tool, $arguments); $this->assertTrue($result['isError']); Http::assertNothingSent(); } public function test_quantity_shortcut_preserves_inventory_variation_metadata(): void { $this->connectShop(); Http::preventStrayRequests(); Http::fake([ 'https://api.etsy.com/v3/application/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 42]), 'https://api.etsy.com/v3/application/listings/99/inventory' => fn ($request) => $request->method() === 'GET' ? Http::response([ 'price_on_property' => [100], 'quantity_on_property' => [200], 'sku_on_property' => [100, 200], 'readiness_state_on_property' => [200], 'products' => [['sku' => 'HAT', 'property_values' => [], 'offerings' => [['quantity' => 1, 'price' => ['amount' => 1250, 'divisor' => 100], 'is_enabled' => true, 'readiness_state_id' => 18201076875]]]], ]) : Http::response(['products' => []]), ]); $result = $this->mcpCall('etsy_update_listing_inventory', ['listing_id' => 99, 'quantity' => 0]); $this->assertFalse($result['isError']); Http::assertSent(fn ($request) => $request->method() === 'PUT' && ($request->data()['price_on_property'] ?? null) === [100] && $request['quantity_on_property'] === [200] && $request['sku_on_property'] === [100, 200] && ($request->data()['readiness_state_on_property'] ?? null) === [200] && ($request['products'][0]['offerings'][0]['readiness_state_id'] ?? null) === 18201076875 && $request['products'][0]['offerings'][0]['quantity'] === 0 && $request['products'][0]['offerings'][0]['price'] === 12.5); } public function test_inventory_update_and_receipt_messages_go_through_the_service(): void { Http::preventStrayRequests(); Http::fake([ 'https://api.etsy.com/v3/application/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 42]), 'https://api.etsy.com/v3/application/listings/99/inventory' => function ($request) { return $request->method() === 'PUT' ? Http::response(['products' => [['sku' => 'HAT', 'offerings' => [['quantity' => 4, 'price' => 12.5, 'is_enabled' => true]]]]]) : Http::response(['products' => [['product_id' => 1, 'sku' => 'HAT', 'property_values' => [], 'offerings' => [['offering_id' => 2, 'quantity' => 1, 'is_enabled' => true, 'price' => ['amount' => 1250, 'divisor' => 100, 'currency_code' => 'GBP']]]]]]); }, 'https://api.etsy.com/v3/application/shops/42/receipts/15' => Http::response([ 'receipt_id' => 15, 'message_from_buyer' => 'Please leave in porch', 'message_from_seller' => null, 'name' => 'Buyer', 'email' => 'buyer@example.test', ]), 'https://api.etsy.com/v3/application/shops/42/receipts?*' => Http::response([ 'count' => 1, 'results' => [[ 'receipt_id' => 15, 'message_from_buyer' => 'Please leave in porch', 'message_from_seller' => '', 'name' => 'Buyer', ]], ]), ]); $this->connectShop(); $inventory = $this->mcpCall('etsy_update_listing_inventory', ['listing_id' => 99, 'quantity' => 4]); $this->assertFalse($inventory['isError']); Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/listings/99/inventory' && $request->method() === 'PUT'); $messages = $this->mcpCall('etsy_get_receipt_messages', ['receipt_id' => 15]); $this->assertFalse($messages['isError']); $this->assertSame('Please leave in porch', $messages['data']['message_from_buyer']); $this->assertArrayNotHasKey('email', $messages['data']); $this->assertFalse($messages['data']['conversations_api']['available']); $listed = $this->mcpCall('etsy_list_receipt_messages'); $this->assertSame(15, $listed['data']['results'][0]['receipt_id']); } public function test_mutations_never_echo_tokens_even_when_etsy_error_bodies_contain_them(): void { Http::preventStrayRequests(); Http::fake([ 'https://api.etsy.com/v3/application/shops/42/listings' => Http::response([ 'error' => 'invalid token access_token=super-secret-token refresh_token=super-secret-refresh', ], 403), ]); $this->connectShop(); $result = $this->mcpCall('etsy_create_draft_listing', [ 'title' => 'Widget', 'description' => 'A printed widget', 'price' => 12.5, 'quantity' => 3, 'who_made' => 'i_did', 'when_made' => 'made_to_order', 'taxonomy_id' => 1234, ]); $this->assertTrue($result['isError']); $encoded = json_encode($result['rpc']); $this->assertStringNotContainsString('super-secret-token', $encoded); $this->assertStringNotContainsString('123.token', $encoded); $this->assertStringContainsString('reconnect', strtolower($encoded)); } #[TestWith([7])] #[TestWith([null])] public function test_inventory_access_is_denied_for_another_shop_before_any_inventory_request(?int $shopId): void { $this->connectShop(); Http::preventStrayRequests(); Http::fake([ '*/application/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => $shopId]), '*/inventory' => Http::response(['products' => []]), ]); $read = $this->mcpCall('etsy_get_listing_inventory', ['listing_id' => 99]); $this->assertTrue($read['isError']); $write = $this->mcpCall('etsy_update_listing_inventory', ['listing_id' => 99, 'products' => []]); $this->assertTrue($write['isError']); Http::assertNotSent(fn ($request): bool => str_ends_with($request->url(), '/inventory')); } public function test_mutation_with_missing_scopes_requires_reconnect_without_calling_etsy(): void { $this->connectShop('transactions_r listings_r'); Http::fake(); $result = $this->mcpCall('etsy_publish_listing', ['listing_id' => 99, 'confirm' => true]); $this->assertTrue($result['isError']); $this->assertStringContainsString('reconnect', strtolower($result['data'])); Http::assertNothingSent(); } #[TestWith(['quantity', -1])] #[TestWith(['price', -5])] #[TestWith(['who_made', 'invalid'])] #[TestWith(['unexpected_field', true])] public function test_invalid_draft_payload_is_rejected_before_http(string $field, mixed $value): void { $this->connectShop(); Http::fake(['*' => Http::response(['listing_id' => 99, 'shop_id' => 42])]); $payload = ['title' => 'Widget', 'description' => 'Printed widget', 'quantity' => 1, 'price' => 5, 'who_made' => 'i_did', 'when_made' => 'made_to_order', 'taxonomy_id' => 1]; $payload[$field] = $value; $result = $this->mcpCall('etsy_create_draft_listing', $payload); $this->assertTrue($result['isError']); Http::assertNothingSent(); } public function test_wrong_shop_name_is_rejected_without_calling_etsy(): void { Http::preventStrayRequests(); config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D']); EtsyConnection::create([ 'shop_id' => 99, 'shop_name' => 'SomeoneElse', 'access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_at' => now()->addHour(), 'scopes' => 'listings_r listings_w transactions_r transactions_w shops_r', ]); $result = $this->mcpCall('etsy_list_listings', ['state' => 'active']); $this->assertTrue($result['isError']); $this->assertStringContainsString('HickLab3D', is_string($result['data']) ? $result['data'] : json_encode($result['data'])); Http::assertNothingSent(); } /** * @param array $message * @return array */ private function mcp(array $message): array { $raw = $this->mcpRaw($message); $this->assertNotNull($raw); $decoded = json_decode($raw, true); $this->assertIsArray($decoded); return $decoded; } /** * @param array $message */ private function mcpRaw(array $message): ?string { $command = $this->app->make(EtsyMcp::class); $command->setLaravel($this->app); $stdin = fopen('php://temp', 'w+'); fwrite($stdin, json_encode($message)."\n"); rewind($stdin); $input = new ArrayInput([]); $input->bind($command->getDefinition()); $input->setStream($stdin); $output = new BufferedOutput(BufferedOutput::VERBOSITY_NORMAL, false); $this->assertSame(0, $command->run($input, $output)); $raw = trim($output->fetch()); return $raw === '' ? null : $raw; } /** * @param array $arguments * @return array{rpc: array, data: mixed, isError: bool} */ private function mcpCall(string $name, array $arguments = [], int $id = 2): array { $response = $this->mcp([ 'jsonrpc' => '2.0', 'id' => $id, 'method' => 'tools/call', 'params' => ['name' => $name, 'arguments' => $arguments], ]); $this->assertArrayHasKey('result', $response); $text = $response['result']['content'][0]['text'] ?? ''; $decoded = json_decode($text, true); return [ 'rpc' => $response, 'data' => is_array($decoded) ? $decoded : $text, 'isError' => (bool) ($response['result']['isError'] ?? false), ]; } private function connectShop(string $scopes = 'listings_r listings_w transactions_r transactions_w shops_r'): void { config()->set('services.etsy', [ 'keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D', 'redirect_uri' => 'https://example.test/etsy/callback', ]); EtsyConnection::create([ 'shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_at' => now()->addHour(), 'scopes' => $scopes, ]); } }