[verified] Harden Etsy MCP validation and scheduled token refresh
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 38s
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 38s
This commit is contained in:
@@ -166,41 +166,11 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Ensure writable runtime paths
|
- name: Preserve managed production environment
|
||||||
working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }}
|
working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }}
|
||||||
env:
|
|
||||||
RUNTIME_PATH: /tmp/${{ env.PROJECT_NAME }}
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p "$RUNTIME_PATH/views" "$RUNTIME_PATH/logs" "$RUNTIME_PATH/cache"
|
# Infisical owns .env. Never rewrite runtime paths or integration settings here.
|
||||||
chmod 0777 "$RUNTIME_PATH" "$RUNTIME_PATH/views" "$RUNTIME_PATH/logs" "$RUNTIME_PATH/cache"
|
test -f .env
|
||||||
|
|
||||||
if grep -q '^VIEW_COMPILED_PATH=' .env; then
|
|
||||||
sed -i "s|^VIEW_COMPILED_PATH=.*|VIEW_COMPILED_PATH=/tmp/HickLab3D.views|" .env
|
|
||||||
else
|
|
||||||
echo "VIEW_COMPILED_PATH=/tmp/HickLab3D.views" >> .env
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -q '^LOG_PATH=' .env; then
|
|
||||||
sed -i "s|^LOG_PATH=.*|LOG_PATH=/tmp/HickLab3D.laravel.log|" .env
|
|
||||||
else
|
|
||||||
echo "LOG_PATH=/tmp/HickLab3D.laravel.log" >> .env
|
|
||||||
fi
|
|
||||||
|
|
||||||
for key_path in \
|
|
||||||
"APP_SERVICES_CACHE=storage/framework/cache/data/services.php" \
|
|
||||||
"APP_PACKAGES_CACHE=storage/framework/cache/data/packages.php" \
|
|
||||||
"APP_CONFIG_CACHE=/tmp/HickLab3D.config.php" \
|
|
||||||
"APP_ROUTES_CACHE=/tmp/HickLab3D.routes.php" \
|
|
||||||
"APP_EVENTS_CACHE=/tmp/HickLab3D.events.php"
|
|
||||||
do
|
|
||||||
key="${key_path%%=*}"
|
|
||||||
value="${key_path#*=}"
|
|
||||||
if grep -q "^${key}=" .env; then
|
|
||||||
sed -i "s|^${key}=.*|${key}=${value}|" .env
|
|
||||||
else
|
|
||||||
echo "${key}=${value}" >> .env
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Install production PHP dependencies
|
- name: Install production PHP dependencies
|
||||||
working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }}
|
working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }}
|
||||||
|
|||||||
@@ -43,13 +43,23 @@ First-party stdio MCP for HL3D agents. It uses the stored HickLab3D shop connect
|
|||||||
php artisan etsy:mcp
|
php artisan etsy:mcp
|
||||||
```
|
```
|
||||||
|
|
||||||
Register it from this repo's `.mcp.json` as server name `etsy` (`php` + `artisan etsy:mcp`). Example for an HL3D Hermes profile after this server exists:
|
Register the local `.mcp.json` only where PHP is installed and the working directory is this app. On the Hermes host, use the existing web container instead (after deployment):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hermes -p hl3d_teamlead mcp add etsy --command php --args artisan etsy:mcp
|
hermes -p hl3d_teamlead mcp add etsy --command ssh --args -T H-NODE-05 'docker exec -i -u www-data -w /var/www/HickLab3D nginx-ui-php php artisan etsy:mcp --no-ansi'
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the command from the dashboard app root so it inherits `.env`. After deploy, an admin must reconnect Etsy in the dashboard so the token includes `listings_r listings_w transactions_r transactions_w shops_r`. New listings are created as drafts; publishing requires `etsy_publish_listing` with `confirm=true`.
|
This uses the managed live environment without placing credentials in MCP config and does not require host PHP. Configure positive tool allow-lists through supported `hermes config set` commands before exposing tools to agents: teamlead/designer should get read/status tools only; marketing may additionally create drafts. Do not expose publishing, shipment or inventory-write tools without separately authorising those operations. Disable MCP sampling for this server. Start a fresh profile session or reload MCP after configuration.
|
||||||
|
|
||||||
|
An admin must reconnect Etsy in the dashboard if status reports unknown or missing scopes (`listings_r listings_w transactions_r transactions_w shops_r`). Never request operator passwords or MFA codes. New listings are drafts; `confirm=true` is a technical publish guard, not operator authorisation. Publishing, activating, renewing or repricing live listings, inventory changes and shipping still require explicit approval for the specific action in the current conversation.
|
||||||
|
|
||||||
|
## Artisan scheduler
|
||||||
|
|
||||||
|
The Settings page lets admins tune registered jobs or run them immediately. Defaults: `integrations:refresh` and `alerts:scan` every 30 seconds, `etsy:sync` every 15 minutes, and `etsy:refresh-token` checks every five minutes. Etsy is no longer force-refreshed by the bundled integration job. The refresh check rotates only within ten minutes of expiry; database row locking and a fresh read serialise rotating-token use across MCP, HTTP and scheduler processes. Lowering the token-check cadence or disabling it can leave an idle access token expired.
|
||||||
|
|
||||||
|
Settings are atomically replaced under a shared file lock at `storage/app/dashboard-schedules.json`. Preserve storage and the Infisical-managed `.env` during deployments. Only H-NODE-05 owns the systemd scheduler timer; its container working directory must be `/var/www/HickLab3D`, matching the actual vhost and CI target. Subminute tasks keep `schedule:run` alive until the end of the minute, so do not install a duplicate cron or scheduler elsewhere.
|
||||||
|
|
||||||
|
For local tests in the existing serviceengineering toolchain image, explicitly set `APP_ENV=testing`: the image exports `APP_ENV=local`, which otherwise wins over PHPUnit XML and correctly activates real CSRF enforcement. Never disable production CSRF to repair that test-environment mismatch.
|
||||||
|
|
||||||
Etsy Open API v3 has no conversation inbox (`conversations_*` scopes do not exist). Receipt notes are exposed; sending a buyer-inbox reply is not.
|
Etsy Open API v3 has no conversation inbox (`conversations_*` scopes do not exist). Receipt notes are exposed; sending a buyer-inbox reply is not.
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ class EtsySync extends Command
|
|||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$failed = false;
|
||||||
try {
|
try {
|
||||||
$receipts = $etsy->receipts($connection, fresh: true);
|
$receipts = $etsy->receipts($connection, fresh: true);
|
||||||
$this->line('etsy receipts: refreshed ('.($receipts['count'] ?? 0).' open)');
|
$this->line('etsy receipts: refreshed ('.($receipts['count'] ?? 0).' open)');
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
|
$failed = true;
|
||||||
$this->warn('etsy receipts: '.$exception->getMessage());
|
$this->warn('etsy receipts: '.$exception->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,9 +36,10 @@ class EtsySync extends Command
|
|||||||
$listings = $etsy->listings($connection, fresh: true);
|
$listings = $etsy->listings($connection, fresh: true);
|
||||||
$this->line('etsy listings: refreshed ('.count($listings['results'] ?? []).' active)');
|
$this->line('etsy listings: refreshed ('.count($listings['results'] ?? []).' active)');
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
|
$failed = true;
|
||||||
$this->warn('etsy listings: '.$exception->getMessage());
|
$this->warn('etsy listings: '.$exception->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::SUCCESS;
|
return $failed ? self::FAILURE : self::SUCCESS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\EtsyConnection;
|
|
||||||
use App\Services\Bambuddy;
|
use App\Services\Bambuddy;
|
||||||
use App\Services\DryerClient;
|
use App\Services\DryerClient;
|
||||||
use App\Services\Etsy;
|
use App\Services\Etsy;
|
||||||
@@ -22,11 +21,6 @@ class RefreshIntegrationCache extends Command
|
|||||||
{
|
{
|
||||||
$cache->forceRefresh = true;
|
$cache->forceRefresh = true;
|
||||||
|
|
||||||
if (($connection = EtsyConnection::first()) && $etsy->configured()) {
|
|
||||||
$this->refresh('etsy receipts', fn () => $etsy->receipts($connection));
|
|
||||||
$this->refresh('etsy listings', fn () => $etsy->listings($connection));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->refresh('parcel2go', function () use ($parcel2Go): void {
|
$this->refresh('parcel2go', function () use ($parcel2Go): void {
|
||||||
$parcel2Go->verifyCredentials();
|
$parcel2Go->verifyCredentials();
|
||||||
foreach (['me/orders/detail', 'me/detail', 'me/prepay/balance'] as $path) {
|
foreach (['me/orders/detail', 'me/detail', 'me/prepay/balance'] as $path) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class ScheduleSettingsController extends Controller
|
|||||||
$frequencyKeys = array_keys(DashboardSchedule::frequencyOptions());
|
$frequencyKeys = array_keys(DashboardSchedule::frequencyOptions());
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'schedules' => ['required', 'array'],
|
'schedules' => ['required', 'array:'.implode(',', $jobKeys)],
|
||||||
'schedules.*' => ['required', 'string', Rule::in($frequencyKeys)],
|
'schedules.*' => ['required', 'string', Rule::in($frequencyKeys)],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+112
-31
@@ -6,6 +6,8 @@ use App\Models\EtsyAppCredential;
|
|||||||
use App\Models\EtsyConnection;
|
use App\Models\EtsyConnection;
|
||||||
use Illuminate\Http\Client\RequestException;
|
use Illuminate\Http\Client\RequestException;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class Etsy
|
class Etsy
|
||||||
@@ -217,18 +219,55 @@ class Etsy
|
|||||||
return $this->summarizeListing($listing, detailed: true);
|
return $this->summarizeListing($listing, detailed: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function requireScope(EtsyConnection $connection, string $scope): void
|
||||||
* @param array<string, mixed> $payload
|
{
|
||||||
|
if (! in_array($scope, $this->scopeList($connection->scopes) ?? [], true)) {
|
||||||
|
throw new RuntimeException('An admin must reconnect Etsy in the dashboard to grant '.$scope.'.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $payload
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
private function validatedListingPayload(array $payload, bool $creating): array
|
||||||
|
{
|
||||||
|
unset($payload['listing_id']);
|
||||||
|
if ($creating) {
|
||||||
|
unset($payload['shop_id'], $payload['state']);
|
||||||
|
}
|
||||||
|
$allowedFields = $creating
|
||||||
|
? 'title,description,price,quantity,who_made,when_made,taxonomy_id,shipping_profile_id,tags,materials,type'
|
||||||
|
: 'title,description,taxonomy_id,tags,state';
|
||||||
|
$required = $creating ? 'required' : 'sometimes';
|
||||||
|
$rules = [
|
||||||
|
'listing' => ['required', 'array:'.$allowedFields],
|
||||||
|
'listing.title' => [$required, 'string', 'max:140'],
|
||||||
|
'listing.description' => [$required, 'string'],
|
||||||
|
'listing.price' => [$required, 'numeric', 'gt:0'],
|
||||||
|
'listing.quantity' => [$required, 'integer', 'min:1'],
|
||||||
|
'listing.who_made' => [$required, Rule::in(['i_did', 'someone_else', 'collective'])],
|
||||||
|
'listing.when_made' => [$required, 'string', 'max:32'],
|
||||||
|
'listing.taxonomy_id' => [$required, 'integer', 'min:1'],
|
||||||
|
'listing.shipping_profile_id' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
'listing.tags' => ['sometimes', 'array', 'max:13'],
|
||||||
|
'listing.tags.*' => ['string', 'max:20'],
|
||||||
|
'listing.materials' => ['sometimes', 'array'],
|
||||||
|
'listing.materials.*' => ['string'],
|
||||||
|
'listing.type' => ['sometimes', Rule::in(['physical', 'download', 'both'])],
|
||||||
|
'listing.state' => ['sometimes', Rule::in(['draft', 'inactive'])],
|
||||||
|
];
|
||||||
|
$validator = Validator::make(['listing' => $payload], $rules);
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw new RuntimeException('Invalid listing payload: '.$validator->errors()->first());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $validator->validated()['listing'];
|
||||||
|
}
|
||||||
|
|
||||||
public function createDraftListing(EtsyConnection $connection, array $payload): array
|
public function createDraftListing(EtsyConnection $connection, array $payload): array
|
||||||
{
|
{
|
||||||
foreach (['quantity', 'title', 'description', 'price', 'who_made', 'when_made', 'taxonomy_id'] as $field) {
|
$this->requireScope($connection, 'listings_w');
|
||||||
if (! array_key_exists($field, $payload) || $payload[$field] === '' || $payload[$field] === null) {
|
$payload = $this->validatedListingPayload($payload, true);
|
||||||
throw new RuntimeException("Draft listing requires {$field}.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($payload['state'], $payload['shop_id'], $payload['listing_id']);
|
|
||||||
$listing = $this->request('POST', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), body: $payload);
|
$listing = $this->request('POST', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), body: $payload);
|
||||||
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
||||||
|
|
||||||
@@ -244,7 +283,9 @@ class Etsy
|
|||||||
if (isset($payload['state']) && strtolower((string) $payload['state']) === 'active') {
|
if (isset($payload['state']) && strtolower((string) $payload['state']) === 'active') {
|
||||||
throw new RuntimeException('Activating a listing requires etsy_publish_listing with confirm=true. HL3D listings stay draft until the operator authorises going live.');
|
throw new RuntimeException('Activating a listing requires etsy_publish_listing with confirm=true. HL3D listings stay draft until the operator authorises going live.');
|
||||||
}
|
}
|
||||||
unset($payload['shop_id'], $payload['listing_id']);
|
$this->requireScope($connection, 'listings_w');
|
||||||
|
$payload = $this->validatedListingPayload($payload, false);
|
||||||
|
$this->getListing($connection, $listingId);
|
||||||
$listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: $payload);
|
$listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: $payload);
|
||||||
$this->assertListingBelongsToShop($connection, $listing);
|
$this->assertListingBelongsToShop($connection, $listing);
|
||||||
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
||||||
@@ -257,9 +298,11 @@ class Etsy
|
|||||||
*/
|
*/
|
||||||
public function publishListing(EtsyConnection $connection, int $listingId, bool $confirm = false): array
|
public function publishListing(EtsyConnection $connection, int $listingId, bool $confirm = false): array
|
||||||
{
|
{
|
||||||
|
$this->requireScope($connection, 'listings_w');
|
||||||
if (! $confirm) {
|
if (! $confirm) {
|
||||||
throw new RuntimeException('Publishing a listing requires confirm=true. HL3D listings stay draft until the operator authorises going live.');
|
throw new RuntimeException('Publishing a listing requires confirm=true. HL3D listings stay draft until the operator authorises going live.');
|
||||||
}
|
}
|
||||||
|
$this->getListing($connection, $listingId);
|
||||||
$listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: ['state' => 'active']);
|
$listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: ['state' => 'active']);
|
||||||
$this->assertListingBelongsToShop($connection, $listing);
|
$this->assertListingBelongsToShop($connection, $listing);
|
||||||
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
||||||
@@ -272,6 +315,8 @@ class Etsy
|
|||||||
*/
|
*/
|
||||||
public function getListingInventory(EtsyConnection $connection, int $listingId): array
|
public function getListingInventory(EtsyConnection $connection, int $listingId): array
|
||||||
{
|
{
|
||||||
|
$this->getListing($connection, $listingId);
|
||||||
|
|
||||||
return $this->request('GET', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection));
|
return $this->request('GET', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,13 +326,38 @@ class Etsy
|
|||||||
*/
|
*/
|
||||||
public function updateListingInventory(EtsyConnection $connection, int $listingId, array $payload): array
|
public function updateListingInventory(EtsyConnection $connection, int $listingId, array $payload): array
|
||||||
{
|
{
|
||||||
|
$this->requireScope($connection, 'listings_w');
|
||||||
|
$validator = Validator::make($payload, [
|
||||||
|
'quantity' => ['required_without:products', 'integer', 'min:0'],
|
||||||
|
'products' => ['required_without:quantity', 'array', 'min:1'],
|
||||||
|
'products.*' => ['array:sku,property_values,offerings'],
|
||||||
|
'products.*.sku' => ['sometimes', 'string'],
|
||||||
|
'products.*.property_values' => ['sometimes', 'array'],
|
||||||
|
'products.*.offerings' => ['required', 'array', 'min:1'],
|
||||||
|
'products.*.offerings.*' => ['array:price,quantity,is_enabled,readiness_state_id'],
|
||||||
|
'products.*.offerings.*.price' => ['required', 'numeric', 'gt:0'],
|
||||||
|
'products.*.offerings.*.quantity' => ['required', 'integer', 'min:0'],
|
||||||
|
'products.*.offerings.*.is_enabled' => ['required', 'boolean'],
|
||||||
|
'products.*.offerings.*.readiness_state_id' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
]);
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw new RuntimeException('Invalid inventory payload: '.$validator->errors()->first());
|
||||||
|
}
|
||||||
|
$this->getListing($connection, $listingId);
|
||||||
|
$currentInventory = $this->getListingInventory($connection, $listingId);
|
||||||
if (! isset($payload['products']) && array_key_exists('quantity', $payload)) {
|
if (! isset($payload['products']) && array_key_exists('quantity', $payload)) {
|
||||||
$payload = ['products' => $this->productsWithQuantity($this->getListingInventory($connection, $listingId), (int) $payload['quantity'])];
|
$payload = ['products' => $this->productsWithQuantity($currentInventory, (int) $payload['quantity'])];
|
||||||
}
|
}
|
||||||
if (! isset($payload['products']) || ! is_array($payload['products'])) {
|
if (! isset($payload['products']) || ! is_array($payload['products'])) {
|
||||||
throw new RuntimeException('Inventory update requires products or quantity.');
|
throw new RuntimeException('Inventory update requires products or quantity.');
|
||||||
}
|
}
|
||||||
$inventory = $this->request('PUT', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection), body: ['products' => $payload['products']], jsonBody: true);
|
$body = ['products' => $payload['products']];
|
||||||
|
foreach (['price_on_property', 'quantity_on_property', 'sku_on_property', 'readiness_state_on_property'] as $field) {
|
||||||
|
if (array_key_exists($field, $currentInventory)) {
|
||||||
|
$body[$field] = $currentInventory[$field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$inventory = $this->request('PUT', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection), body: $body, jsonBody: true);
|
||||||
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
||||||
|
|
||||||
return $inventory;
|
return $inventory;
|
||||||
@@ -332,6 +402,7 @@ class Etsy
|
|||||||
*/
|
*/
|
||||||
public function createReceiptShipment(EtsyConnection $connection, int $receiptId, string $trackingCode, string $carrierName, array $extra = []): array
|
public function createReceiptShipment(EtsyConnection $connection, int $receiptId, string $trackingCode, string $carrierName, array $extra = []): array
|
||||||
{
|
{
|
||||||
|
$this->requireScope($connection, 'transactions_w');
|
||||||
if ($trackingCode === '' || $carrierName === '') {
|
if ($trackingCode === '' || $carrierName === '') {
|
||||||
throw new RuntimeException('Marking a receipt shipped requires tracking_code and carrier_name.');
|
throw new RuntimeException('Marking a receipt shipped requires tracking_code and carrier_name.');
|
||||||
}
|
}
|
||||||
@@ -400,29 +471,38 @@ class Etsy
|
|||||||
*/
|
*/
|
||||||
public function refreshAccessToken(EtsyConnection $connection): string
|
public function refreshAccessToken(EtsyConnection $connection): string
|
||||||
{
|
{
|
||||||
return $this->accessToken($connection);
|
return $this->accessToken($connection, 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function accessToken(EtsyConnection $connection): string
|
private function accessToken(EtsyConnection $connection, int $marginSeconds = 0): string
|
||||||
{
|
{
|
||||||
if ($connection->expires_at->isFuture()) {
|
return $connection->getConnection()->transaction(function () use ($connection, $marginSeconds): string {
|
||||||
return $connection->access_token;
|
// Lock the shared database row, not a per-process cache: HTTP, MCP
|
||||||
}
|
// and scheduler workers must re-read the latest rotating token.
|
||||||
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
|
$current = EtsyConnection::query()->whereKey($connection->getKey())->lockForUpdate()->firstOrFail();
|
||||||
->post(self::API_URL.'/public/oauth/token', [
|
if ($current->expires_at->greaterThan(now()->addSeconds($marginSeconds))) {
|
||||||
'grant_type' => 'refresh_token', 'client_id' => $this->credentials()['keystring'],
|
$connection->setRawAttributes($current->getAttributes(), true);
|
||||||
'refresh_token' => $connection->refresh_token,
|
|
||||||
])->throw()->json();
|
|
||||||
if (empty($tokens['access_token']) || empty($tokens['refresh_token'])) {
|
|
||||||
throw new RuntimeException('Etsy did not refresh the shop connection.');
|
|
||||||
}
|
|
||||||
$connection->update([
|
|
||||||
'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'],
|
|
||||||
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)),
|
|
||||||
...isset($tokens['scope']) ? ['scopes' => trim((string) $tokens['scope'])] : [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $tokens['access_token'];
|
return $current->access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
|
||||||
|
->post(self::API_URL.'/public/oauth/token', [
|
||||||
|
'grant_type' => 'refresh_token', 'client_id' => $this->credentials()['keystring'],
|
||||||
|
'refresh_token' => $current->refresh_token,
|
||||||
|
])->throw()->json();
|
||||||
|
if (empty($tokens['access_token']) || empty($tokens['refresh_token'])) {
|
||||||
|
throw new RuntimeException('Etsy did not refresh the shop connection.');
|
||||||
|
}
|
||||||
|
$current->update([
|
||||||
|
'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'],
|
||||||
|
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)),
|
||||||
|
...isset($tokens['scope']) ? ['scopes' => trim((string) $tokens['scope'])] : [],
|
||||||
|
]);
|
||||||
|
$connection->setRawAttributes($current->getAttributes(), true);
|
||||||
|
|
||||||
|
return $current->access_token;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -471,7 +551,7 @@ class Etsy
|
|||||||
*/
|
*/
|
||||||
private function assertListingBelongsToShop(EtsyConnection $connection, array $listing): void
|
private function assertListingBelongsToShop(EtsyConnection $connection, array $listing): void
|
||||||
{
|
{
|
||||||
if (isset($listing['shop_id']) && (int) $listing['shop_id'] !== (int) $connection->shop_id) {
|
if (! isset($listing['shop_id']) || (int) $listing['shop_id'] !== (int) $connection->shop_id) {
|
||||||
throw new RuntimeException('That listing does not belong to the connected HickLab3D shop.');
|
throw new RuntimeException('That listing does not belong to the connected HickLab3D shop.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,6 +643,7 @@ class Etsy
|
|||||||
'price' => $price,
|
'price' => $price,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'is_enabled' => $offering['is_enabled'] ?? true,
|
'is_enabled' => $offering['is_enabled'] ?? true,
|
||||||
|
...array_intersect_key($offering, array_flip(['readiness_state_id'])),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
$products[] = [
|
$products[] = [
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class EtsyMcpServer
|
|||||||
'protocolVersion' => is_string($params['protocolVersion'] ?? null) ? $params['protocolVersion'] : '2024-11-05',
|
'protocolVersion' => is_string($params['protocolVersion'] ?? null) ? $params['protocolVersion'] : '2024-11-05',
|
||||||
'capabilities' => ['tools' => ['listChanged' => false]],
|
'capabilities' => ['tools' => ['listChanged' => false]],
|
||||||
'serverInfo' => ['name' => 'etsy', 'version' => '1.0.0'],
|
'serverInfo' => ['name' => 'etsy', 'version' => '1.0.0'],
|
||||||
'instructions' => 'HickLab3D Etsy shop MCP. Uses the dashboard stored shop connection — do not start a new OAuth dance. New listings are drafts unless etsy_publish_listing is called with confirm=true. After deploy, an admin must reconnect Etsy in the dashboard so the token includes listings_w, transactions_w, and shops_r. Buyer-inbox send is not available on Etsy Open API v3.',
|
'instructions' => 'HickLab3D Etsy shop MCP. Uses the dashboard stored shop connection — do not start a new OAuth dance. New listings are drafts. Never publish, activate, renew, reprice a live listing, change inventory or shop settings, or ship orders without explicit operator authorisation for that specific action in the current conversation. confirm=true is only a technical guard, not authorisation. After deploy, an admin must reconnect Etsy in the dashboard so the token includes listings_w, transactions_w, and shops_r. Buyer-inbox send is not available on Etsy Open API v3.',
|
||||||
],
|
],
|
||||||
'notifications/initialized', 'initialized' => [],
|
'notifications/initialized', 'initialized' => [],
|
||||||
'ping' => ['ok' => true],
|
'ping' => ['ok' => true],
|
||||||
@@ -78,6 +78,18 @@ class EtsyMcpServer
|
|||||||
private function callTool(string $name, array $arguments): array
|
private function callTool(string $name, array $arguments): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$validationArguments = $arguments;
|
||||||
|
if ($name === 'etsy_create_draft_listing') {
|
||||||
|
unset($validationArguments['state']);
|
||||||
|
}
|
||||||
|
if ($name === 'etsy_update_listing' && ($arguments['state'] ?? null) === 'active') {
|
||||||
|
throw new RuntimeException('Activating a listing requires etsy_publish_listing with confirm=true.');
|
||||||
|
}
|
||||||
|
$tool = collect($this->tools())->firstWhere('name', $name);
|
||||||
|
if ($tool === null) {
|
||||||
|
throw new RuntimeException('Unknown tool: '.$name);
|
||||||
|
}
|
||||||
|
$this->validateSchema($validationArguments, $tool['inputSchema'], 'arguments');
|
||||||
$data = $this->invoke($name, $arguments);
|
$data = $this->invoke($name, $arguments);
|
||||||
$payload = $this->redact($data);
|
$payload = $this->redact($data);
|
||||||
|
|
||||||
@@ -182,7 +194,7 @@ class EtsyMcpServer
|
|||||||
'type' => ['type' => 'string', 'enum' => ['physical', 'download', 'both']],
|
'type' => ['type' => 'string', 'enum' => ['physical', 'download', 'both']],
|
||||||
],
|
],
|
||||||
'required' => ['title', 'description', 'price', 'quantity', 'who_made', 'when_made', 'taxonomy_id'],
|
'required' => ['title', 'description', 'price', 'quantity', 'who_made', 'when_made', 'taxonomy_id'],
|
||||||
'additionalProperties' => true,
|
'additionalProperties' => false,
|
||||||
]),
|
]),
|
||||||
$this->tool('etsy_update_listing', 'Update HickLab3D listing fields (title, description, tags, state=inactive, etc). Refuses state=active — use etsy_publish_listing with confirm=true to go live.', [
|
$this->tool('etsy_update_listing', 'Update HickLab3D listing fields (title, description, tags, state=inactive, etc). Refuses state=active — use etsy_publish_listing with confirm=true to go live.', [
|
||||||
'type' => 'object',
|
'type' => 'object',
|
||||||
@@ -195,7 +207,7 @@ class EtsyMcpServer
|
|||||||
'state' => ['type' => 'string', 'enum' => ['inactive', 'draft'], 'description' => 'inactive deactivates. active is rejected; use etsy_publish_listing.'],
|
'state' => ['type' => 'string', 'enum' => ['inactive', 'draft'], 'description' => 'inactive deactivates. active is rejected; use etsy_publish_listing.'],
|
||||||
],
|
],
|
||||||
'required' => ['listing_id'],
|
'required' => ['listing_id'],
|
||||||
'additionalProperties' => true,
|
'additionalProperties' => false,
|
||||||
]),
|
]),
|
||||||
$this->tool('etsy_publish_listing', 'Publish a draft HickLab3D listing (state=active). Requires confirm=true. The listing must already have images (and a shipping profile for physical items).', [
|
$this->tool('etsy_publish_listing', 'Publish a draft HickLab3D listing (state=active). Requires confirm=true. The listing must already have images (and a shipping profile for physical items).', [
|
||||||
'type' => 'object',
|
'type' => 'object',
|
||||||
@@ -287,11 +299,51 @@ class EtsyMcpServer
|
|||||||
*/
|
*/
|
||||||
private function requiredInt(array $arguments, string $key): int
|
private function requiredInt(array $arguments, string $key): int
|
||||||
{
|
{
|
||||||
if (! isset($arguments[$key]) || ! is_numeric($arguments[$key])) {
|
if (! isset($arguments[$key]) || ! is_int($arguments[$key]) || $arguments[$key] < 1) {
|
||||||
throw new RuntimeException("{$key} is required.");
|
throw new RuntimeException("{$key} must be a positive integer.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int) $arguments[$key];
|
return $arguments[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $schema */
|
||||||
|
private function validateSchema(mixed $value, array $schema, string $path): void
|
||||||
|
{
|
||||||
|
$validType = match ($schema['type'] ?? null) {
|
||||||
|
'object' => is_array($value) && ($value === [] || ! array_is_list($value)),
|
||||||
|
'array' => is_array($value) && array_is_list($value),
|
||||||
|
'integer' => is_int($value),
|
||||||
|
'number' => is_int($value) || is_float($value),
|
||||||
|
'boolean' => is_bool($value),
|
||||||
|
'string' => is_string($value),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
if (! $validType
|
||||||
|
|| (isset($schema['enum']) && ! in_array($value, $schema['enum'], true))
|
||||||
|
|| (isset($schema['minimum']) && $value < $schema['minimum'])
|
||||||
|
|| (isset($schema['maximum']) && $value > $schema['maximum'])) {
|
||||||
|
throw new RuntimeException('Invalid listing payload: '.$path.' does not match the tool schema.');
|
||||||
|
}
|
||||||
|
if (($schema['type'] ?? null) === 'object') {
|
||||||
|
$properties = (array) ($schema['properties'] ?? []);
|
||||||
|
foreach ($schema['required'] ?? [] as $key) {
|
||||||
|
if (! array_key_exists($key, $value)) {
|
||||||
|
throw new RuntimeException('Invalid listing payload: '.$path.'.'.$key.' is required.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($value as $key => $item) {
|
||||||
|
if (isset($properties[$key])) {
|
||||||
|
$this->validateSchema($item, $properties[$key], $path.'.'.$key);
|
||||||
|
} elseif (($schema['additionalProperties'] ?? true) === false) {
|
||||||
|
throw new RuntimeException('Invalid listing payload: '.$path.'.'.$key.' is not supported.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (($schema['type'] ?? null) === 'array' && isset($schema['items'])) {
|
||||||
|
foreach ($value as $key => $item) {
|
||||||
|
$this->validateSchema($item, $schema['items'], $path.'.'.$key);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class IntegrationCache
|
|||||||
try {
|
try {
|
||||||
$value = $fetch();
|
$value = $fetch();
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
if (is_array($entry)) {
|
if (is_array($entry) && ! $fresh) {
|
||||||
report($exception);
|
report($exception);
|
||||||
|
|
||||||
return $entry['value'];
|
return $entry['value'];
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class DashboardSchedule
|
|||||||
'name' => 'Integration cache refresh',
|
'name' => 'Integration cache refresh',
|
||||||
'feature' => 'Integrations',
|
'feature' => 'Integrations',
|
||||||
'command' => 'integrations:refresh',
|
'command' => 'integrations:refresh',
|
||||||
'description' => 'Refreshes cached Etsy, Parcel2Go, BamBuddy and dryer data so dashboard pages load instantly. Migrated from the previous hard-coded schedule entry — frequency and withoutOverlapping(5) behavior unchanged.',
|
'description' => 'Refreshes cached Parcel2Go, BamBuddy and dryer data. Etsy runs separately through etsy:sync; the 30-second cadence and withoutOverlapping(5) guard are unchanged.',
|
||||||
'default_frequency' => 'every_30_seconds',
|
'default_frequency' => 'every_30_seconds',
|
||||||
'overlap_minutes' => 5,
|
'overlap_minutes' => 5,
|
||||||
'manual' => true,
|
'manual' => true,
|
||||||
@@ -46,16 +46,16 @@ class DashboardSchedule
|
|||||||
'command' => 'etsy:sync',
|
'command' => 'etsy:sync',
|
||||||
'description' => 'Forces a fresh pull of Etsy receipts and active listings into the same IntegrationCache entries the Etsy page reads, independent of the bundled integrations:refresh job so it can be tuned or disabled on its own.',
|
'description' => 'Forces a fresh pull of Etsy receipts and active listings into the same IntegrationCache entries the Etsy page reads, independent of the bundled integrations:refresh job so it can be tuned or disabled on its own.',
|
||||||
'default_frequency' => 'every_15_minutes',
|
'default_frequency' => 'every_15_minutes',
|
||||||
'overlap_minutes' => null,
|
'overlap_minutes' => 5,
|
||||||
'manual' => true,
|
'manual' => true,
|
||||||
],
|
],
|
||||||
'etsy_token_refresh' => [
|
'etsy_token_refresh' => [
|
||||||
'name' => 'Etsy token refresh',
|
'name' => 'Etsy token refresh',
|
||||||
'feature' => 'Etsy',
|
'feature' => 'Etsy',
|
||||||
'command' => 'etsy:refresh-token',
|
'command' => 'etsy:refresh-token',
|
||||||
'description' => 'Proactively refreshes the Etsy OAuth access token (tokens last ~1 hour) so the connection stays alive even during quiet periods with no receipts or listings activity to trigger an on-demand refresh.',
|
'description' => 'Checks every five minutes by default and refreshes only when expiry is within ten minutes. Disabling or slowing this job can leave idle tokens expired; on-demand reads also refresh expired tokens.',
|
||||||
'default_frequency' => 'every_45_minutes',
|
'default_frequency' => 'every_5_minutes',
|
||||||
'overlap_minutes' => null,
|
'overlap_minutes' => 5,
|
||||||
'manual' => true,
|
'manual' => true,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -104,7 +104,22 @@ class DashboardSchedule
|
|||||||
->all();
|
->all();
|
||||||
|
|
||||||
File::ensureDirectoryExists(dirname(self::runtimeSettingsPath()));
|
File::ensureDirectoryExists(dirname(self::runtimeSettingsPath()));
|
||||||
File::put(self::runtimeSettingsPath(), json_encode($payload, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
$lock = fopen(self::runtimeSettingsPath().'.lock', 'c');
|
||||||
|
if ($lock === false) {
|
||||||
|
throw new \RuntimeException('Unable to open schedule settings lock.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (! flock($lock, LOCK_EX)) {
|
||||||
|
throw new \RuntimeException('Unable to lock schedule settings.');
|
||||||
|
}
|
||||||
|
$stored = File::exists(self::runtimeSettingsPath())
|
||||||
|
? json_decode(File::get(self::runtimeSettingsPath()), true, 512, JSON_THROW_ON_ERROR)
|
||||||
|
: [];
|
||||||
|
File::replace(self::runtimeSettingsPath(), json_encode(array_replace(is_array($stored) ? $stored : [], $payload), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||||
|
} finally {
|
||||||
|
flock($lock, LOCK_UN);
|
||||||
|
fclose($lock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function register(): void
|
public static function register(): void
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Console\Commands\EtsyMcp;
|
|||||||
use App\Models\EtsyConnection;
|
use App\Models\EtsyConnection;
|
||||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use PHPUnit\Framework\Attributes\TestWith;
|
||||||
use Symfony\Component\Console\Input\ArrayInput;
|
use Symfony\Component\Console\Input\ArrayInput;
|
||||||
use Symfony\Component\Console\Output\BufferedOutput;
|
use Symfony\Component\Console\Output\BufferedOutput;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -130,6 +131,7 @@ class EtsyMcpTest extends TestCase
|
|||||||
Http::assertNothingSent();
|
Http::assertNothingSent();
|
||||||
|
|
||||||
Http::fake([
|
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([
|
'https://api.etsy.com/v3/application/shops/42/listings/99' => Http::response([
|
||||||
'listing_id' => 99, 'state' => 'active', 'shop_id' => 42, 'title' => 'Widget',
|
'listing_id' => 99, 'state' => 'active', 'shop_id' => 42, 'title' => 'Widget',
|
||||||
]),
|
]),
|
||||||
@@ -143,6 +145,68 @@ class EtsyMcpTest extends TestCase
|
|||||||
&& $request['state'] === 'active');
|
&& $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
|
public function test_update_listing_rejects_publishing_without_the_publish_tool(): void
|
||||||
{
|
{
|
||||||
Http::preventStrayRequests();
|
Http::preventStrayRequests();
|
||||||
@@ -202,10 +266,54 @@ class EtsyMcpTest extends TestCase
|
|||||||
&& $request['carrier_name'] === 'evri');
|
&& $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
|
public function test_inventory_update_and_receipt_messages_go_through_the_service(): void
|
||||||
{
|
{
|
||||||
Http::preventStrayRequests();
|
Http::preventStrayRequests();
|
||||||
Http::fake([
|
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) {
|
'https://api.etsy.com/v3/application/listings/99/inventory' => function ($request) {
|
||||||
return $request->method() === 'PUT'
|
return $request->method() === 'PUT'
|
||||||
? Http::response(['products' => [['sku' => 'HAT', 'offerings' => [['quantity' => 4, 'price' => 12.5, 'is_enabled' => true]]]]])
|
? Http::response(['products' => [['sku' => 'HAT', 'offerings' => [['quantity' => 4, 'price' => 12.5, 'is_enabled' => true]]]]])
|
||||||
@@ -271,6 +379,49 @@ class EtsyMcpTest extends TestCase
|
|||||||
$this->assertStringContainsString('reconnect', strtolower($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
|
public function test_wrong_shop_name_is_rejected_without_calling_etsy(): void
|
||||||
{
|
{
|
||||||
Http::preventStrayRequests();
|
Http::preventStrayRequests();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Models\EtsyAppCredential;
|
use App\Models\EtsyAppCredential;
|
||||||
use App\Models\EtsyConnection;
|
use App\Models\EtsyConnection;
|
||||||
|
use App\Services\Etsy;
|
||||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
@@ -22,6 +23,16 @@ class EtsySyncScheduledJobsTest extends TestCase
|
|||||||
Http::assertNothingSent();
|
Http::assertNothingSent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_bundled_refresh_does_not_duplicate_the_independently_scheduled_etsy_sync(): void
|
||||||
|
{
|
||||||
|
$this->connectShop();
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$this->artisan('integrations:refresh')->assertExitCode(0);
|
||||||
|
|
||||||
|
Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'etsy.com'));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_etsy_sync_refreshes_receipts_and_listings(): void
|
public function test_etsy_sync_refreshes_receipts_and_listings(): void
|
||||||
{
|
{
|
||||||
$this->connectShop();
|
$this->connectShop();
|
||||||
@@ -38,6 +49,28 @@ class EtsySyncScheduledJobsTest extends TestCase
|
|||||||
Http::assertSentCount(2);
|
Http::assertSentCount(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_etsy_sync_returns_failure_if_a_required_refresh_fails(): void
|
||||||
|
{
|
||||||
|
$this->connectShop();
|
||||||
|
Http::fake([
|
||||||
|
'*/receipts*' => Http::response([], 503),
|
||||||
|
'*/listings*' => Http::response(['results' => []]),
|
||||||
|
]);
|
||||||
|
$this->artisan('etsy:sync')->assertExitCode(1);
|
||||||
|
Http::assertSentCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_etsy_sync_reports_failure_instead_of_claiming_stale_cache_was_refreshed(): void
|
||||||
|
{
|
||||||
|
$this->connectShop();
|
||||||
|
Http::fake([
|
||||||
|
'*/receipts*' => Http::sequence()->push(['results' => []])->push([], 503),
|
||||||
|
'*/listings*' => Http::sequence()->push(['results' => []])->push([], 503),
|
||||||
|
]);
|
||||||
|
$this->artisan('etsy:sync')->assertExitCode(0);
|
||||||
|
$this->artisan('etsy:sync')->assertExitCode(1);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_etsy_refresh_token_skips_cleanly_when_shop_is_not_connected(): void
|
public function test_etsy_refresh_token_skips_cleanly_when_shop_is_not_connected(): void
|
||||||
{
|
{
|
||||||
Http::fake(fn () => Http::response([], 500));
|
Http::fake(fn () => Http::response([], 500));
|
||||||
@@ -78,6 +111,23 @@ class EtsySyncScheduledJobsTest extends TestCase
|
|||||||
Http::assertNothingSent();
|
Http::assertNothingSent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_refreshes_before_expiry_and_does_not_reuse_a_stale_rotating_token(): void
|
||||||
|
{
|
||||||
|
$this->freezeTime();
|
||||||
|
$connection = $this->connectShop(expiresAt: now()->addMinutes(8));
|
||||||
|
$stale = EtsyConnection::findOrFail($connection->id);
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
Http::fake(['*/oauth/token' => Http::response([
|
||||||
|
'access_token' => 'rotated-access', 'refresh_token' => 'rotated-refresh', 'expires_in' => 3600,
|
||||||
|
])]);
|
||||||
|
|
||||||
|
$etsy = app(Etsy::class);
|
||||||
|
$this->assertSame('rotated-access', $etsy->refreshAccessToken($connection));
|
||||||
|
$this->assertSame('rotated-access', $etsy->refreshAccessToken($stale));
|
||||||
|
Http::assertSentCount(1);
|
||||||
|
$this->assertSame('rotated-refresh', $connection->fresh()->refresh_token);
|
||||||
|
}
|
||||||
|
|
||||||
private function connectShop(?Carbon $expiresAt = null): EtsyConnection
|
private function connectShop(?Carbon $expiresAt = null): EtsyConnection
|
||||||
{
|
{
|
||||||
EtsyAppCredential::create([
|
EtsyAppCredential::create([
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ class ScheduleSettingsTest extends TestCase
|
|||||||
$this->assertFileExists(DashboardSchedule::runtimeSettingsPath());
|
$this->assertFileExists(DashboardSchedule::runtimeSettingsPath());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_partial_settings_updates_preserve_other_jobs(): void
|
||||||
|
{
|
||||||
|
DashboardSchedule::persistRuntimeFrequencies(['alerts_scan' => 'hourly']);
|
||||||
|
DashboardSchedule::persistRuntimeFrequencies(['etsy_sync' => 'disabled']);
|
||||||
|
|
||||||
|
$this->assertSame('hourly', DashboardSchedule::frequencyFor('alerts_scan'));
|
||||||
|
$this->assertSame('disabled', DashboardSchedule::frequencyFor('etsy_sync'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_cannot_save_unknown_jobs(): void
|
||||||
|
{
|
||||||
|
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||||
|
->put(route('settings.schedules.update'), ['schedules' => ['unknown' => 'hourly']])
|
||||||
|
->assertSessionHasErrors('schedules');
|
||||||
|
$this->assertFileDoesNotExist(DashboardSchedule::runtimeSettingsPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_non_admin_cannot_run_jobs(): void
|
||||||
|
{
|
||||||
|
$this->actingAs(User::factory()->create())
|
||||||
|
->post(route('settings.schedules.run', 'etsy_sync'))->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_admin_can_manually_run_a_job(): void
|
public function test_admin_can_manually_run_a_job(): void
|
||||||
{
|
{
|
||||||
$response = $this
|
$response = $this
|
||||||
|
|||||||
@@ -67,7 +67,24 @@ class SchedulerTest extends TestCase
|
|||||||
$this->assertNotNull($sync);
|
$this->assertNotNull($sync);
|
||||||
$this->assertNotNull($tokenRefresh);
|
$this->assertNotNull($tokenRefresh);
|
||||||
$this->assertSame('*/15 * * * *', $sync->expression);
|
$this->assertSame('*/15 * * * *', $sync->expression);
|
||||||
$this->assertSame('*/45 * * * *', $tokenRefresh->expression);
|
$this->assertSame('*/5 * * * *', $tokenRefresh->expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_etsy_stale_overlap_locks_expire_within_five_minutes(): void
|
||||||
|
{
|
||||||
|
$this->freezeTime();
|
||||||
|
$events = collect(app(Schedule::class)->events())
|
||||||
|
->filter(fn ($event): bool => str_contains((string) $event->command, 'etsy:'));
|
||||||
|
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$this->assertTrue($event->mutex->create($event));
|
||||||
|
$this->assertTrue($event->mutex->exists($event));
|
||||||
|
}
|
||||||
|
$this->travel(6)->minutes();
|
||||||
|
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$this->assertFalse($event->mutex->exists($event));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_changing_a_stored_frequency_changes_what_gets_scheduled(): void
|
public function test_changing_a_stored_frequency_changes_what_gets_scheduled(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user