521 lines
23 KiB
PHP
521 lines
23 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 PHPUnit\Framework\Attributes\TestWith;
|
|
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/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<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,
|
|
]);
|
|
}
|
|
}
|