Expose php artisan etsy:mcp as JSON-RPC stdio next to laravel-boost, reuse the stored HickLab3D shop connection, create listings as drafts only, and skip Etsy v3 conversation send because that API does not exist.
370 lines
16 KiB
PHP
370 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Console\Commands\EtsyMcp;
|
|
use App\Models\EtsyConnection;
|
|
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Symfony\Component\Console\Input\ArrayInput;
|
|
use Symfony\Component\Console\Output\BufferedOutput;
|
|
use Tests\TestCase;
|
|
|
|
class EtsyMcpTest extends TestCase
|
|
{
|
|
use LazilyRefreshDatabase;
|
|
|
|
public function test_tools_list_is_served_over_stdio_jsonrpc(): void
|
|
{
|
|
$response = $this->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/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');
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
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/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));
|
|
}
|
|
|
|
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<string, mixed> $message
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed> $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<string, mixed> $arguments
|
|
* @return array{rpc: array<string, mixed>, 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,
|
|
]);
|
|
}
|
|
}
|