From 10cb922061a410908cff2bb86b944a451afd8a0a Mon Sep 17 00:00:00 2001 From: Marc Gerzimbke Date: Tue, 1 Sep 2026 13:35:07 +0200 Subject: [PATCH 1/2] Sample AI Sandbox version 2 --- local-ai-sandbox/.env | 2 +- local-ai-sandbox/.gitignore | 1 + local-ai-sandbox/README.md | 166 +- .../deployment/cloudformation-template.yml | 15 + local-ai-sandbox/docs/onboarding-new-api.md | 372 + local-ai-sandbox/package-lock.json | 9842 ++++++++++------- local-ai-sandbox/package.json | 20 +- local-ai-sandbox/public/app.js | 2672 +++++ local-ai-sandbox/public/index.html | 1321 +-- local-ai-sandbox/public/styles.css | 1048 ++ .../res/generated/operationRegistry.json | 638 ++ .../res/models/catalogItems_2022-04-01.json | 2449 +--- .../res/models/dataKiosk_2023-11-15.json | 996 ++ .../definitionsProductTypes_2020-09-01.json | 940 ++ ...fbaInventory_v1.json => fbaInventory.json} | 92 +- .../res/models/listingsItems_2021-08-01.json | 332 +- .../listingsRestrictions_2021-08-01.json | 506 + .../res/models/notifications.json | 2150 ++++ .../res/models/orders_2026-01-01.json | 549 +- .../res/models/productPricing_2022-05-01.json | 198 +- .../res/models/reports_2021-06-30.json | 510 +- .../OrderChangeNotification.json | 705 ++ local-ai-sandbox/res/response/invoice.pdf | Bin 0 -> 247007 bytes local-ai-sandbox/res/response/label.png | Bin 0 -> 149788 bytes .../res/scenarios/launch-a-product.json | 322 + local-ai-sandbox/res/triggers.yaml | 74 + .../scripts/config/apiRegistrationConfig.ts | 148 + .../config/notificationSchemasConfig.ts | 9 + local-ai-sandbox/scripts/fetchModels.ts | 108 + .../scripts/fetchNotificationSchemas.ts | 153 + .../scripts/generateOperationRegistry.ts | 122 + .../agentsDefinitionsRegistry.ts | 51 - .../catalogItemsAgentsDefinitions.ts | 80 - ...xtFulfillmentInventoryAgentsDefinitions.ts | 37 - .../extFulfillmentReturnsAgentsDefinitions.ts | 40 - ...xtFulfillmentShipmentsAgentsDefinitions.ts | 163 - .../fbaInventoryAgentsDefinitions.ts | 126 - .../listingsAgentsDefinitions.ts | 165 - .../ordersAgentsDefinitions.ts | 99 - .../pricingAgentsDefinitions.ts | 84 - .../src/controller/dataGeneratorController.ts | 3 + .../notificationsManagementController.ts | 156 + .../controller/ordersManagementController.ts | 86 + .../src/controller/reportsController.ts | 145 - .../src/controller/scenariosController.ts | 140 + .../src/controller/spapiController.ts | 91 +- local-ai-sandbox/src/database/Context.ts | 210 +- .../src/database/DatabaseEngine.ts | 248 + local-ai-sandbox/src/database/types.ts | 80 + local-ai-sandbox/src/index.ts | 125 +- local-ai-sandbox/src/marketplaceIds.ts | 72 + .../src/operation/catalogItemsOperations.ts | 284 + .../src/operation/dataKioskDatasets.ts | 250 + .../src/operation/dataKioskOperations.ts | 319 + .../src/operation/dataKioskQueryParser.ts | 103 + .../extFulfillmentInventoryOperations.ts | 256 + .../extFulfillmentReturnsOperations.ts | 158 + .../extFulfillmentShipmentsOperations.ts | 589 + .../src/operation/fbaInventoryOperations.ts | 96 + .../src/operation/listingsItemModel.ts | 739 ++ .../src/operation/listingsOperations.ts | 393 + .../listingsRestrictionsOperations.ts | 79 + .../operation/listingsValidationPreview.ts | 77 + .../src/operation/notificationsOperations.ts | 510 + .../src/operation/operationTypes.ts | 42 + .../src/operation/ordersOperations.ts | 325 + .../src/operation/passThroughOperations.ts | 124 + .../src/operation/pricingOperations.ts | 275 + .../src/operation/reportsOperations.ts | 232 + .../src/registry/operationRegistry.ts | 340 + local-ai-sandbox/src/service/Paginator.ts | 119 + .../service/apiSchemaIdentificationService.ts | 30 +- .../src/service/reportValidationService.ts | 50 +- .../src/service/telemetryService.ts | 118 - .../src/service/validationEngine.ts | 1265 +++ .../src/tool/callSellingPartnerApiTool.ts | 83 - .../src/tool/databaseInsertionTool.ts | 49 +- .../src/tool/databaseLookupTool.ts | 50 - .../src/tool/databaseRemovalTool.ts | 25 - .../src/tool/resourceRetrievalTool.ts | 14 +- local-ai-sandbox/src/trigger/DataEvent.ts | 11 + .../src/trigger/TriggerProcessor.ts | 32 + .../handlers/processListingSubmission.ts | 158 + .../handlers/reduceInventoryOnOrderPlaced.ts | 84 + .../handlers/sendOrderChangeNotification.ts | 231 + .../src/trigger/triggerRegistry.ts | 95 + .../src/validation/validationRegistry.ts | 1340 +++ .../src/validation/validationTypes.ts | 525 + .../deriveNotificationType.property.test.ts | 60 + .../notificationsManagementController.test.ts | 259 + .../ordersManagement.property.test.ts | 351 + .../ordersManagementController.test.ts | 251 + .../controller/scenariosController.test.ts | 182 + .../sendNotification.property.test.ts | 63 + ...ndNotificationDestination.property.test.ts | 190 + .../spapiController.validation.test.ts | 519 + .../test/database/Context.test.ts | 95 + .../test/database/DatabaseEngine.prop.test.ts | 126 + .../test/database/DatabaseEngine.test.ts | 136 + .../test/database/triggerEmission.test.ts | 109 + .../notificationValidation.property.test.ts | 370 + .../numericValidation.property.test.ts | 100 + .../catalogItemsOperations.prop.test.ts | 94 + .../operation/catalogItemsOperations.test.ts | 202 + .../dataKioskOperations.integration.test.ts | 193 + .../operation/dataKioskOperations.test.ts | 482 + .../extFulfillmentInventoryOperations.test.ts | 587 + ...ulfillmentInventoryOperations.unit.test.ts | 306 + .../extFulfillmentReturnsOperations.test.ts | 375 + ...FulfillmentShipmentsOperations.pbt.test.ts | 660 ++ ...ulfillmentShipmentsOperations.pbt2.test.ts | 462 + ...ulfillmentShipmentsOperations.pbt3.test.ts | 686 ++ .../extFulfillmentShipmentsOperations.test.ts | 559 + ...xtFulfillmentShipmentsRegistration.test.ts | 47 + ...fbaInventoryOperations.integration.test.ts | 262 + .../operation/fbaInventoryOperations.test.ts | 863 ++ .../test/operation/listingsOperations.test.ts | 1118 ++ .../listingsRestrictionsOperations.test.ts | 83 + .../listingsSellerIsolation.prop.test.ts | 134 + .../test/operation/listingsVendorMode.test.ts | 192 + ...ficationsDestinationRoundTrip.prop.test.ts | 83 + .../operation/notificationsOperations.test.ts | 522 + ...ationsSubscriptionDeleteQuery.prop.test.ts | 108 + .../pricingOperations.integration.test.ts | 271 + .../operation/pricingOperations.prop.test.ts | 524 + .../test/operation/pricingOperations.test.ts | 196 + .../test/registry/operationRegistry.test.ts | 115 + .../scripts/apiRegistrationConfig.test.ts | 39 + .../scripts/fetchNotificationSchemas.test.ts | 305 + .../test/scripts/sanitizeModel.test.ts | 70 + .../apiSchemaIdentificationService.test.ts | 91 + .../service/dateComparison.property.test.ts | 422 + ...alidationEngine.bodyExclusion.prop.test.ts | 156 + ...ionEngine.contextConstruction.prop.test.ts | 137 + ...idationEngine.operationPassThrough.test.ts | 296 + ...onEngine.operationPassthrough.prop.test.ts | 222 + .../service/validationEngine.property.test.ts | 2650 +++++ .../validationEngine.schemaStage.prop.test.ts | 243 + .../validationEngine.schemaValidation.test.ts | 294 + ...idationEngine.schemaViolation.prop.test.ts | 134 + .../test/service/validationEngine.test.ts | 217 + ...dationEngine.unrecognizedPath.prop.test.ts | 81 + .../test/tool/databaseLookupTool.test.ts | 116 - .../trigger/processListingSubmission.test.ts | 207 + .../reduceInventoryOnOrderPlaced.test.ts | 77 + .../test/trigger/triggerProcessor.test.ts | 56 + .../validation/dateComparisonHandler.test.ts | 522 + .../extFulfillmentInventoryValidation.test.ts | 33 + .../listingsRequestValidation.test.ts | 246 + .../marketplaceIdValidation.test.ts | 487 + .../ordersDateFormatting.property.test.ts | 40 + .../ordersItemCount.property.test.ts | 84 + .../validation/ordersPrefill.property.test.ts | 103 + .../validation/ordersValidation.prop.test.ts | 120 + .../ordersValidation.property.test.ts | 123 + .../test/validation/ordersValidation.test.ts | 419 + .../test/validation/resolvedEntities.test.ts | 381 + .../test/validation/ruleHandlers.test.ts | 876 ++ .../test/validation/stringLengthLimit.test.ts | 90 + .../validation/validationRegistry.test.ts | 310 + local-ai-sandbox/vitest.config.ts | 4 + 161 files changed, 50303 insertions(+), 9414 deletions(-) create mode 100644 local-ai-sandbox/docs/onboarding-new-api.md create mode 100644 local-ai-sandbox/public/app.js create mode 100644 local-ai-sandbox/public/styles.css create mode 100644 local-ai-sandbox/res/generated/operationRegistry.json create mode 100644 local-ai-sandbox/res/models/dataKiosk_2023-11-15.json create mode 100644 local-ai-sandbox/res/models/definitionsProductTypes_2020-09-01.json rename local-ai-sandbox/res/models/{fbaInventory_v1.json => fbaInventory.json} (96%) create mode 100644 local-ai-sandbox/res/models/listingsRestrictions_2021-08-01.json create mode 100644 local-ai-sandbox/res/models/notifications.json create mode 100644 local-ai-sandbox/res/notification-schemas/OrderChangeNotification.json create mode 100644 local-ai-sandbox/res/response/invoice.pdf create mode 100644 local-ai-sandbox/res/response/label.png create mode 100644 local-ai-sandbox/res/scenarios/launch-a-product.json create mode 100644 local-ai-sandbox/res/triggers.yaml create mode 100644 local-ai-sandbox/scripts/config/apiRegistrationConfig.ts create mode 100644 local-ai-sandbox/scripts/config/notificationSchemasConfig.ts create mode 100644 local-ai-sandbox/scripts/fetchModels.ts create mode 100644 local-ai-sandbox/scripts/fetchNotificationSchemas.ts create mode 100644 local-ai-sandbox/scripts/generateOperationRegistry.ts delete mode 100644 local-ai-sandbox/src/agent-definition/agentsDefinitionsRegistry.ts delete mode 100644 local-ai-sandbox/src/agent-definition/catalogItemsAgentsDefinitions.ts delete mode 100644 local-ai-sandbox/src/agent-definition/extFulfillmentInventoryAgentsDefinitions.ts delete mode 100644 local-ai-sandbox/src/agent-definition/extFulfillmentReturnsAgentsDefinitions.ts delete mode 100644 local-ai-sandbox/src/agent-definition/extFulfillmentShipmentsAgentsDefinitions.ts delete mode 100644 local-ai-sandbox/src/agent-definition/fbaInventoryAgentsDefinitions.ts delete mode 100644 local-ai-sandbox/src/agent-definition/listingsAgentsDefinitions.ts delete mode 100644 local-ai-sandbox/src/agent-definition/ordersAgentsDefinitions.ts delete mode 100644 local-ai-sandbox/src/agent-definition/pricingAgentsDefinitions.ts create mode 100644 local-ai-sandbox/src/controller/notificationsManagementController.ts create mode 100644 local-ai-sandbox/src/controller/ordersManagementController.ts delete mode 100644 local-ai-sandbox/src/controller/reportsController.ts create mode 100644 local-ai-sandbox/src/controller/scenariosController.ts create mode 100644 local-ai-sandbox/src/database/DatabaseEngine.ts create mode 100644 local-ai-sandbox/src/database/types.ts create mode 100644 local-ai-sandbox/src/marketplaceIds.ts create mode 100644 local-ai-sandbox/src/operation/catalogItemsOperations.ts create mode 100644 local-ai-sandbox/src/operation/dataKioskDatasets.ts create mode 100644 local-ai-sandbox/src/operation/dataKioskOperations.ts create mode 100644 local-ai-sandbox/src/operation/dataKioskQueryParser.ts create mode 100644 local-ai-sandbox/src/operation/extFulfillmentInventoryOperations.ts create mode 100644 local-ai-sandbox/src/operation/extFulfillmentReturnsOperations.ts create mode 100644 local-ai-sandbox/src/operation/extFulfillmentShipmentsOperations.ts create mode 100644 local-ai-sandbox/src/operation/fbaInventoryOperations.ts create mode 100644 local-ai-sandbox/src/operation/listingsItemModel.ts create mode 100644 local-ai-sandbox/src/operation/listingsOperations.ts create mode 100644 local-ai-sandbox/src/operation/listingsRestrictionsOperations.ts create mode 100644 local-ai-sandbox/src/operation/listingsValidationPreview.ts create mode 100644 local-ai-sandbox/src/operation/notificationsOperations.ts create mode 100644 local-ai-sandbox/src/operation/operationTypes.ts create mode 100644 local-ai-sandbox/src/operation/ordersOperations.ts create mode 100644 local-ai-sandbox/src/operation/passThroughOperations.ts create mode 100644 local-ai-sandbox/src/operation/pricingOperations.ts create mode 100644 local-ai-sandbox/src/operation/reportsOperations.ts create mode 100644 local-ai-sandbox/src/registry/operationRegistry.ts create mode 100644 local-ai-sandbox/src/service/Paginator.ts delete mode 100644 local-ai-sandbox/src/service/telemetryService.ts create mode 100644 local-ai-sandbox/src/service/validationEngine.ts delete mode 100644 local-ai-sandbox/src/tool/callSellingPartnerApiTool.ts delete mode 100644 local-ai-sandbox/src/tool/databaseLookupTool.ts delete mode 100644 local-ai-sandbox/src/tool/databaseRemovalTool.ts create mode 100644 local-ai-sandbox/src/trigger/DataEvent.ts create mode 100644 local-ai-sandbox/src/trigger/TriggerProcessor.ts create mode 100644 local-ai-sandbox/src/trigger/handlers/processListingSubmission.ts create mode 100644 local-ai-sandbox/src/trigger/handlers/reduceInventoryOnOrderPlaced.ts create mode 100644 local-ai-sandbox/src/trigger/handlers/sendOrderChangeNotification.ts create mode 100644 local-ai-sandbox/src/trigger/triggerRegistry.ts create mode 100644 local-ai-sandbox/src/validation/validationRegistry.ts create mode 100644 local-ai-sandbox/src/validation/validationTypes.ts create mode 100644 local-ai-sandbox/test/controller/deriveNotificationType.property.test.ts create mode 100644 local-ai-sandbox/test/controller/notificationsManagementController.test.ts create mode 100644 local-ai-sandbox/test/controller/ordersManagement.property.test.ts create mode 100644 local-ai-sandbox/test/controller/ordersManagementController.test.ts create mode 100644 local-ai-sandbox/test/controller/scenariosController.test.ts create mode 100644 local-ai-sandbox/test/controller/sendNotification.property.test.ts create mode 100644 local-ai-sandbox/test/controller/sendNotificationDestination.property.test.ts create mode 100644 local-ai-sandbox/test/controller/spapiController.validation.test.ts create mode 100644 local-ai-sandbox/test/database/Context.test.ts create mode 100644 local-ai-sandbox/test/database/DatabaseEngine.prop.test.ts create mode 100644 local-ai-sandbox/test/database/DatabaseEngine.test.ts create mode 100644 local-ai-sandbox/test/database/triggerEmission.test.ts create mode 100644 local-ai-sandbox/test/frontend/notificationValidation.property.test.ts create mode 100644 local-ai-sandbox/test/frontend/numericValidation.property.test.ts create mode 100644 local-ai-sandbox/test/operation/catalogItemsOperations.prop.test.ts create mode 100644 local-ai-sandbox/test/operation/catalogItemsOperations.test.ts create mode 100644 local-ai-sandbox/test/operation/dataKioskOperations.integration.test.ts create mode 100644 local-ai-sandbox/test/operation/dataKioskOperations.test.ts create mode 100644 local-ai-sandbox/test/operation/extFulfillmentInventoryOperations.test.ts create mode 100644 local-ai-sandbox/test/operation/extFulfillmentInventoryOperations.unit.test.ts create mode 100644 local-ai-sandbox/test/operation/extFulfillmentReturnsOperations.test.ts create mode 100644 local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.pbt.test.ts create mode 100644 local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.pbt2.test.ts create mode 100644 local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.pbt3.test.ts create mode 100644 local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.test.ts create mode 100644 local-ai-sandbox/test/operation/extFulfillmentShipmentsRegistration.test.ts create mode 100644 local-ai-sandbox/test/operation/fbaInventoryOperations.integration.test.ts create mode 100644 local-ai-sandbox/test/operation/fbaInventoryOperations.test.ts create mode 100644 local-ai-sandbox/test/operation/listingsOperations.test.ts create mode 100644 local-ai-sandbox/test/operation/listingsRestrictionsOperations.test.ts create mode 100644 local-ai-sandbox/test/operation/listingsSellerIsolation.prop.test.ts create mode 100644 local-ai-sandbox/test/operation/listingsVendorMode.test.ts create mode 100644 local-ai-sandbox/test/operation/notificationsDestinationRoundTrip.prop.test.ts create mode 100644 local-ai-sandbox/test/operation/notificationsOperations.test.ts create mode 100644 local-ai-sandbox/test/operation/notificationsSubscriptionDeleteQuery.prop.test.ts create mode 100644 local-ai-sandbox/test/operation/pricingOperations.integration.test.ts create mode 100644 local-ai-sandbox/test/operation/pricingOperations.prop.test.ts create mode 100644 local-ai-sandbox/test/operation/pricingOperations.test.ts create mode 100644 local-ai-sandbox/test/registry/operationRegistry.test.ts create mode 100644 local-ai-sandbox/test/scripts/apiRegistrationConfig.test.ts create mode 100644 local-ai-sandbox/test/scripts/fetchNotificationSchemas.test.ts create mode 100644 local-ai-sandbox/test/scripts/sanitizeModel.test.ts create mode 100644 local-ai-sandbox/test/service/apiSchemaIdentificationService.test.ts create mode 100644 local-ai-sandbox/test/service/dateComparison.property.test.ts create mode 100644 local-ai-sandbox/test/service/validationEngine.bodyExclusion.prop.test.ts create mode 100644 local-ai-sandbox/test/service/validationEngine.contextConstruction.prop.test.ts create mode 100644 local-ai-sandbox/test/service/validationEngine.operationPassThrough.test.ts create mode 100644 local-ai-sandbox/test/service/validationEngine.operationPassthrough.prop.test.ts create mode 100644 local-ai-sandbox/test/service/validationEngine.property.test.ts create mode 100644 local-ai-sandbox/test/service/validationEngine.schemaStage.prop.test.ts create mode 100644 local-ai-sandbox/test/service/validationEngine.schemaValidation.test.ts create mode 100644 local-ai-sandbox/test/service/validationEngine.schemaViolation.prop.test.ts create mode 100644 local-ai-sandbox/test/service/validationEngine.test.ts create mode 100644 local-ai-sandbox/test/service/validationEngine.unrecognizedPath.prop.test.ts delete mode 100644 local-ai-sandbox/test/tool/databaseLookupTool.test.ts create mode 100644 local-ai-sandbox/test/trigger/processListingSubmission.test.ts create mode 100644 local-ai-sandbox/test/trigger/reduceInventoryOnOrderPlaced.test.ts create mode 100644 local-ai-sandbox/test/trigger/triggerProcessor.test.ts create mode 100644 local-ai-sandbox/test/validation/dateComparisonHandler.test.ts create mode 100644 local-ai-sandbox/test/validation/extFulfillmentInventoryValidation.test.ts create mode 100644 local-ai-sandbox/test/validation/listingsRequestValidation.test.ts create mode 100644 local-ai-sandbox/test/validation/marketplaceIdValidation.test.ts create mode 100644 local-ai-sandbox/test/validation/ordersDateFormatting.property.test.ts create mode 100644 local-ai-sandbox/test/validation/ordersItemCount.property.test.ts create mode 100644 local-ai-sandbox/test/validation/ordersPrefill.property.test.ts create mode 100644 local-ai-sandbox/test/validation/ordersValidation.prop.test.ts create mode 100644 local-ai-sandbox/test/validation/ordersValidation.property.test.ts create mode 100644 local-ai-sandbox/test/validation/ordersValidation.test.ts create mode 100644 local-ai-sandbox/test/validation/resolvedEntities.test.ts create mode 100644 local-ai-sandbox/test/validation/ruleHandlers.test.ts create mode 100644 local-ai-sandbox/test/validation/stringLengthLimit.test.ts create mode 100644 local-ai-sandbox/test/validation/validationRegistry.test.ts diff --git a/local-ai-sandbox/.env b/local-ai-sandbox/.env index 5eb93c914..5dac0396a 100644 --- a/local-ai-sandbox/.env +++ b/local-ai-sandbox/.env @@ -1,3 +1,3 @@ PORT=9001 REGION=NA -SP_API_SANDBOX_TELEMETRY_ENABLED=true \ No newline at end of file +MODE=Seller \ No newline at end of file diff --git a/local-ai-sandbox/.gitignore b/local-ai-sandbox/.gitignore index d58db8709..7446e2965 100644 --- a/local-ai-sandbox/.gitignore +++ b/local-ai-sandbox/.gitignore @@ -6,6 +6,7 @@ # Temporary files *.tmp *~ +build #Mac .DS_Store diff --git a/local-ai-sandbox/README.md b/local-ai-sandbox/README.md index 121547262..87eb5a680 100644 --- a/local-ai-sandbox/README.md +++ b/local-ai-sandbox/README.md @@ -1,6 +1,6 @@ # Sample AI Sandbox for SP-API -Sample AI Sandbox for SP-API is a local development tool for testing SP-API integrations before deploying to production. It validates requests against SP-API OpenAPI schemas, simulates API responses using an AI agent backed by Amazon Bedrock, and provides a Reports API implementation — helping catch integration issues early in the development cycle. +Sample AI Sandbox for SP-API is a local development tool for testing SP-API integrations before deploying to production. It validates requests against SP-API OpenAPI schemas and serves responses from deterministic, local operation handlers backed by an in-process database — helping catch integration issues early in the development cycle. An AI agent backed by Amazon Bedrock powers the Data Generator, which turns natural-language prompts into test data stored in the local database. Watch the following video for a brief introduction to Sample AI Sandbox for SP-API on YouTube: [![Video Thumbnail](docs/demo-thumbnail.png)](https://www.youtube.com/watch?v=DzuGgYYLuEM) @@ -24,16 +24,19 @@ Create a `.env` file in the project root (or edit the existing one): ```dotenv PORT=9001 REGION=NA -SP_API_SANDBOX_TELEMETRY_ENABLED=true +MODE=Seller +DB_MODE=memory ``` | Variable | Description | Default | |----------|-----------------------------------------------------------------|---------| | `PORT` | Port the server listens on | `9001` | | `REGION` | SP-API region (`NA`, `EU`, or `FE`) | `NA` | -| `SP_API_SANDBOX_TELEMETRY_ENABLED` | Enable telemetry (fully anonymized, no personal data collected) | `true` | +| `MODE` | Operating mode (`Seller` or `Vendor`). Mode-restricted operations return `403` when accessed in the wrong mode. | `Seller` | +| `DB_MODE` | Database persistence mode (`memory` or `persistent`) | `memory` | +| `DB_FILE_PATH` | File path used when `DB_MODE=persistent` (required in that mode) | — | -AWS credentials are resolved through the standard AWS SDK credential chain (environment variables, `~/.aws/credentials`, IAM role, etc.). No additional env vars are needed for Bedrock access beyond valid credentials with the appropriate permissions. +AWS credentials are resolved through the standard AWS SDK credential chain (environment variables, `~/.aws/credentials`, IAM role, etc.). No additional env vars are needed for Bedrock access beyond valid credentials with the appropriate permissions. Bedrock is only used by the Data Generator (`POST /chat`); SP-API endpoints are served locally and do not call Bedrock. ## Running @@ -54,70 +57,115 @@ The server starts on `http://localhost:9001` by default. ## Supported SP-API Endpoints -| Method | Path | API | Mode | -|--------|------|-----|------| -| `GET` | `/catalog/2022-04-01/items` | Catalog Items | AI | -| `GET` | `/catalog/2022-04-01/items/{asin}` | Catalog Items | AI | -| `GET` | `/listings/2021-08-01/items/{sellerId}` | Listings Items | AI | -| `GET` | `/listings/2021-08-01/items/{sellerId}/{sku}` | Listings Items | AI | -| `PUT` | `/listings/2021-08-01/items/{sellerId}/{sku}` | Listings Items | AI | -| `PATCH` | `/listings/2021-08-01/items/{sellerId}/{sku}` | Listings Items | AI | -| `DELETE` | `/listings/2021-08-01/items/{sellerId}/{sku}` | Listings Items | AI | -| `GET` | `/orders/2026-01-01/orders` | Orders | AI | -| `GET` | `/orders/2026-01-01/orders/{orderId}` | Orders | AI | -| `POST` | `/orders/v0/orders/{orderId}/shipmentConfirmation` | Orders | AI | -| `POST` | `/externalFulfillment/inventory/2024-09-11/inventories` | External Fulfillment Inventory | AI | -| `GET` | `/externalFulfillment/2024-09-11/returns` | External Fulfillment Returns | AI | -| `GET` | `/externalFulfillment/2024-09-11/returns/{returnId}` | External Fulfillment Returns | AI | -| `GET` | `/externalFulfillment/2024-09-11/shipments` | External Fulfillment Shipments | AI | -| `GET` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}` | External Fulfillment Shipments | AI | -| `POST` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}` | External Fulfillment Shipments | AI | -| `POST` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/packages` | External Fulfillment Shipments | AI | -| `PUT` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/packages/{packageId}` | External Fulfillment Shipments | AI | -| `PATCH` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/packages/{packageId}` | External Fulfillment Shipments | AI | -| `GET` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/shippingOptions` | External Fulfillment Shipments | AI | -| `POST` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/invoice` | External Fulfillment Shipments | AI | -| `GET` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/invoice` | External Fulfillment Shipments | AI | -| `PUT` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/shipLabels` | External Fulfillment Shipments | AI | -| `POST` | `/batches/products/pricing/2022-05-01/offer/featuredOfferExpectedPrice` | Product Pricing | AI | -| `POST` | `/batches/products/pricing/2022-05-01/items/competitiveSummary` | Product Pricing | Pass-through (Production) | -| `GET` | `/definitions/2020-09-01/*` | Product Type Definitions | Pass-through (Production) | -| `GET` | `/listings/2021-08-01/restrictions` | Listings Restrictions | Pass-through (Production) | -| `GET` | `/fba/inventory/v1/summaries` | FBA Inventory | Pass-through (Sandbox) | -| `POST` | `/fba/inventory/v1/items` | FBA Inventory | Pass-through (Sandbox) | -| `POST` | `/fba/inventory/v1/items/inventory` | FBA Inventory | Pass-through (Sandbox) | -| `DELETE` | `/fba/inventory/v1/items/*` | FBA Inventory | Pass-through (Sandbox) | -| `POST` | `/reports/2021-06-30/reports` | Reports | Local | -| `GET` | `/reports/2021-06-30/reports` | Reports | Local | -| `GET` | `/reports/2021-06-30/reports/:reportId` | Reports | Local | -| `DELETE` | `/reports/2021-06-30/reports/:reportId` | Reports | Local | -| `GET` | `/reports/2021-06-30/documents/:reportDocumentId` | Reports | Local | -| `POST` | `/reports/2021-06-30/schedules` | Reports | Local | -| `GET` | `/reports/2021-06-30/schedules` | Reports | Local | -| `GET` | `/reports/2021-06-30/schedules/:reportScheduleId` | Reports | Local | -| `DELETE` | `/reports/2021-06-30/schedules/:reportScheduleId` | Reports | Local | - -**Mode legend:** -- **AI** — Request is validated against the OpenAPI schema and a response is generated by an AI agent (Claude Haiku on Bedrock). -- **Pass-through (Production)** — Request is proxied to the SP-API production endpoint. -- **Pass-through (Sandbox)** — Request is proxied to the SP-API sandbox endpoint. -- **Local** — Handled entirely locally with deterministic logic (no AI, no external calls). +All SP-API endpoints are validated against their bundled OpenAPI model and served by deterministic local handlers, except where noted as pass-through. The **Modes** column indicates which operating modes (`MODE` env var) expose the operation. + +| Method | Path | API | Behavior | Modes | +|--------|------|-----|----------|-------| +| `GET` | `/catalog/2022-04-01/items` | Catalog Items | Local | Seller, Vendor | +| `GET` | `/catalog/2022-04-01/items/{asin}` | Catalog Items | Local | Seller, Vendor | +| `POST` | `/dataKiosk/2023-11-15/queries` | Data Kiosk | Local | Seller, Vendor | +| `GET` | `/dataKiosk/2023-11-15/queries` | Data Kiosk | Local | Seller, Vendor | +| `GET` | `/dataKiosk/2023-11-15/queries/{queryId}` | Data Kiosk | Local | Seller, Vendor | +| `DELETE` | `/dataKiosk/2023-11-15/queries/{queryId}` | Data Kiosk | Local | Seller, Vendor | +| `GET` | `/dataKiosk/2023-11-15/documents/{documentId}` | Data Kiosk | Local | Seller, Vendor | +| `GET` | `/listings/2021-08-01/items/{sellerId}` | Listings Items | Local | Seller, Vendor | +| `GET` | `/listings/2021-08-01/items/{sellerId}/{sku}` | Listings Items | Local | Seller, Vendor | +| `PUT` | `/listings/2021-08-01/items/{sellerId}/{sku}` | Listings Items | Local (validation delegated to Production) | Seller, Vendor | +| `PATCH` | `/listings/2021-08-01/items/{sellerId}/{sku}` | Listings Items | Local | Seller, Vendor | +| `DELETE` | `/listings/2021-08-01/items/{sellerId}/{sku}` | Listings Items | Local | Seller, Vendor | +| `GET` | `/listings/2021-08-01/restrictions` | Listings Restrictions | Local | Seller | +| `GET` | `/orders/2026-01-01/orders` | Orders | Local | Seller | +| `GET` | `/orders/2026-01-01/orders/{orderId}` | Orders | Local | Seller | +| `POST` | `/orders/v0/orders/{orderId}/shipmentConfirmation` | Orders | Local | Seller | +| `POST` | `/externalFulfillment/inventory/2024-09-11/inventories` | External Fulfillment Inventory | Local | Seller | +| `GET` | `/externalFulfillment/2024-09-11/returns` | External Fulfillment Returns | Local | Seller | +| `GET` | `/externalFulfillment/2024-09-11/returns/{returnId}` | External Fulfillment Returns | Local | Seller | +| `GET` | `/externalFulfillment/2024-09-11/shipments` | External Fulfillment Shipments | Local | Seller | +| `GET` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}` | External Fulfillment Shipments | Local | Seller | +| `POST` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}` | External Fulfillment Shipments | Local | Seller | +| `POST` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/packages` | External Fulfillment Shipments | Local | Seller | +| `PUT` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/packages/{packageId}` | External Fulfillment Shipments | Local | Seller | +| `PATCH` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/packages/{packageId}` | External Fulfillment Shipments | Local | Seller | +| `GET` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/shippingOptions` | External Fulfillment Shipments | Local | Seller | +| `POST` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/invoice` | External Fulfillment Shipments | Local | Seller | +| `GET` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/invoice` | External Fulfillment Shipments | Local | Seller | +| `PUT` | `/externalFulfillment/2024-09-11/shipments/{shipmentId}/shipLabels` | External Fulfillment Shipments | Local | Seller | +| `POST` | `/batches/products/pricing/2022-05-01/offer/featuredOfferExpectedPrice` | Product Pricing | Local | Seller | +| `POST` | `/batches/products/pricing/2022-05-01/items/competitiveSummary` | Product Pricing | Pass-through (Production) | Seller | +| `GET` | `/definitions/2020-09-01/productTypes` | Product Type Definitions | Pass-through (Production) | Seller, Vendor | +| `GET` | `/definitions/2020-09-01/productTypes/{productType}` | Product Type Definitions | Pass-through (Production) | Seller, Vendor | +| `GET` | `/fba/inventory/v1/summaries` | FBA Inventory | Local | Seller | +| `GET` | `/notifications/v1/destinations` | Notifications | Local | Seller, Vendor | +| `POST` | `/notifications/v1/destinations` | Notifications | Local | Seller, Vendor | +| `GET` | `/notifications/v1/destinations/{destinationId}` | Notifications | Local | Seller, Vendor | +| `DELETE` | `/notifications/v1/destinations/{destinationId}` | Notifications | Local | Seller, Vendor | +| `GET` | `/notifications/v1/subscriptions` | Notifications | Local | Seller, Vendor | +| `GET` | `/notifications/v1/subscriptions/{notificationType}` | Notifications | Local | Seller, Vendor | +| `POST` | `/notifications/v1/subscriptions/{notificationType}` | Notifications | Local | Seller, Vendor | +| `GET` | `/notifications/v1/subscriptions/{notificationType}/{subscriptionId}` | Notifications | Local | Seller, Vendor | +| `DELETE` | `/notifications/v1/subscriptions/{notificationType}/{subscriptionId}` | Notifications | Local | Seller, Vendor | +| `POST` | `/reports/2021-06-30/reports` | Reports | Local | Seller, Vendor | +| `GET` | `/reports/2021-06-30/reports` | Reports | Local | Seller, Vendor | +| `GET` | `/reports/2021-06-30/reports/:reportId` | Reports | Local | Seller, Vendor | +| `DELETE` | `/reports/2021-06-30/reports/:reportId` | Reports | Local | Seller, Vendor | +| `GET` | `/reports/2021-06-30/documents/:reportDocumentId` | Reports | Local | Seller, Vendor | +| `POST` | `/reports/2021-06-30/schedules` | Reports | Local | Seller, Vendor | +| `GET` | `/reports/2021-06-30/schedules` | Reports | Local | Seller, Vendor | +| `GET` | `/reports/2021-06-30/schedules/:reportScheduleId` | Reports | Local | Seller, Vendor | +| `DELETE` | `/reports/2021-06-30/schedules/:reportScheduleId` | Reports | Local | Seller, Vendor | + +**Behavior legend:** +- **Local** — Request is validated against the OpenAPI schema and handled entirely locally with deterministic logic against the in-memory database (no AI, no external calls). +- **Local (validation delegated to Production)** — Handled locally, but the write is first validated against the real production SP-API before being stored. +- **Pass-through (Production)** — Request is proxied to the SP-API production endpoint (requires a valid `x-amz-access-token` header). + +Report and Data Kiosk documents are downloaded from the generated URLs at `GET /reports/download/:documentId` and `GET /dataKiosk/download/:documentId`. ## Sandbox-Specific Endpoints +These endpoints are not part of SP-API; they help you generate, seed, inspect, and manage sandbox data. + | Method | Path | Description | |--------|------|---------------------------------------------------------------------------------------------------------------------------------------| -| `POST` | `/chat` | Creates test data based on the request prompt. Request format:
`{ "prompt": "Create an order with fulfillment status unshipped"}` | +| `POST` | `/chat` | Creates test data based on the request prompt using the AI Data Generator. Request format:
`{ "prompt": "Create an order with fulfillment status unshipped"}` | | `GET` | `/data` | Returns all data currently stored in the in-memory database | | `DELETE` | `/data` | Clears the in-memory database | +| `GET` | `/scenarios` | Lists the available guided scenarios (pre-seeded, runnable SP-API journeys) defined in `res/scenarios/` | +| `POST` | `/scenarios/:scenarioId/seed` | Seeds a scenario's fixture data into the database | +| `POST` | `/manage/orders` | Creates an order directly in the database (requires `orderId` in the body) | +| `PUT` | `/manage/orders` | Updates an order directly in the database (requires `orderId` in the body) | +| `DELETE` | `/manage/orders/:orderId` | Deletes an order directly from the database | +| `GET` | `/manage/notifications/schemas` | Lists the available notification schemas from `res/notification-schemas/` | +| `POST` | `/manage/notifications/send` | Sends a notification to the SQS destination for a subscription (requires `NotificationType` in the body) | ## How It Works -1. **Request proxying** — Some endpoints (definitions, restrictions, pricing batch, FBA inventory) are proxied directly to the real SP-API production or sandbox backends. -2. **Schema validation** — Incoming requests are validated against the bundled OpenAPI models in `res/models/`. -3. **AI-powered responses** — For supported endpoints, an AI agent (Claude Haiku on Bedrock) generates realistic mock responses that conform to the API schema. -4. **Reports API** — A deterministic, fully local implementation of the SP-API Reports workflow (create, poll, download). -5. **Local database** — Generated data is persisted in a local JSON store (lowdb). View it at `GET /data` or clear it with `DELETE /data`. +1. **Schema validation** — Incoming requests are validated against the bundled OpenAPI models in `res/models/`, and the matching operation is identified via the generated operation registry. +2. **Mode check** — Operations restricted to a specific mode (`Seller`/`Vendor`) return `403` when accessed in the wrong `MODE`. +3. **Local operation handlers** — Most endpoints are served by deterministic local handlers that read from and write to the in-process database. No AI or external calls are involved. +4. **Pass-through proxying** — A few endpoints (Product Type Definitions, Pricing `competitiveSummary`) are proxied directly to the real SP-API production backend, forwarding authentication headers. +5. **AI Data Generator** — `POST /chat` uses an AI agent (Claude Haiku on Bedrock) to turn natural-language prompts into test data, which it writes to the local database via tools. +6. **Local database** — Data is persisted in an in-process document database (LokiJS), partitioned by API domain. View it at `GET /data` or clear it with `DELETE /data`. Set `DB_MODE=persistent` with `DB_FILE_PATH` to persist across restarts. +7. **Event-driven triggers** — Database writes fire declarative triggers defined in `res/triggers.yaml` (e.g., deducting inventory when an order is placed). + +## Onboarding a New API + +Adding a new SP-API endpoint (or a whole new API domain) is mostly registration, not plumbing: + +1. **Add the model & generate the registry** — allowlist the upstream model folder in + `scripts/config/apiRegistrationConfig.ts`, run `npm run models:fetch` to copy and sanitize it + into `res/models/`, then `npm run registry:generate` to regenerate + `res/generated/operationRegistry.json`. +2. **Match the `Api` enum** — add the new `dbNamespace` to the `Api` enum in + `src/database/Context.ts` (the app asserts enum ↔ registry parity at boot). +3. **Choose a behavior template** — per operation, pick either **Pass-through** or **Local (deterministic)**, and register a handler in `src/registry/operationRegistry.ts`. +4. **Add validations & side-effects** — register a validation pipeline in + `src/validation/validationRegistry.ts` (required for every operation; `[]` if there are no + business rules) and declare triggers in `res/triggers.yaml`. +5. **Add deterministic business logic** — implement the operation handler in `src/operation/`. + +See the full, step-by-step guide with code examples in +**[docs/onboarding-new-api.md](docs/onboarding-new-api.md)**. ## Deployment @@ -150,4 +198,4 @@ By default, the application is using Amazon Bedrock and Anthropic Claude Haiku 4 Either way, respective changes must be applied to the default model provider configuration in [modelProvider.ts](src/modelProvider.ts). ## Extend Data Generator for Listings Items API -By default, Data Generator (UI and endpoint) doesn't support Listings Items API out of the box. Product Type Definitions are necessary to generate the respective mock data, which are not publicly available and therefore can't be added to this repository. If you want to add listings generation capabilities, manually download product type definitions via SP-API (e.g. for product type PRODUCT) and add it to the [resource folder](res/pt-definitions). Additionally, uncomment **Api.LISTINGS** in the [resourceRetrievalTools](src/tool/resourceRetrievalTool.ts). \ No newline at end of file +By default, Data Generator (UI and endpoint) doesn't support Listings Items API out of the box. Product Type Definitions are necessary to generate the respective mock data, which are not publicly available and therefore can't be added to this repository. If you want to add listings generation capabilities, manually download product type definitions via SP-API (e.g. for product type PRODUCT) and add it to the [resource folder](res/pt-definitions). Additionally, set **resourcePath** to **./res/pt-definitions/PRODUCT.json** in the [operationRegistry](res/generated/operationRegistry.json). \ No newline at end of file diff --git a/local-ai-sandbox/deployment/cloudformation-template.yml b/local-ai-sandbox/deployment/cloudformation-template.yml index 5f903278e..8fe98d048 100644 --- a/local-ai-sandbox/deployment/cloudformation-template.yml +++ b/local-ai-sandbox/deployment/cloudformation-template.yml @@ -19,6 +19,12 @@ Parameters: Type: String Resources: + AiSandboxQueue: + Type: AWS::SQS::Queue + DeletionPolicy: Delete + Properties: + QueueName: AiSandbox + ElasticBeanstalkApplication: Type: AWS::ElasticBeanstalk::Application DeletionPolicy: Delete @@ -75,6 +81,15 @@ Resources: Resource: - !Sub arn:aws:bedrock:*:${AWS::AccountId}:inference-profile/global.anthropic.claude-haiku-4-5-20251001-v1:0 - 'arn:aws:bedrock:*::foundation-model/anthropic.claude-haiku-4-5-*' + - PolicyName: SqsSendMessagePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - sqs:SendMessage + Resource: + - !GetAtt AiSandboxQueue.Arn InstanceProfile: Type: AWS::IAM::InstanceProfile diff --git a/local-ai-sandbox/docs/onboarding-new-api.md b/local-ai-sandbox/docs/onboarding-new-api.md new file mode 100644 index 000000000..1cebd7a14 --- /dev/null +++ b/local-ai-sandbox/docs/onboarding-new-api.md @@ -0,0 +1,372 @@ +# Onboarding a New API + +This guide walks through every step required to add a new SP-API endpoint (or a whole new API +domain) to the Sample AI Sandbox. It reflects the current, registry-driven architecture: a single +generated **operation registry** is the source of truth for routing/validation facts, and each +operation is wired up through a small set of registries and (optionally) a behavior template. + +> **Mental model.** Adding an API is mostly *data + registration*, not plumbing. You (1) bring the +> OpenAPI model into the repo and regenerate the registry, (2) make the `Api` enum match, (3) pick a +> **behavior template** per operation and register a handler, (4) register a validation pipeline +> (required for every operation — use `[]` when there are no business rules), then optionally +> (5) add deterministic business logic and (6) add side-effects (triggers). + +## Request lifecycle (what happens at runtime) + +Understanding the flow makes each onboarding step obvious. Every unmatched route funnels into +`spapiController.createResponse` (`src/index.ts` → `app.all("/{*splat}", ...)`): + +1. **Schema stage** — `validationEngine.validateRequest` uses `identifyApiModel(path)` (from the + operation registry) to find the model file, then validates the request with `openapi-enforcer`. + It extracts `operationId`, `apiName`, and `apiVersion`. +2. **Pipeline stage** — the composite key `apiName:apiVersion:operationId` is looked up in + `VALIDATION_REGISTRY`. Its ordered `ValidationPipeline` runs; the first failing rule + short-circuits with an error response. Entities resolved here are passed downstream. + **If no pipeline is registered for the key, the request fails with a `NoValidationPipeline` + error before any handler runs** — every operation needs an entry, even an empty one. +3. **Operation handler** — the same composite key is looked up in `OPERATIONS_REGISTRY` + (`src/registry/operationRegistry.ts`). The handler runs deterministic logic and returns an + `OperationContext`. If no handler is registered, the controller returns **501**. +4. **Response behavior** — The controller sends `data.body` (and optional `data.headers`) directly. + +## Key files + +| Concern | File | +|---------|------| +| Registration policy (allowlist, overrides, exclude list) | `scripts/config/apiRegistrationConfig.ts` | +| Copy + sanitize upstream models | `scripts/fetchModels.ts` | +| Generate the operation registry | `scripts/generateOperationRegistry.ts` | +| Generated registry (checked in) | `res/generated/operationRegistry.json` | +| Local OpenAPI specs | `res/models/*.json` | +| DB partitions (`Api` enum) | `src/database/Context.ts` | +| Operation handlers + behavior template registration | `src/registry/operationRegistry.ts`, `src/operation/*` | +| Validation pipelines | `src/validation/validationRegistry.ts`, `src/validation/validationTypes.ts` | +| Triggers (side-effects) | `res/triggers.yaml`, `src/trigger/*` | + +--- + +## Step 1 — Bring the model in and regenerate the registry + +All registration policy lives in `scripts/config/apiRegistrationConfig.ts`. The two scripts +(`fetchModels.ts`, `generateOperationRegistry.ts`) consume it. + +### 1a. Allowlist the upstream model folder + +`fetchModels.ts` does a sparse clone of [`amzn/selling-partner-api-models`](https://github.com/amzn/selling-partner-api-models) +and only copies folders named in `ALLOWLIST`. Add your API's upstream `models/` folder name: + +```ts +// scripts/config/apiRegistrationConfig.ts +export const ALLOWLIST: string[] = [ + // ...existing entries... + "my-new-api-model", // <- upstream folder under models/ in amzn/selling-partner-api-models +]; +``` + +> An **empty** `ALLOWLIST` means "import everything (minus the exclude list)". Until coverage +> approaches full parity, keep the allowlist explicit so we don't import surface we can't handle yet. + +### 1b. Fetch + sanitize the model + +```bash +npm run models:fetch # copies from upstream `main` +# or pin a ref for reproducibility: +tsx scripts/fetchModels.ts --ref v +``` + +`fetchModels.ts` writes sanitized JSON into `res/models/`. Sanitization recursively strips every +`examples` block and `x-amzn-api-sandbox` key (the singular `example` is preserved). + +### 1c. Add metadata overrides *only if needed* + +The generator derives facts from each model automatically: + +- **`apiVersion`** ← `info.version` (e.g. `v0`, `2021-06-30`, `v1`). +- **`apiName`** ← `info.title` with SP-API boilerplate stripped (`deriveApiName`). +- **`dbNamespace`** ← lowerCamel slug of `apiName` (`deriveDbNamespace`). +- **`pathPrefix`** ← longest common static path prefix (used for longest-prefix routing). + +Add an entry to `API_METADATA_OVERRIDES` **only** when a derived value would be wrong: + +```ts +export const API_METADATA_OVERRIDES: Record = { + // ... + "myNewApi_2025-01-01.json": { + apiName: "My New API", // if the title doesn't slugify to the canonical name + dbNamespace: "myNewApi", // must equal a member of the `Api` enum (Step 2) + // resourcePath: "./res/..." // only if the resource-retrieval tool needs a non-model file + }, +}; +``` + +> **`apiName` must match the validation registry.** Whatever `apiName` ends up in the registry is +> the first segment of the composite key `apiName:apiVersion:operationId` used everywhere. + +### 1d. Exclude superseded versions or specific operations + +```ts +export const EXCLUDE_LIST: ExcludeListEntry[] = [ + // whole model/API: + { apiName: "My New API", apiVersion: "v0", reason: "Superseded by 2025-01-01." }, + // or only specific operations (rest of the model is still registered): + { modelFile: "myNewApi_2025-01-01.json", operationIds: ["unsupportedOp"], reason: "Not implemented." }, +]; +``` + +Operations flagged `deprecated: true` in the schema are dropped automatically — no entry needed. + +### 1e. Regenerate and verify + +```bash +npm run registry:generate # rewrites res/generated/operationRegistry.json +# or do 1b + 1e together: +npm run models:sync +``` + +The registry is **checked in**. `npm run registry:check` fails (exit 1) if the committed file is +stale — run it before pushing. Commit the regenerated `res/generated/operationRegistry.json` +alongside your config change. + +--- + +## Step 2 — Make the `Api` enum match the registry + +Every non-excluded model contributes a `dbNamespace` to the registry — **including pass-through +APIs** (e.g. `listingsRestrictions`, `productTypeDefinitions`). The database `Api` enum in +`src/database/Context.ts` must exactly equal the set of registry namespaces, or the app throws at +startup (`assertEnumMatchesRegistry`). + +```ts +// src/database/Context.ts +export enum Api { + // ...existing... + MY_NEW_API = "myNewApi", // value MUST equal the dbNamespace from Step 1 +} +``` + +If you skip this, boot fails fast with a message telling you exactly which namespace is missing from +which side. This is intentional — one guarded edit instead of silent drift. + +--- + +## Step 3 — Choose a behavior template and register the operation handler + +Every operation needs a handler registered in `OPERATIONS_REGISTRY.registerAllHandlers()` +(`src/registry/operationRegistry.ts`). Pick the **behavior template** that fits the operation: + +| Template | When to use | Handler | +|----------|-------------|---------| +| **Pass-through** | The sandbox should proxy to the real SP-API (prod or sandbox backend) | `productionPassThroughHandler` / `sandboxPassThroughHandler` (`src/operation/passThroughOperations.ts`) | +| **Local (deterministic)** | Fully local, deterministic response you build yourself (e.g. Reports) | Your handler in `src/operation/Operations.ts` | + +Register with `buildKey`-style arguments: + +```ts +// src/registry/operationRegistry.ts → registerAllHandlers() +this.register("My New API", "2025-01-01", "getThing", getThingHandler); // local +this.register("My New API", "2025-01-01", "getRemoteThing", productionPassThroughHandler); // pass-through +``` + +- Composite key = `"My New API:2025-01-01:getThing"`. If no handler is registered under it, + `spapiController` returns a bare **501**. Keep `apiName`/`apiVersion`/`operationId` identical to + the generated-registry entry — a mismatch means your handler is registered under a key that no + incoming request will ever produce. + +### Behavior details + +- **Local**: return `data.body` (JSON) and optional `data.headers`. The controller sends them at + `OperationContext.statusCode` verbatim — no AI, no network. +- **Pass-through**: no code to write. The generic handler forwards the method, path, raw query + string, request body (for non-GET/HEAD/DELETE), and the `content-type`, `x-amz-access-token`, and + `user-agent` headers to `https://sellingpartnerapi-.amazon.com` (production) or + `https://sandbox.sellingpartnerapi-.amazon.com` (sandbox), where `` is the + lowercased `REGION` env var (`na`/`eu`/`fe`). + +--- + +## Step 4 — Register a validation pipeline (required for every operation) + +Schema validation (types, required fields, enums) is automatic via `openapi-enforcer` against your +model. Business-rule validation runs afterwards through a `ValidationPipeline` registered under the +composite key in `VALIDATION_REGISTRY` (`src/validation/validationRegistry.ts`). + +**Every operation must have an entry** — the validation engine rejects requests whose key has no +pipeline (`NoValidationPipeline`) before the handler runs. If an operation has no business rules, +register an empty pipeline, as existing ops do: + +```ts +["My New API:2025-01-01:listThings", []], // no business rules — schema validation only +``` + +For operations with business rules, list them in order: + +```ts +export const VALIDATION_REGISTRY = new Map([ + // ... + ["My New API:2025-01-01:getThing", [ + { + checkType: "entityExistence", + entity: { api: Api.MY_NEW_API, paramName: "thingId", paramSource: "path", entityLabel: "thing" }, + failAction: { statusCode: 404, code: "NotFound", message: "Thing not found" }, + }, + { + checkType: "marketplaceIdValidation", + marketplaceIdsParam: { name: "marketplaceIds", source: "query" }, + failAction: { statusCode: 400, code: "InvalidInput", message: "Invalid marketplace ID" }, + }, + ]], +]); +``` + +Rules run in order; the first failure returns its `failAction`. Entities resolved by +`entityExistence` are accumulated and delivered to your operation handler as +`validationResult.resolvedEntities[entityLabel]`. + +### Available rule types + +`entityExistence`, `mutualExclusivity`, `atLeastOneRequired`, `conditionalExclusion`, +`businessRule`, `dateComparison`, `orderItemExistence`, `quantityLimit`, `reportTypeSupported`, +`reportMetaValidation`, `entityFieldCheck`, `reportSchedulable`, `marketplaceIdValidation`. + +See `src/validation/validationTypes.ts` for the exact shape of each (and its Zod schema). Reuse +these before inventing new ones. + +### Adding a brand-new rule type + +If no existing rule fits: + +1. Add the rule interface + its Zod schema in `src/validation/validationTypes.ts`, and add it to the + `ValidationRule` union and `ValidationRuleSchema` discriminated union. +2. Register a handler for the new `checkType` in `src/service/validationEngine.ts` so the engine + knows how to execute it. + +--- + +## Step 5 — Add deterministic business logic + +Implement local/AI handlers in `src/operation/Operations.ts`. A handler is an +`OperationHandler`: `(validationResult, request) => Promise`. + +```ts +// src/operation/myNewApiOperations.ts +import { OperationHandler } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; + +export const getThingHandler: OperationHandler = async (validationResult) => { + const thingId = validationResult.pathParams.thingId; + const thing = Context.instance.engine.get(Api.MY_NEW_API, thingId); + + return { + statusCode: thing ? 200 : 404, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + // Local template → data.body is sent verbatim. + data: thing ? { body: thing } : { error: "Thing not found", thingId }, + }; +}; +``` + +Database access goes through `Context.instance.engine` (LokiJS wrapper): `get`, `find` (LokiJS +query syntax), `put` (upsert), `remove`, `getBatch`. Data is partitioned by the `Api` enum member. +Prefer reusing entities already resolved during validation (`validationResult.resolvedEntities`) +instead of re-reading them. + +Remember to register the handler (Step 3). + +--- + +## Step 6 — Add side-effects (triggers) + +Triggers are deterministic, cross-cutting reactions to database writes (e.g. reduce inventory when +an order is placed). They are declared in `res/triggers.yaml` and implemented as pure handler +functions. + +### 6a. Declare the rule + +```yaml +# res/triggers.yaml +domains: + myNewApi: + - name: Do something on thing created + description: > + When a thing is created, update the related record. + on: + api: myNewApi # must equal the Api enum value + event: [INSERT] # INSERT | UPDATE | DELETE + condition: # optional; JSONPath against the entity + path: "$.status" + equals: "ACTIVE" # supports equals | notEquals | exists + handler: doSomethingOnThingCreated +``` + +### 6b. Implement + register the handler + +```ts +// src/trigger/handlers/doSomethingOnThingCreated.ts +import { DataEvent } from "../DataEvent.js"; +export function doSomethingOnThingCreated(event: DataEvent): void { + const thing = event.entity; + // ...deterministic side-effect using Context.instance.engine... +} +``` + +```ts +// src/trigger/triggerRegistry.ts → handlers map +const handlers: Record void | Promise> = { + reduceInventoryOnOrderPlaced, + doSomethingOnThingCreated, // <- add here +}; +``` + +### How/when triggers fire + +A trigger runs only when a `DataEvent` is **emitted** for its `api` + `event` (and its optional +condition passes): + +- `DatabaseEngine.put()` emits `INSERT` or `UPDATE`, deciding from whether the key already existed. +- `DatabaseEngine.remove()` emits `DELETE`, with the previous entity attached. +- Emission lives in the engine, so **every** write path fires triggers — operation handlers, the + data-generator's `databaseInsertionTool`, and any future caller alike. No handler calls + `TriggerProcessor.emit(...)` itself. +- Emission is deferred one tick, so the originating write returns before any handler runs. +- **A trigger handler that writes must pass `{ silent: true }`.** A silent write emits nothing, and + is the only mechanism preventing a handler from re-entering the trigger chain. + +--- + +## Step 7 — Verify + +```bash +npm run registry:check # generated registry is fresh & committed +npm run type-check # types (incl. Api enum ↔ registry usage) +npm run lint +npm run test:run # unit + property tests +npm run build # tsc production build +npm run dev # boot: registry validate() + enum assertion + handler wiring +``` + +At boot the app calls `validateOperationRegistry()` (non-empty, no duplicate composite keys) and +`assertEnumMatchesRegistry()`. A green boot means your registration is internally consistent. + +Add tests mirroring `src/` under `test/` (e.g. `test/operation/`, `test/validation/`) — property +tests via `fast-check` are used heavily for validation and registry invariants. + +--- + +## Quick checklist + +- [ ] `ALLOWLIST` updated; `npm run models:fetch` copied the model into `res/models/`. +- [ ] `API_METADATA_OVERRIDES` / `EXCLUDE_LIST` adjusted only where derivation is wrong. +- [ ] `npm run registry:generate` run; `res/generated/operationRegistry.json` committed. +- [ ] `Api` enum member added matching the new `dbNamespace`. +- [ ] Behavior template chosen and handler registered in `registerAllHandlers()`. +- [ ] Validation pipeline registered under the composite key for **every** operation (`[]` if no business rules). +- [ ] Deterministic handler implemented in `src/operation/` (local/AI templates). +- [ ] Triggers declared in `res/triggers.yaml` + handler registered (if there are side-effects). +- [ ] `registry:check`, `type-check`, `lint`, `test:run`, `build` all green; app boots. diff --git a/local-ai-sandbox/package-lock.json b/local-ai-sandbox/package-lock.json index a7ac6b4fb..5672ffc78 100644 --- a/local-ai-sandbox/package-lock.json +++ b/local-ai-sandbox/package-lock.json @@ -1,7 +1,7 @@ { "name": "@amzn/sp-api-ai-sandbox", "version": "1.0.0", - "lockfileVersion": 3, + "lockfileVersion": 2, "requires": true, "packages": { "": { @@ -10,26 +10,25 @@ "license": "UNLICENSED", "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.985.0", - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", - "@opentelemetry/resources": "^2.7.0", - "@opentelemetry/sdk-metrics": "^2.6.1", - "@opentelemetry/semantic-conventions": "^1.40.0", + "@aws-sdk/client-sqs": "^3.1104.0", "@strands-agents/sdk": "^1.2.0", "class-validator": "^0.14.3", "express": "^5.2.1", - "lowdb": "^7.0.1", - "openapi-enforcer": "^1.23.0" + "jsonpath-plus": "^10.4.0", + "lokijs": "^1.5.12", + "openapi-enforcer": "^1.23.0", + "yaml": "^2.9.0" }, "devDependencies": { "@eslint/js": "^9.39.2", "@tsconfig/node22": "^22.0.5", "@types/express": "^5.0.6", + "@types/lokijs": "^1.5.14", "@types/node": "^25.2.0", "@vitest/coverage-v8": "^4.0.18", "@vitest/eslint-plugin": "^1.6.6", "eslint": "^9.39.2", - "http-proxy-middleware": "^3.0.7", + "fast-check": "^4.8.0", "prettier": "^3.8.1", "tsx": "^4.22.4", "typescript": "^5.9.3", @@ -37,23 +36,8 @@ "vitest": "^4.0.18" } }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", "license": "Apache-2.0", "dependencies": { @@ -66,47 +50,8 @@ "tslib": "^2.6.2" } }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-crypto/sha256-js": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", "license": "Apache-2.0", "dependencies": { @@ -120,7 +65,6 @@ }, "node_modules/@aws-crypto/supports-web-crypto": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", "license": "Apache-2.0", "dependencies": { @@ -129,7 +73,6 @@ }, "node_modules/@aws-crypto/util": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", "license": "Apache-2.0", "dependencies": { @@ -138,96 +81,44 @@ "tslib": "^2.6.2" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1075.0", + "integrity": "sha512-LDGtNMOxnMz0dw9q+8z0f/X+Soj8OyiYg5zPcqToLh6H9/HHlazogFj7PXqFLOhnvhCqyAvKVAC1ZrL0RX418g==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/credential-provider-node": "^3.972.58", + "@aws-sdk/eventstream-handler-node": "^3.972.22", + "@aws-sdk/middleware-eventstream": "^3.972.18", + "@aws-sdk/middleware-websocket": "^3.972.31", + "@aws-sdk/token-providers": "3.1075.0", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1038.0.tgz", - "integrity": "sha512-oGiqs9v9WzPOdv7PDdm9iPibHgrbDvCDyNg43wFZn2PiiEUisFM+xUP2CRMsj41SmwZPhohmZkXiUu1+MghbAQ==", + "node_modules/@aws-sdk/client-sqs": { + "version": "3.1104.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1104.0.tgz", + "integrity": "sha512-JT/X9bqJQ8yRuQ3E8QMyrewIvBHpllxhpvBnruw3j/702lH4XwgVmyEEa+9axVz2VayPPSmt02jindNg3b5aSw==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", - "@aws-sdk/eventstream-handler-node": "^3.972.14", - "@aws-sdk/middleware-eventstream": "^3.972.10", - "@aws-sdk/middleware-host-header": "^3.972.10", - "@aws-sdk/middleware-logger": "^3.972.10", - "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", - "@aws-sdk/middleware-websocket": "^3.972.16", - "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/token-providers": "3.1038.0", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", - "@smithy/config-resolver": "^4.4.17", - "@smithy/core": "^3.23.17", - "@smithy/eventstream-serde-browser": "^4.2.14", - "@smithy/eventstream-serde-config-resolver": "^4.3.14", - "@smithy/eventstream-serde-node": "^4.2.14", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/hash-node": "^4.2.14", - "@smithy/invalid-dependency": "^4.2.14", - "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.49", - "@smithy/util-defaults-mode-node": "^4.2.54", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", + "@aws-sdk/middleware-sdk-sqs": "^3.972.39", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -235,24 +126,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.6.tgz", - "integrity": "sha512-8Vu7zGxu+39ChR/s5J7nXBw3a2kMHAi0OfKT8ohgTVjX0qYed/8mIfdBb638oBmKrWCwwKjYAM5J/4gMJ8nAJA==", + "version": "3.977.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz", + "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.20", - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", - "@smithy/util-utf8": "^4.2.2", + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { @@ -260,15 +145,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.32", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.32.tgz", - "integrity": "sha512-7vA4GHg8NSmQxquJHSBcSM3RgB4ZaaRi6u4+zGFKOmOH6aqlgr2Sda46clkZDYzlirgfY96w15Zj0jh6PT48ng==", + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz", + "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -276,20 +161,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.34.tgz", - "integrity": "sha512-vBrhWujFCLp1u8ptJRWYlipMutzPptb8pDQ00rKVH9q67T7rGd3VTWIj63aKrlLuY6qSsw1Rt5F/D/7wnNgryA==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz", + "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/types": "^3.973.8", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-stream": "^4.5.25", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -297,24 +179,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.36.tgz", - "integrity": "sha512-FBHyCmV8EB0gUvh1d+CZm87zt2PrdC7OyWexLRoH3I5zWSOUGa+9t58Y5jbxRfwUp3AWpHAFvKY6YzgR845sVA==", + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz", + "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-env": "^3.972.32", - "@aws-sdk/credential-provider-http": "^3.972.34", - "@aws-sdk/credential-provider-login": "^3.972.36", - "@aws-sdk/credential-provider-process": "^3.972.32", - "@aws-sdk/credential-provider-sso": "^3.972.36", - "@aws-sdk/credential-provider-web-identity": "^3.972.36", - "@aws-sdk/nested-clients": "^3.997.4", - "@aws-sdk/types": "^3.973.8", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-login": "^3.972.74", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -322,18 +203,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.36.tgz", - "integrity": "sha512-IFap01lJKxQc0C/OHmZwZQr/cKq0DhrcmKedRrdnnl42D+P0SImnnnWQjv07uIPqpEdtqmkPXb9TiPYTU+prxQ==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz", + "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/nested-clients": "^3.997.4", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -341,22 +220,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.37.tgz", - "integrity": "sha512-/WFixFAAiw8WpmjZcI0l4t3DerXLmVinOIfuotmRZnu2qmsFPoqqmstASz0z8bi1pGdFXzeLzf6bwucM3mZcUQ==", + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz", + "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.32", - "@aws-sdk/credential-provider-http": "^3.972.34", - "@aws-sdk/credential-provider-ini": "^3.972.36", - "@aws-sdk/credential-provider-process": "^3.972.32", - "@aws-sdk/credential-provider-sso": "^3.972.36", - "@aws-sdk/credential-provider-web-identity": "^3.972.36", - "@aws-sdk/types": "^3.973.8", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-ini": "^3.973.12", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -364,16 +242,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.32", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.32.tgz", - "integrity": "sha512-uZp4tlGbpczV8QxmtIwOpSkcyGtBRR8/T4BAumRKfAt1nwCig3FSCZvrKl6ARDIDVRYn5p2oRcAsfFR01EgMGA==", + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz", + "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -381,155 +258,94 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.36.tgz", - "integrity": "sha512-DsLr0UHMyKzRJKe2bjlwU8q1cfoXg8TIJKV/xwvnalAemiZLOZunFzj/whGnFDZIBVLdnbLiwv5SvRf1+CSwkg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/nested-clients": "^3.997.4", - "@aws-sdk/token-providers": "3.1038.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.36.tgz", - "integrity": "sha512-uzrURO7frJhHQVVNR5zBJcCYeMYflmXcWBK1+MiBym2Dfjh6nXATrMixrmGZi+97Q7ETZ+y/4lUwAy0Nfnznjw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/nested-clients": "^3.997.4", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.14.tgz", - "integrity": "sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/eventstream-codec": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.10.tgz", - "integrity": "sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ==", + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz", + "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/token-providers": "3.1103.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", - "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1103.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz", + "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", - "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz", + "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", - "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.22", + "integrity": "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.35.tgz", - "integrity": "sha512-lLppaNTAz+wNgLdi4FtHzrlwrGF0ODTnBWHBaFg85SKs0eJ+M+tP5ifrA8f/0lNd+Ak3MC1NGC6RavV3ny4HTg==", + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.18", + "integrity": "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.36.tgz", - "integrity": "sha512-O2beToxguBvrZFFZ+fFgPbbae8MvyIBjQ6lImee4APHEXXNAD5ZJ2ayLF1mb7rsKw86TM81y5czg82bZncjSjg==", + "node_modules/@aws-sdk/middleware-sdk-sqs": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.39.tgz", + "integrity": "sha512-dlKLmJg1dLQVfFXUPS+f+SqXrRGHDVumY4gjM8sCLGao+zkDXEu8TObTyiaheKjT20ruok9Xs8oLocNItKQ0fw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@smithy/core": "^3.23.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-retry": "^4.3.5", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -537,22 +353,16 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.16.tgz", - "integrity": "sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg==", + "version": "3.972.31", + "integrity": "sha512-ps1rumU1LybSFHaW9dTDgkhCMJLVaedEY78kKSzUDDY+b9974/g6aiaYYA0U9WV0oL4CJCJrVWG+EZ/qr4or7g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-format-url": "^3.972.10", - "@smithy/eventstream-codec": "^4.2.14", - "@smithy/eventstream-serde-browser": "^4.2.14", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -560,65 +370,18 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.4.tgz", - "integrity": "sha512-4Sf+WY1lMJzXlw5MiyCMe/UzdILCwvuaHThbqMXS6dfh9gZy3No360I42RXquOI/ULUOhWy2HCyU0Fp20fQGPQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/middleware-host-header": "^3.972.10", - "@aws-sdk/middleware-logger": "^3.972.10", - "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", - "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.23", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", - "@smithy/config-resolver": "^4.4.17", - "@smithy/core": "^3.23.17", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/hash-node": "^4.2.14", - "@smithy/invalid-dependency": "^4.2.14", - "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.49", - "@smithy/util-defaults-mode-node": "^4.2.54", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", - "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", + "version": "3.997.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", + "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/config-resolver": "^4.4.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -626,16 +389,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.23.tgz", - "integrity": "sha512-wBbys3Y53Ikly556vyADurKpYQHXS7Jjaskbz+Ga9PZCz7PB/9f3VdKbDlz7dqIzn+xwz7L/a6TR4iXcOi8IRw==", + "version": "3.996.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", + "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.35", - "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/types": "^4.14.1", + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -643,17 +404,15 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1038.0.tgz", - "integrity": "sha512-Qniru+9oGGb/HNK/gGZWbV3jsD0k71ngE7qMQ/x6gYNYLd2EOwHCS6E2E6jfkaqO4i0d+nNKmfRy8bNcshKdGQ==", + "version": "3.1075.0", + "integrity": "sha512-SsunyegDXq68TaN5Iut8ElErGIAA6DeuKPKd5/v0lpSmZBI7ZKOC5OALyi1MRHsl/cuO/zHkJL3vKnNHdGaI+Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/nested-clients": "^3.997.4", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -661,55 +420,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", - "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", - "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-endpoints": "^3.4.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.10.tgz", - "integrity": "sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==", + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/querystring-builder": "^4.2.14", - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -717,63 +433,23 @@ } }, "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", - "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.22.tgz", - "integrity": "sha512-YTYqTmOUrwbm1h99Ee4y/mVYpFRl0oSO/amtP5cc1BZZWdaAVWs9zj3TkyRHWvR9aI/ZS8m3mS6awXtYUlWyaw==", + "version": "3.965.8", + "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.36", - "@aws-sdk/types": "^3.973.8", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.21.tgz", - "integrity": "sha512-qxNiHUtlrsjTeSlrPWiFkWps7uD6YB4eKzg7eLAFH8jbiHTlt0ePNlo2Xu+WlftP38JIcMaIX4jTUjOlE2ySWw==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", "license": "Apache-2.0", "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.2", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -781,18 +457,17 @@ } }, "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -800,9 +475,8 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -810,13 +484,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -826,14 +499,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -841,7 +513,6 @@ }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", @@ -851,7 +522,6 @@ }, "node_modules/@emnapi/core": { "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", @@ -863,7 +533,6 @@ }, "node_modules/@emnapi/runtime": { "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", @@ -874,7 +543,6 @@ }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", @@ -885,7 +553,6 @@ }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" @@ -902,7 +569,6 @@ }, "node_modules/@esbuild/android-arm": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" @@ -919,7 +585,6 @@ }, "node_modules/@esbuild/android-arm64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" @@ -936,7 +601,6 @@ }, "node_modules/@esbuild/android-x64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" @@ -953,7 +617,6 @@ }, "node_modules/@esbuild/darwin-arm64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" @@ -970,7 +633,6 @@ }, "node_modules/@esbuild/darwin-x64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" @@ -987,7 +649,6 @@ }, "node_modules/@esbuild/freebsd-arm64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" @@ -1004,7 +665,6 @@ }, "node_modules/@esbuild/freebsd-x64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" @@ -1021,7 +681,6 @@ }, "node_modules/@esbuild/linux-arm": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" @@ -1038,7 +697,6 @@ }, "node_modules/@esbuild/linux-arm64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" @@ -1055,7 +713,6 @@ }, "node_modules/@esbuild/linux-ia32": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" @@ -1072,7 +729,6 @@ }, "node_modules/@esbuild/linux-loong64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" @@ -1089,7 +745,6 @@ }, "node_modules/@esbuild/linux-mips64el": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" @@ -1106,7 +761,6 @@ }, "node_modules/@esbuild/linux-ppc64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" @@ -1123,7 +777,6 @@ }, "node_modules/@esbuild/linux-riscv64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" @@ -1140,7 +793,6 @@ }, "node_modules/@esbuild/linux-s390x": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" @@ -1157,7 +809,6 @@ }, "node_modules/@esbuild/linux-x64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" @@ -1174,7 +825,6 @@ }, "node_modules/@esbuild/netbsd-arm64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" @@ -1191,7 +841,6 @@ }, "node_modules/@esbuild/netbsd-x64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" @@ -1208,7 +857,6 @@ }, "node_modules/@esbuild/openbsd-arm64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" @@ -1225,7 +873,6 @@ }, "node_modules/@esbuild/openbsd-x64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" @@ -1242,7 +889,6 @@ }, "node_modules/@esbuild/openharmony-arm64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" @@ -1259,7 +905,6 @@ }, "node_modules/@esbuild/sunos-x64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" @@ -1276,7 +921,6 @@ }, "node_modules/@esbuild/win32-arm64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" @@ -1293,7 +937,6 @@ }, "node_modules/@esbuild/win32-ia32": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" @@ -1310,7 +953,6 @@ }, "node_modules/@esbuild/win32-x64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" @@ -1327,7 +969,6 @@ }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", @@ -1346,7 +987,6 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", @@ -1356,7 +996,6 @@ }, "node_modules/@eslint/config-array": { "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", @@ -1371,15 +1010,13 @@ }, "node_modules/@eslint/config-array/node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.15", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1389,7 +1026,6 @@ }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", @@ -1402,7 +1038,6 @@ }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", @@ -1415,7 +1050,6 @@ }, "node_modules/@eslint/core": { "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", @@ -1428,7 +1062,6 @@ }, "node_modules/@eslint/eslintrc": { "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", @@ -1452,7 +1085,6 @@ }, "node_modules/@eslint/eslintrc/node_modules/ajv": { "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", @@ -1469,15 +1101,13 @@ }, "node_modules/@eslint/eslintrc/node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.15", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1487,14 +1117,12 @@ }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", @@ -1507,7 +1135,6 @@ }, "node_modules/@eslint/js": { "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", @@ -1520,7 +1147,6 @@ }, "node_modules/@eslint/object-schema": { "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", @@ -1530,7 +1156,6 @@ }, "node_modules/@eslint/plugin-kit": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", @@ -1544,7 +1169,6 @@ }, "node_modules/@hono/node-server": { "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "peer": true, @@ -1557,7 +1181,6 @@ }, "node_modules/@humanfs/core": { "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", @@ -1570,7 +1193,6 @@ }, "node_modules/@humanfs/node": { "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", @@ -1585,7 +1207,6 @@ }, "node_modules/@humanfs/types": { "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, "license": "Apache-2.0", @@ -1595,7 +1216,6 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", @@ -1609,7 +1229,6 @@ }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", @@ -1623,7 +1242,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", @@ -1633,14 +1251,12 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", @@ -1649,9 +1265,32 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "peer": true, @@ -1692,7 +1331,6 @@ }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", @@ -1709,32 +1347,21 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@opentelemetry/api": { "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } }, "node_modules/@opentelemetry/api-logs": { "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/api": "^1.3.0" }, @@ -1744,9 +1371,10 @@ }, "node_modules/@opentelemetry/core": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -1759,9 +1387,10 @@ }, "node_modules/@opentelemetry/exporter-metrics-otlp-http": { "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.214.0.tgz", "integrity": "sha512-Tx/59RmjBgkXJ3qnsD04rpDrVWL53LU/czpgLJh+Ab98nAroe91I7vZ3uGN9mxwPS0jsZEnmqmHygVwB2vRMlA==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", @@ -1778,9 +1407,10 @@ }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/resources": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -1794,9 +1424,10 @@ }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-metrics": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" @@ -1810,9 +1441,10 @@ }, "node_modules/@opentelemetry/otlp-exporter-base": { "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.214.0.tgz", "integrity": "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" @@ -1826,9 +1458,10 @@ }, "node_modules/@opentelemetry/otlp-transformer": { "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.214.0.tgz", "integrity": "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", @@ -1847,9 +1480,10 @@ }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -1863,9 +1497,10 @@ }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-metrics": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" @@ -1878,12 +1513,13 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.0.tgz", - "integrity": "sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==", + "version": "2.8.0", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { - "@opentelemetry/core": "2.7.0", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -1894,10 +1530,11 @@ } }, "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.0.tgz", - "integrity": "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==", + "version": "2.8.0", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -1910,9 +1547,10 @@ }, "node_modules/@opentelemetry/sdk-logs": { "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", @@ -1928,9 +1566,10 @@ }, "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -1943,13 +1582,14 @@ } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.0.tgz", - "integrity": "sha512-Vd7h95av/LYRsAVN7wbprvvJnHkq7swMXAo7Uad0Uxf9jl6NSReLa0JNivrcc5BVIx/vl2t+cgdVQQbnVhsR9w==", + "version": "2.8.0", + "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { - "@opentelemetry/core": "2.7.0", - "@opentelemetry/resources": "2.7.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -1959,10 +1599,11 @@ } }, "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.0.tgz", - "integrity": "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==", + "version": "2.8.0", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -1975,9 +1616,10 @@ }, "node_modules/@opentelemetry/sdk-trace-base": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", @@ -1992,9 +1634,10 @@ }, "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2007,17 +1650,17 @@ } }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", - "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "version": "1.41.1", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", "license": "Apache-2.0", + "optional": true, + "peer": true, "engines": { "node": ">=14" } }, "node_modules/@oxc-project/types": { "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", @@ -2027,71 +1670,72 @@ }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" + "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78=", + "license": "BSD-3-Clause", + "optional": true, + "peer": true }, "node_modules/@protobufjs/base64": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true, + "peer": true }, "node_modules/@protobufjs/codegen": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true, + "peer": true }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" + "version": "1.1.1", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", + "optional": true, + "peer": true, "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", - "license": "BSD-3-Clause" + "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=", + "license": "BSD-3-Clause", + "optional": true, + "peer": true }, "node_modules/@protobufjs/path": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" + "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=", + "license": "BSD-3-Clause", + "optional": true, + "peer": true }, "node_modules/@protobufjs/pool": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" + "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=", + "license": "BSD-3-Clause", + "optional": true, + "peer": true }, "node_modules/@protobufjs/utf8": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true, + "peer": true }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" @@ -2108,7 +1752,6 @@ }, "node_modules/@rolldown/binding-darwin-arm64": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" @@ -2125,7 +1768,6 @@ }, "node_modules/@rolldown/binding-darwin-x64": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" @@ -2142,7 +1784,6 @@ }, "node_modules/@rolldown/binding-freebsd-x64": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" @@ -2159,7 +1800,6 @@ }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" @@ -2176,15 +1816,11 @@ }, "node_modules/@rolldown/binding-linux-arm64-gnu": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2196,15 +1832,11 @@ }, "node_modules/@rolldown/binding-linux-arm64-musl": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2216,15 +1848,11 @@ }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2236,15 +1864,11 @@ }, "node_modules/@rolldown/binding-linux-s390x-gnu": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2256,15 +1880,11 @@ }, "node_modules/@rolldown/binding-linux-x64-gnu": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2276,15 +1896,11 @@ }, "node_modules/@rolldown/binding-linux-x64-musl": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2296,7 +1912,6 @@ }, "node_modules/@rolldown/binding-openharmony-arm64": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" @@ -2313,7 +1928,6 @@ }, "node_modules/@rolldown/binding-wasm32-wasi": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" @@ -2332,7 +1946,6 @@ }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" @@ -2349,7 +1962,6 @@ }, "node_modules/@rolldown/binding-win32-x64-msvc": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" @@ -2366,43 +1978,17 @@ }, "node_modules/@rolldown/pluginutils": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.17", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz", - "integrity": "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/core": { - "version": "3.23.17", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", - "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2410,1771 +1996,5548 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", - "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", - "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.1", - "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz", - "integrity": "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==", + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.14", - "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz", - "integrity": "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==", + "node_modules/@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz", - "integrity": "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==", + "node_modules/@smithy/signature-v4": { + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.14", - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz", - "integrity": "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==", + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.14", - "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.17", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", - "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/querystring-builder": "^4.2.14", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", + "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, - "node_modules/@smithy/hash-node": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz", - "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==", + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.1", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", + "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz", - "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", - "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "node_modules/@strands-agents/sdk": { + "version": "1.6.0", + "integrity": "sha512-tvmDHkgO7oPe8G0c3bmnTiSetb/Iden4DdSh/maRyLqDZj7uLIeS7whwrk654yM7EOYgo6p0/ewEItMbS+7m+A==", "license": "Apache-2.0", "dependencies": { - "tslib": "^2.6.2" + "@aws-sdk/client-bedrock-runtime": "^3.1037.0", + "@types/json-schema": "^7.0.15", + "uuid": "^14.0.0", + "yaml": "^2.8.3" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz", - "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" + "node": ">=20.0.0" }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.32", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz", - "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-middleware": "^4.2.14", - "tslib": "^2.6.2" + "peerDependencies": { + "@a2a-js/sdk": "^0.3.10", + "@ai-sdk/provider": "^3.0.0", + "@anthropic-ai/sdk": "^0.92.0", + "@aws-sdk/client-bedrock-agent": "^3.943.0", + "@aws-sdk/client-bedrock-agent-runtime": "^3.943.0", + "@aws-sdk/client-s3": "^3.943.0", + "@aws/bedrock-token-generator": "^1.1.0", + "@cedar-policy/cedar-wasm": "^4.0.0", + "@cedar-policy/mcp-schema-generator-wasm": "^0.6.0", + "@google/genai": "^1.40.0", + "@modelcontextprotocol/sdk": "^1.25.2", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-metrics": "^2.6.1", + "@opentelemetry/sdk-trace-base": "^2.6.1", + "@opentelemetry/sdk-trace-node": "^2.6.1", + "@smithy/types": "^4.0.0", + "express": "^5.1.0", + "openai": "^6.7.0", + "zod": "^4.1.12" }, - "engines": { - "node": ">=18.0.0" + "peerDependenciesMeta": { + "@a2a-js/sdk": { + "optional": true + }, + "@ai-sdk/provider": { + "optional": true + }, + "@anthropic-ai/sdk": { + "optional": true + }, + "@aws-sdk/client-bedrock-agent": { + "optional": true + }, + "@aws-sdk/client-bedrock-agent-runtime": { + "optional": true + }, + "@aws-sdk/client-s3": { + "optional": true + }, + "@aws/bedrock-token-generator": { + "optional": true + }, + "@cedar-policy/cedar-wasm": { + "optional": true + }, + "@cedar-policy/mcp-schema-generator-wasm": { + "optional": true + }, + "@google/genai": { + "optional": true + }, + "@opentelemetry/exporter-metrics-otlp-http": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-metrics": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/sdk-trace-node": { + "optional": true + }, + "@smithy/types": { + "optional": true + }, + "express": { + "optional": true + }, + "openai": { + "optional": true + } } }, - "node_modules/@smithy/middleware-retry": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.6.tgz", - "integrity": "sha512-5zhmo2AkstmM/RMKYP0NHfmuYWBR+/umlmSuALgajLxf0X0rLE6d17MfzTxpzkILWVhwvCJkCyPH0AfMlbaucQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/service-error-classification": "^4.3.1", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@tsconfig/node22": { + "version": "22.0.5", + "integrity": "sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==", + "dev": true, + "license": "MIT" }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.20", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz", - "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==", - "license": "Apache-2.0", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "tslib": "^2.4.0" } }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", - "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", - "license": "Apache-2.0", + "node_modules/@types/body-parser": { + "version": "1.19.6", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/connect": "*", + "@types/node": "*" } }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.14", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", - "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", - "license": "Apache-2.0", + "node_modules/@types/chai": { + "version": "5.2.3", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/@smithy/node-http-handler": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz", - "integrity": "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==", - "license": "Apache-2.0", + "node_modules/@types/connect": { + "version": "3.4.38", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/querystring-builder": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/node": "*" } }, - "node_modules/@smithy/property-provider": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", - "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", - "license": "Apache-2.0", + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" } }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", - "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", - "license": "Apache-2.0", + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" } }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", - "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", - "license": "Apache-2.0", + "node_modules/@types/http-errors": { + "version": "2.0.5", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/lokijs": { + "version": "1.5.14", + "resolved": "https://registry.npmjs.org/@types/lokijs/-/lokijs-1.5.14.tgz", + "integrity": "sha512-4Fic47BX3Qxr8pd12KT6/T1XWU8dOlJBIp1jGoMbaDbiEvdv50rAii+B3z1b/J2pvMywcVP+DBPGP5/lgLOKGA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "25.9.4", + "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", + "devOptional": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.14.1", - "@smithy/util-uri-escape": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", - "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@types/qs": { + "version": "6.15.1", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" }, - "node_modules/@smithy/service-error-classification": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz", - "integrity": "sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==", - "license": "Apache-2.0", + "node_modules/@types/range-parser": { + "version": "1.2.7", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.14.1" - }, - "engines": { - "node": ">=18.0.0" + "@types/node": "*" } }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", - "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", - "license": "Apache-2.0", + "node_modules/@types/serve-static": { + "version": "2.2.0", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/http-errors": "*", + "@types/node": "*" } }, - "node_modules/@smithy/signature-v4": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", - "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", - "license": "Apache-2.0", + "node_modules/@types/validator": { + "version": "13.15.10", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.1", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@smithy/smithy-client": { - "version": "4.12.13", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.13.tgz", - "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-stream": "^4.5.25", - "tslib": "^2.6.2" - }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">= 4" } }, - "node_modules/@smithy/types": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", - "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", - "license": "Apache-2.0", + "node_modules/@typescript-eslint/parser": { + "version": "8.61.1", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@smithy/url-parser": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", - "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", - "license": "Apache-2.0", + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.1", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/querystring-parser": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", + "debug": "^4.4.3" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@smithy/util-base64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", - "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", - "license": "Apache-2.0", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.1", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", - "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.1", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", - "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", - "license": "Apache-2.0", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.1", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", - "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "tslib": "^2.6.2" - }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.1", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", - "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", - "license": "Apache-2.0", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.1", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.49", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz", - "integrity": "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==", - "license": "Apache-2.0", + "node_modules/@typescript-eslint/utils": { + "version": "8.61.1", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/property-provider": "^4.2.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.54", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz", - "integrity": "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==", - "license": "Apache-2.0", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.1", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/config-resolver": "^4.4.17", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" + "@typescript-eslint/types": "8.61.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@smithy/util-endpoints": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz", - "integrity": "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", - "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", - "license": "Apache-2.0", + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", - "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", - "license": "Apache-2.0", + "node_modules/@vitest/eslint-plugin": { + "version": "1.6.20", + "integrity": "sha512-xRwWHFG0Utp6hXtbGiWk4VdKXCGdExD8kbWrrmFEiG5dk8anOJ+vbWbeOa8EbkocKQRTsx7JAWETccZiBgFp/Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" + "@typescript-eslint/scope-manager": "^8.58.0", + "@typescript-eslint/utils": "^8.58.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "*", + "eslint": ">=8.57.0", + "typescript": ">=5.0.0", + "vitest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vitest": { + "optional": true + } } }, - "node_modules/@smithy/util-retry": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.5.tgz", - "integrity": "sha512-h1IJsbgMDA+jaTjrco/JsyfWOgHRJBv8myB1y4AEI2fjIzD6ktZ7pFAyTw+gwN9GKIAygvC6db0mq0j8N2rFOg==", - "license": "Apache-2.0", + "node_modules/@vitest/expect": { + "version": "4.1.9", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/service-error-classification": "^4.3.1", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@smithy/util-stream": { - "version": "4.5.25", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.25.tgz", - "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==", - "license": "Apache-2.0", + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", - "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", - "license": "Apache-2.0", + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", - "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", - "license": "Apache-2.0", + "node_modules/@vitest/runner": { + "version": "4.1.9", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "tslib": "^2.6.2" + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@smithy/uuid": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", - "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", - "license": "Apache-2.0", + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "node_modules/@vitest/spy": { + "version": "4.1.9", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, - "license": "MIT" + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@strands-agents/sdk": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@strands-agents/sdk/-/sdk-1.2.0.tgz", - "integrity": "sha512-dPetRpgOFs37hwsHnSGZO7fxRHUFwlmtO4vhl/nXRa5A1gwnbvnQNUqdQxu9G+0J5MiRYlOllHZM/WlC3rnNTA==", - "license": "Apache-2.0", + "node_modules/@vitest/utils": { + "version": "4.1.9", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/client-bedrock-runtime": "^3.1037.0", - "@types/json-schema": "^7.0.15", - "uuid": "^14.0.0", - "yaml": "^2.8.3" + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", "peerDependencies": { - "@a2a-js/sdk": "^0.3.10", - "@ai-sdk/provider": "^3.0.0", - "@anthropic-ai/sdk": "^0.92.0", - "@aws-sdk/client-s3": "^3.943.0", - "@google/genai": "^1.40.0", - "@modelcontextprotocol/sdk": "^1.25.2", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", - "@opentelemetry/resources": "^2.6.1", - "@opentelemetry/sdk-metrics": "^2.6.1", - "@opentelemetry/sdk-trace-base": "^2.6.1", - "@opentelemetry/sdk-trace-node": "^2.6.1", - "express": "^5.1.0", - "openai": "^6.7.0", - "zod": "^4.1.12" + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" }, "peerDependenciesMeta": { - "@a2a-js/sdk": { - "optional": true - }, - "@ai-sdk/provider": { - "optional": true - }, - "@anthropic-ai/sdk": { - "optional": true - }, - "@aws-sdk/client-s3": { - "optional": true - }, - "@google/genai": { - "optional": true - }, - "@opentelemetry/exporter-metrics-otlp-http": { - "optional": true - }, - "@opentelemetry/exporter-trace-otlp-http": { - "optional": true - }, - "@opentelemetry/resources": { - "optional": true - }, - "@opentelemetry/sdk-metrics": { - "optional": true - }, - "@opentelemetry/sdk-trace-base": { - "optional": true - }, - "@opentelemetry/sdk-trace-node": { - "optional": true - }, - "express": { - "optional": true - }, - "openai": { + "ajv": { "optional": true } } }, - "node_modules/@tsconfig/node22": { + "node_modules/ansi-styles": { + "version": "4.3.0", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-validator": { + "version": "0.14.4", + "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/drange": { + "version": "1.1.1", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.15", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.26", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.7", + "integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lokijs": { + "version": "1.5.12", + "resolved": "https://registry.npmjs.org/lokijs/-/lokijs-1.5.12.tgz", + "integrity": "sha512-Q5ALD6JiS6xAUWCwX3taQmgwxyveCtIIuL08+ml0nHwT3k0S/GIFJN+Hd38b1qYIMaE5X++iqsqWVksz7SYW+Q==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true, + "peer": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.14", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openapi-enforcer": { + "version": "1.23.0", + "integrity": "sha512-Ja6kvNQ28jvCHpotNZkB129/dBg2IslMDV56aUSl1Dcs+bcd8lGJTq1NatGYsspGpURXvQnFA/q2K8V3AQoO3Q==", + "license": "Apache-2.0", + "dependencies": { + "js-yaml": "^4.1.0", + "randexp": "^0.5.3" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.4", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/protobufjs": { + "version": "7.6.4", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/qs": { + "version": "6.15.2", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/randexp": { + "version": "0.5.3", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "license": "MIT", + "dependencies": { + "drange": "^1.0.2", + "ret": "^0.2.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ret": { + "version": "0.2.2", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/router": { + "version": "2.2.0", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "integrity": "sha1-Gsig2Ug4SNFpXkGLbQMaPDzmjjs=", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.61.1", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uuid": { + "version": "14.0.1", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + }, + "dependencies": { + "@aws-crypto/sha256-browser": { + "version": "5.2.0", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "requires": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "@aws-crypto/sha256-js": { + "version": "5.2.0", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "requires": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "requires": { + "tslib": "^2.6.2" + } + }, + "@aws-crypto/util": { + "version": "5.2.0", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "requires": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/client-bedrock-runtime": { + "version": "3.1075.0", + "integrity": "sha512-LDGtNMOxnMz0dw9q+8z0f/X+Soj8OyiYg5zPcqToLh6H9/HHlazogFj7PXqFLOhnvhCqyAvKVAC1ZrL0RX418g==", + "requires": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/credential-provider-node": "^3.972.58", + "@aws-sdk/eventstream-handler-node": "^3.972.22", + "@aws-sdk/middleware-eventstream": "^3.972.18", + "@aws-sdk/middleware-websocket": "^3.972.31", + "@aws-sdk/token-providers": "3.1075.0", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/client-sqs": { + "version": "3.1104.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1104.0.tgz", + "integrity": "sha512-JT/X9bqJQ8yRuQ3E8QMyrewIvBHpllxhpvBnruw3j/702lH4XwgVmyEEa+9axVz2VayPPSmt02jindNg3b5aSw==", + "requires": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", + "@aws-sdk/middleware-sdk-sqs": "^3.972.39", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/core": { + "version": "3.977.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz", + "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==", + "requires": { + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-env": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz", + "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==", + "requires": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-http": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz", + "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==", + "requires": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-ini": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz", + "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==", + "requires": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-login": "^3.972.74", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-login": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz", + "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==", + "requires": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-node": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz", + "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==", + "requires": { + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-ini": "^3.973.12", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-process": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz", + "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==", + "requires": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-sso": { + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz", + "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==", + "requires": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/token-providers": "3.1103.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "dependencies": { + "@aws-sdk/token-providers": { + "version": "3.1103.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz", + "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==", + "requires": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + } + } + }, + "@aws-sdk/credential-provider-web-identity": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz", + "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==", + "requires": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/eventstream-handler-node": { + "version": "3.972.22", + "integrity": "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==", + "requires": { + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/middleware-eventstream": { + "version": "3.972.18", + "integrity": "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==", + "requires": { + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/middleware-sdk-sqs": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.39.tgz", + "integrity": "sha512-dlKLmJg1dLQVfFXUPS+f+SqXrRGHDVumY4gjM8sCLGao+zkDXEu8TObTyiaheKjT20ruok9Xs8oLocNItKQ0fw==", + "requires": { + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/middleware-websocket": { + "version": "3.972.31", + "integrity": "sha512-ps1rumU1LybSFHaW9dTDgkhCMJLVaedEY78kKSzUDDY+b9974/g6aiaYYA0U9WV0oL4CJCJrVWG+EZ/qr4or7g==", + "requires": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/nested-clients": { + "version": "3.997.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", + "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==", + "requires": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/signature-v4-multi-region": { + "version": "3.996.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", + "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", + "requires": { + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/token-providers": { + "version": "3.1075.0", + "integrity": "sha512-SsunyegDXq68TaN5Iut8ElErGIAA6DeuKPKd5/v0lpSmZBI7ZKOC5OALyi1MRHsl/cuO/zHkJL3vKnNHdGaI+Q==", + "requires": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "requires": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/util-locate-window": { + "version": "3.965.8", + "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", + "requires": { + "tslib": "^2.6.2" + } + }, + "@aws-sdk/xml-builder": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "requires": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==" + }, + "@babel/helper-string-parser": { + "version": "7.29.7", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.29.7", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true + }, + "@babel/parser": { + "version": "7.29.7", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "requires": { + "@babel/types": "^7.29.7" + } + }, + "@babel/types": { + "version": "7.29.7", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + } + }, + "@bcoe/v8-coverage": { + "version": "1.0.2", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true + }, + "@emnapi/core": { + "version": "1.10.0", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "optional": true, + "requires": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "@emnapi/runtime": { + "version": "1.10.0", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@emnapi/wasi-threads": { + "version": "1.2.1", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@esbuild/aix-ppc64": { + "version": "0.28.1", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.28.1", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.28.1", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.28.1", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.28.1", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.28.1", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.28.1", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.28.1", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.28.1", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.28.1", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.28.1", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.28.1", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.28.1", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.28.1", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.28.1", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.28.1", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.28.1", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.28.1", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.28.1", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.28.1", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.28.1", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.28.1", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.28.1", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.28.1", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.28.1", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.28.1", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "dev": true, + "optional": true + }, + "@eslint-community/eslint-utils": { + "version": "4.9.1", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.4.3" + } + }, + "@eslint-community/regexpp": { + "version": "4.12.2", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true + }, + "@eslint/config-array": { + "version": "0.21.2", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "requires": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "dependencies": { + "balanced-match": { + "version": "1.0.2", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.15", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "minimatch": { + "version": "3.1.5", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + } + } + }, + "@eslint/config-helpers": { + "version": "0.4.2", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "requires": { + "@eslint/core": "^0.17.0" + } + }, + "@eslint/core": { + "version": "0.17.0", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.15" + } + }, + "@eslint/eslintrc": { + "version": "3.3.5", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "requires": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "ajv": { + "version": "6.15.0", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "balanced-match": { + "version": "1.0.2", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.15", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "minimatch": { + "version": "3.1.5", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + } + } + }, + "@eslint/js": { + "version": "9.39.4", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true + }, + "@eslint/object-schema": { + "version": "2.1.7", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true + }, + "@eslint/plugin-kit": { + "version": "0.4.1", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "requires": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + } + }, + "@hono/node-server": { + "version": "1.19.14", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "peer": true, + "requires": {} + }, + "@humanfs/core": { + "version": "0.19.2", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "requires": { + "@humanfs/types": "^0.15.0" + } + }, + "@humanfs/node": { + "version": "0.16.8", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "requires": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + } + }, + "@humanfs/types": { + "version": "0.15.0", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true + }, + "@humanwhocodes/module-importer": { + "version": "1.0.1", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true + }, + "@humanwhocodes/retry": { + "version": "0.4.3", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true + }, + "@jridgewell/resolve-uri": { + "version": "3.1.2", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.31", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "requires": {} + }, + "@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "requires": {} + }, + "@modelcontextprotocol/sdk": { + "version": "1.29.0", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "peer": true, + "requires": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + } + }, + "@napi-rs/wasm-runtime": { + "version": "1.1.5", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "optional": true, + "requires": { + "@tybys/wasm-util": "^0.10.2" + } + }, + "@opentelemetry/api": { + "version": "1.9.1", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "peer": true + }, + "@opentelemetry/api-logs": { + "version": "0.214.0", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/api": "^1.3.0" + } + }, + "@opentelemetry/core": { + "version": "2.6.1", + "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/semantic-conventions": "^1.29.0" + } + }, + "@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.214.0", + "integrity": "sha512-Tx/59RmjBgkXJ3qnsD04rpDrVWL53LU/czpgLJh+Ab98nAroe91I7vZ3uGN9mxwPS0jsZEnmqmHygVwB2vRMlA==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-metrics": "2.6.1" + }, + "dependencies": { + "@opentelemetry/resources": { + "version": "2.6.1", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + } + }, + "@opentelemetry/sdk-metrics": { + "version": "2.6.1", + "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1" + } + } + } + }, + "@opentelemetry/otlp-exporter-base": { + "version": "0.214.0", + "integrity": "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-transformer": "0.214.0" + } + }, + "@opentelemetry/otlp-transformer": { + "version": "0.214.0", + "integrity": "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-logs": "0.214.0", + "@opentelemetry/sdk-metrics": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1", + "protobufjs": "^7.0.0" + }, + "dependencies": { + "@opentelemetry/resources": { + "version": "2.6.1", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + } + }, + "@opentelemetry/sdk-metrics": { + "version": "2.6.1", + "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1" + } + } + } + }, + "@opentelemetry/resources": { + "version": "2.8.0", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "dependencies": { + "@opentelemetry/core": { + "version": "2.8.0", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/semantic-conventions": "^1.29.0" + } + } + } + }, + "@opentelemetry/sdk-logs": { + "version": "0.214.0", + "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "dependencies": { + "@opentelemetry/resources": { + "version": "2.6.1", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + } + } + } + }, + "@opentelemetry/sdk-metrics": { + "version": "2.8.0", + "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0" + }, + "dependencies": { + "@opentelemetry/core": { + "version": "2.8.0", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/semantic-conventions": "^1.29.0" + } + } + } + }, + "@opentelemetry/sdk-trace-base": { + "version": "2.6.1", + "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "dependencies": { + "@opentelemetry/resources": { + "version": "2.6.1", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "optional": true, + "peer": true, + "requires": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + } + } + } + }, + "@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "optional": true, + "peer": true + }, + "@oxc-project/types": { + "version": "0.133.0", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true + }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78=", + "optional": true, + "peer": true + }, + "@protobufjs/base64": { + "version": "1.1.2", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "optional": true, + "peer": true + }, + "@protobufjs/codegen": { + "version": "2.0.5", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "optional": true, + "peer": true + }, + "@protobufjs/eventemitter": { + "version": "1.1.1", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "optional": true, + "peer": true + }, + "@protobufjs/fetch": { + "version": "1.1.1", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "optional": true, + "peer": true, + "requires": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=", + "optional": true, + "peer": true + }, + "@protobufjs/path": { + "version": "1.1.2", + "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=", + "optional": true, + "peer": true + }, + "@protobufjs/pool": { + "version": "1.1.0", + "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=", + "optional": true, + "peer": true + }, + "@protobufjs/utf8": { + "version": "1.1.1", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "optional": true, + "peer": true + }, + "@rolldown/binding-android-arm64": { + "version": "1.0.3", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "dev": true, + "optional": true + }, + "@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "dev": true, + "optional": true + }, + "@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "dev": true, + "optional": true + }, + "@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "dev": true, + "optional": true + }, + "@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "dev": true, + "optional": true + }, + "@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "dev": true, + "optional": true, + "requires": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + } + }, + "@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "dev": true, + "optional": true + }, + "@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "dev": true, + "optional": true + }, + "@rolldown/pluginutils": { + "version": "1.0.1", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true + }, + "@smithy/core": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "requires": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@smithy/credential-provider-imds": { + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", + "requires": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@smithy/fetch-http-handler": { + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "requires": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@smithy/is-array-buffer": { + "version": "2.2.0", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "requires": { + "tslib": "^2.6.2" + } + }, + "@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "requires": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@smithy/signature-v4": { + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", + "requires": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + } + }, + "@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "requires": { + "tslib": "^2.6.2" + } + }, + "@smithy/util-buffer-from": { + "version": "2.2.0", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "requires": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + } + }, + "@smithy/util-utf8": { + "version": "2.3.0", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "requires": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + } + }, + "@standard-schema/spec": { + "version": "1.1.0", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, + "@strands-agents/sdk": { + "version": "1.6.0", + "integrity": "sha512-tvmDHkgO7oPe8G0c3bmnTiSetb/Iden4DdSh/maRyLqDZj7uLIeS7whwrk654yM7EOYgo6p0/ewEItMbS+7m+A==", + "requires": { + "@aws-sdk/client-bedrock-runtime": "^3.1037.0", + "@types/json-schema": "^7.0.15", + "uuid": "^14.0.0", + "yaml": "^2.8.3" + } + }, + "@tsconfig/node22": { "version": "22.0.5", - "resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.5.tgz", "integrity": "sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/@tybys/wasm-util": { + "@tybys/wasm-util": { "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, - "license": "MIT", "optional": true, - "dependencies": { + "requires": { "tslib": "^2.4.0" } }, - "node_modules/@types/body-parser": { + "@types/body-parser": { "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@types/connect": "*", "@types/node": "*" } }, - "node_modules/@types/chai": { + "@types/chai": { "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, - "node_modules/@types/connect": { + "@types/connect": { "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@types/node": "*" } }, - "node_modules/@types/deep-eql": { + "@types/deep-eql": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" + "@types/estree": { + "version": "1.0.9", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true }, - "node_modules/@types/express": { + "@types/express": { "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, - "node_modules/@types/express-serve-static-core": { + "@types/express-serve-static-core": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, - "node_modules/@types/http-errors": { + "@types/http-errors": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/http-proxy": { - "version": "1.17.17", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", - "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } + "dev": true }, - "node_modules/@types/json-schema": { + "@types/json-schema": { "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.19.0" + "@types/lokijs": { + "version": "1.5.14", + "resolved": "https://registry.npmjs.org/@types/lokijs/-/lokijs-1.5.14.tgz", + "integrity": "sha512-4Fic47BX3Qxr8pd12KT6/T1XWU8dOlJBIp1jGoMbaDbiEvdv50rAii+B3z1b/J2pvMywcVP+DBPGP5/lgLOKGA==", + "dev": true + }, + "@types/node": { + "version": "25.9.4", + "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", + "devOptional": true, + "requires": { + "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "dev": true, - "license": "MIT" + "@types/qs": { + "version": "6.15.1", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true }, - "node_modules/@types/range-parser": { + "@types/range-parser": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/@types/send": { + "@types/send": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@types/node": "*" } }, - "node_modules/@types/serve-static": { + "@types/serve-static": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@types/http-errors": "*", "@types/node": "*" } }, - "node_modules/@types/validator": { + "@types/validator": { "version": "13.15.10", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", - "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", - "license": "MIT" + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==" }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", - "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "@typescript-eslint/eslint-plugin": { + "version": "8.61.1", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/type-utils": "8.59.1", - "@typescript-eslint/utils": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.59.1", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" + "dependencies": { + "ignore": { + "version": "7.0.5", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true + } } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", - "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "@typescript-eslint/parser": { + "version": "8.61.1", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "requires": { + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", - "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "@typescript-eslint/project-service": { + "version": "8.61.1", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.1", - "@typescript-eslint/types": "^8.59.1", + "requires": { + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", - "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "@typescript-eslint/scope-manager": { + "version": "8.61.1", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "requires": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", - "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "@typescript-eslint/tsconfig-utils": { + "version": "8.61.1", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } + "requires": {} }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", - "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "@typescript-eslint/type-utils": { + "version": "8.61.1", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1", + "requires": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", - "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } + "@typescript-eslint/types": { + "version": "8.61.1", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "dev": true }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", - "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "@typescript-eslint/typescript-estree": { + "version": "8.61.1", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.59.1", - "@typescript-eslint/tsconfig-utils": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "requires": { + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", - "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "@typescript-eslint/utils": { + "version": "8.61.1", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", - "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "@typescript-eslint/visitor-keys": { + "version": "8.61.1", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.59.1", + "requires": { + "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "dependencies": { + "eslint-visitor-keys": { + "version": "5.0.1", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true + } } }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "@vitest/coverage-v8": { + "version": "4.1.9", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" } }, - "node_modules/@vitest/eslint-plugin": { - "version": "1.6.16", - "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.16.tgz", - "integrity": "sha512-2pBN1F1JXq6zTSaYC58CMJa7pGxXIRsLfOioeZM4cPE3pRdSh1ySTSoHPQlOTEF5WgoVzWZQxhGQ3ygT78hOVg==", + "@vitest/eslint-plugin": { + "version": "1.6.20", + "integrity": "sha512-xRwWHFG0Utp6hXtbGiWk4VdKXCGdExD8kbWrrmFEiG5dk8anOJ+vbWbeOa8EbkocKQRTsx7JAWETccZiBgFp/Q==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@typescript-eslint/scope-manager": "^8.58.0", "@typescript-eslint/utils": "^8.58.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "*", - "eslint": ">=8.57.0", - "typescript": ">=5.0.0", - "vitest": "*" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - }, - "typescript": { - "optional": true - }, - "vitest": { - "optional": true - } } }, - "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "@vitest/expect": { + "version": "4.1.9", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "@vitest/mocker": { + "version": "4.1.9", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.5", + "requires": { + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "@vitest/pretty-format": { + "version": "4.1.9", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "@vitest/runner": { + "version": "4.1.9", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.5", + "requires": { + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "@vitest/snapshot": { + "version": "4.1.9", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "requires": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } + "@vitest/spy": { + "version": "4.1.9", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true }, - "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "@vitest/utils": { + "version": "4.1.9", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", + "requires": { + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" } }, - "node_modules/accepts": { + "accepts": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { + "requires": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } + "acorn": { + "version": "8.17.0", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true }, - "node_modules/acorn-jsx": { + "acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } + "requires": {} }, - "node_modules/ajv": { + "ajv": { "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", "peer": true, - "dependencies": { + "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { + "ajv-formats": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { + "requires": { "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } } }, - "node_modules/ansi-styles": { + "ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argparse": { + "argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, - "node_modules/assertion-error": { + "assertion-error": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } + "dev": true }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "ast-v8-to-istanbul": { + "version": "1.0.4", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, - "node_modules/balanced-match": { + "balanced-match": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } + "dev": true }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { + "body-parser": { + "version": "2.3.0", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "requires": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "dependencies": { + "content-type": { + "version": "2.0.0", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==" + } } }, - "node_modules/bowser": { + "bowser": { "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "brace-expansion": { + "version": "5.0.6", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" } }, - "node_modules/bytes": { + "bytes": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" }, - "node_modules/call-bind-apply-helpers": { + "call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { + "requires": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" } }, - "node_modules/call-bound": { + "call-bound": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { + "requires": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { + "callsites": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "dev": true }, - "node_modules/chai": { + "chai": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } + "dev": true }, - "node_modules/chalk": { + "chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/class-validator": { + "class-validator": { "version": "0.14.4", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", - "license": "MIT", - "dependencies": { + "requires": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", "validator": "^13.15.22" } }, - "node_modules/color-convert": { + "color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" } }, - "node_modules/color-name": { + "color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/concat-map": { + "concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true }, - "node_modules/content-disposition": { + "content-disposition": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==" }, - "node_modules/content-type": { + "content-type": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" }, - "node_modules/convert-source-map": { + "convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/cookie": { + "cookie": { "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" }, - "node_modules/cookie-signature": { + "cookie-signature": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" }, - "node_modules/cors": { + "cors": { "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", "peer": true, - "dependencies": { + "requires": { "object-assign": "^4", "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/cross-spawn": { + "cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { + "requires": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" } }, - "node_modules/debug": { + "debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { + "requires": { "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } } }, - "node_modules/deep-is": { + "deep-is": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/depd": { + "depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" }, - "node_modules/detect-libc": { + "detect-libc": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } + "dev": true }, - "node_modules/drange": { + "drange": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", - "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", - "license": "MIT", - "engines": { - "node": ">=4" - } + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==" }, - "node_modules/dunder-proto": { + "dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { + "requires": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" } }, - "node_modules/ee-first": { + "ee-first": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, - "node_modules/encodeurl": { + "encodeurl": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, - "node_modules/es-define-property": { + "es-define-property": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" }, - "node_modules/es-errors": { + "es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" }, - "node_modules/es-module-lexer": { + "es-module-lexer": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { + "es-object-atoms": { + "version": "1.1.2", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "requires": { "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" } }, - "node_modules/esbuild": { + "esbuild": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { + "requires": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", @@ -4203,32 +7566,20 @@ "@esbuild/win32-x64": "0.28.1" } }, - "node_modules/escape-html": { + "escape-html": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" }, - "node_modules/escape-string-regexp": { + "escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "dev": true }, - "node_modules/eslint": { + "eslint": { "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", @@ -4264,264 +7615,143 @@ "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true + "dependencies": { + "ajv": { + "version": "6.15.0", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "balanced-match": { + "version": "1.0.2", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.15", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "eslint-visitor-keys": { + "version": "4.2.1", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "minimatch": { + "version": "3.1.5", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } } } }, - "node_modules/eslint-scope": { + "eslint-scope": { "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { + "requires": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-visitor-keys": { + "eslint-visitor-keys": { "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } + "dev": true }, - "node_modules/espree": { + "espree": { "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { + "requires": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "dependencies": { + "eslint-visitor-keys": { + "version": "4.2.1", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true + } } }, - "node_modules/esquery": { + "esquery": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { + "requires": { "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" } }, - "node_modules/esrecurse": { + "esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { + "requires": { "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" } }, - "node_modules/estraverse": { + "estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } + "dev": true }, - "node_modules/estree-walker": { + "estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@types/estree": "^1.0.0" } }, - "node_modules/esutils": { + "esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } + "dev": true }, - "node_modules/etag": { + "etag": { "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, - "node_modules/eventsource": { + "eventsource": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", "peer": true, - "dependencies": { + "requires": { "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" } }, - "node_modules/eventsource-parser": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", - "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.0.0" - } + "eventsource-parser": { + "version": "3.1.0", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "peer": true }, - "node_modules/expect-type": { + "expect-type": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } + "dev": true }, - "node_modules/express": { + "express": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { + "requires": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", @@ -4550,261 +7780,109 @@ "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/express-rate-limit": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", - "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", - "license": "MIT", + "express-rate-limit": { + "version": "8.5.2", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "peer": true, - "dependencies": { - "ip-address": "10.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" + "requires": { + "ip-address": "^10.2.0" } }, - "node_modules/fast-deep-equal": { + "fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "dev": true, + "requires": { + "pure-rand": "^8.0.0" + } + }, + "fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, - "node_modules/fast-json-stable-stringify": { + "fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/fast-levenshtein": { + "fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", + "fast-uri": { + "version": "3.1.2", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "peer": true }, - "node_modules/fast-xml-builder": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", - "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.1.3" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", - "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/file-entry-cache": { + "file-entry-cache": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" } }, - "node_modules/finalhandler": { + "finalhandler": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { + "requires": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/find-up": { + "find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flat-cache": { + "flat-cache": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "flatted": "^3.2.9", "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" } }, - "node_modules/flatted": { + "flatted": { "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } + "dev": true }, - "node_modules/forwarded": { + "forwarded": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" }, - "node_modules/fresh": { + "fresh": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" }, - "node_modules/fsevents": { + "fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } + "optional": true }, - "node_modules/function-bind": { + "function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, - "node_modules/get-intrinsic": { + "get-intrinsic": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { + "requires": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", @@ -4815,455 +7893,233 @@ "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-proto": { + "get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { + "requires": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" } }, - "node_modules/glob-parent": { + "glob-parent": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "ISC", - "dependencies": { + "requires": { "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" } }, - "node_modules/globals": { + "globals": { "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "dev": true }, - "node_modules/gopd": { + "gopd": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" }, - "node_modules/has-flag": { + "has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "dev": true }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "license": "MIT", - "dependencies": { + "has-symbols": { + "version": "1.1.0", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "hasown": { + "version": "2.0.4", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "requires": { "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" } }, - "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" - } + "hono": { + "version": "4.12.26", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", + "peer": true }, - "node_modules/html-escaper": { + "html-escaper": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/http-errors": { + "http-errors": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { + "requires": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-middleware": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.7.tgz", - "integrity": "sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-proxy": "^1.17.15", - "debug": "^4.3.6", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.3", - "is-plain-object": "^5.0.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": "^14.18.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/iconv-lite": { + "iconv-lite": { "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { + "requires": { "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/ignore": { + "ignore": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } + "dev": true }, - "node_modules/import-fresh": { + "import-fresh": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/imurmurhash": { + "imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true }, - "node_modules/inherits": { + "inherits": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 12" - } + "ip-address": { + "version": "10.2.0", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "peer": true }, - "node_modules/ipaddr.js": { + "ipaddr.js": { "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" }, - "node_modules/is-extglob": { + "is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true }, - "node_modules/is-glob": { + "is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" } }, - "node_modules/is-promise": { + "is-promise": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, - "node_modules/isexe": { + "isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, - "node_modules/istanbul-lib-coverage": { + "istanbul-lib-coverage": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } + "dev": true }, - "node_modules/istanbul-lib-report": { + "istanbul-lib-report": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { + "requires": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" } }, - "node_modules/istanbul-reports": { + "istanbul-reports": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { + "requires": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" } }, - "node_modules/jose": { + "jose": { "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } + "peer": true }, - "node_modules/js-tokens": { + "js-tokens": { "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { + "js-yaml": { + "version": "4.2.0", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "requires": { "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" } }, - "node_modules/json-buffer": { + "jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==" + }, + "json-buffer": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/json-schema-traverse": { + "json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", "peer": true }, - "node_modules/json-schema-typed": { + "json-schema-typed": { "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause", "peer": true }, - "node_modules/json-stable-stringify-without-jsonify": { + "json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true }, - "node_modules/keyv": { + "jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "requires": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + } + }, + "keyv": { "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "json-buffer": "3.0.1" } }, - "node_modules/levn": { + "levn": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" } }, - "node_modules/libphonenumber-js": { - "version": "1.12.42", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.42.tgz", - "integrity": "sha512-oKQFPTibqQwZZkChCDVMFVJXMZdyJNqDWZWYNn8BgyAaK/6yFJEowxCY0RVFirRyWP63hMRuKlkSEd9qlvbWXg==", - "license": "MIT" + "libphonenumber-js": { + "version": "1.13.7", + "integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==" }, - "node_modules/lightningcss": { + "lightningcss": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { + "requires": { + "detect-libc": "^2.0.3", "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", @@ -5277,869 +8133,383 @@ "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/lightningcss-android-arm64": { + "lightningcss-android-arm64": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "optional": true }, - "node_modules/lightningcss-darwin-x64": { + "lightningcss-darwin-arm64": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "dev": true, + "optional": true }, - "node_modules/lightningcss-freebsd-x64": { + "lightningcss-darwin-x64": { + "version": "1.32.0", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "dev": true, + "optional": true + }, + "lightningcss-freebsd-x64": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "optional": true }, - "node_modules/lightningcss-linux-arm-gnueabihf": { + "lightningcss-linux-arm-gnueabihf": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "optional": true }, - "node_modules/lightningcss-linux-arm64-gnu": { + "lightningcss-linux-arm64-gnu": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "optional": true }, - "node_modules/lightningcss-linux-arm64-musl": { + "lightningcss-linux-arm64-musl": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "optional": true }, - "node_modules/lightningcss-linux-x64-gnu": { + "lightningcss-linux-x64-gnu": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "optional": true }, - "node_modules/lightningcss-linux-x64-musl": { + "lightningcss-linux-x64-musl": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "optional": true }, - "node_modules/lightningcss-win32-arm64-msvc": { + "lightningcss-win32-arm64-msvc": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "optional": true }, - "node_modules/lightningcss-win32-x64-msvc": { + "lightningcss-win32-x64-msvc": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "optional": true }, - "node_modules/locate-path": { + "locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { + "lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/long": { + "lokijs": { + "version": "1.5.12", + "resolved": "https://registry.npmjs.org/lokijs/-/lokijs-1.5.12.tgz", + "integrity": "sha512-Q5ALD6JiS6xAUWCwX3taQmgwxyveCtIIuL08+ml0nHwT3k0S/GIFJN+Hd38b1qYIMaE5X++iqsqWVksz7SYW+Q==" + }, + "long": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lowdb": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz", - "integrity": "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==", - "license": "MIT", - "dependencies": { - "steno": "^4.0.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } + "optional": true, + "peer": true }, - "node_modules/magic-string": { + "magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "magicast": { + "version": "0.5.3", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", + "requires": { + "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, - "node_modules/make-dir": { + "make-dir": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/math-intrinsics": { + "math-intrinsics": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" }, - "node_modules/media-typer": { + "media-typer": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" }, - "node_modules/merge-descriptors": { + "merge-descriptors": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" }, - "node_modules/mime-db": { + "mime-db": { "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" }, - "node_modules/mime-types": { + "mime-types": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { + "requires": { "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/minimatch": { + "minimatch": { "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { + "requires": { "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/ms": { + "ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } + "nanoid": { + "version": "3.3.14", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "dev": true }, - "node_modules/natural-compare": { + "natural-compare": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "negotiator": { + "version": "1.0.0", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" }, - "node_modules/object-assign": { + "object-assign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "peer": true }, - "node_modules/object-inspect": { + "object-inspect": { "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" + "obug": { + "version": "2.1.3", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true }, - "node_modules/on-finished": { + "on-finished": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { + "requires": { "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" } }, - "node_modules/once": { + "once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { "wrappy": "1" } }, - "node_modules/openapi-enforcer": { + "openapi-enforcer": { "version": "1.23.0", - "resolved": "https://registry.npmjs.org/openapi-enforcer/-/openapi-enforcer-1.23.0.tgz", "integrity": "sha512-Ja6kvNQ28jvCHpotNZkB129/dBg2IslMDV56aUSl1Dcs+bcd8lGJTq1NatGYsspGpURXvQnFA/q2K8V3AQoO3Q==", - "license": "Apache-2.0", - "dependencies": { + "requires": { "js-yaml": "^4.1.0", "randexp": "^0.5.3" } }, - "node_modules/optionator": { + "optionator": { "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" } }, - "node_modules/p-limit": { + "p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-locate": { + "p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { + "parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" } }, - "node_modules/parseurl": { + "parseurl": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, - "node_modules/path-exists": { + "path-exists": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "dev": true }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/path-key": { + "path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" }, - "node_modules/path-to-regexp": { + "path-to-regexp": { "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" }, - "node_modules/pathe": { + "pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/picocolors": { + "picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "dev": true }, - "node_modules/pkce-challenge": { + "pkce-challenge": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.20.0" - } + "peer": true }, - "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "postcss": { + "version": "8.5.15", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", + "requires": { + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" } }, - "node_modules/prelude-ls": { + "prelude-ls": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } + "dev": true }, - "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } + "prettier": { + "version": "3.8.4", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true }, - "node_modules/protobufjs": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", - "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { + "protobufjs": { + "version": "7.6.4", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "optional": true, + "peer": true, + "requires": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" + "long": "^5.3.2" } }, - "node_modules/proxy-addr": { + "proxy-addr": { "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { + "requires": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" } }, - "node_modules/punycode": { + "punycode": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "dev": true }, - "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "license": "BSD-3-Clause", - "dependencies": { + "pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true + }, + "qs": { + "version": "6.15.2", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "requires": { "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/randexp": { + "randexp": { "version": "0.5.3", - "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", - "license": "MIT", - "dependencies": { + "requires": { "drange": "^1.0.2", "ret": "^0.2.0" - }, - "engines": { - "node": ">=4" } }, - "node_modules/range-parser": { + "range-parser": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" }, - "node_modules/raw-body": { + "raw-body": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { + "requires": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" } }, - "node_modules/require-from-string": { + "require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" + "peer": true }, - "node_modules/resolve-from": { + "resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } + "dev": true }, - "node_modules/ret": { + "ret": { "version": "0.2.2", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", - "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==" }, - "node_modules/rolldown": { + "rolldown": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@oxc-project/types": "=0.133.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", @@ -6154,50 +8524,34 @@ "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-win32-x64-msvc": "1.0.3", + "@rolldown/pluginutils": "^1.0.0" } }, - "node_modules/router": { + "router": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { + "requires": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" + "path-to-regexp": "^8.0.0" } }, - "node_modules/safer-buffer": { + "safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } + "semver": { + "version": "7.8.5", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true }, - "node_modules/send": { + "send": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { + "requires": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -6209,584 +8563,270 @@ "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/serve-static": { + "serve-static": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { + "requires": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/setprototypeof": { + "setprototypeof": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, - "node_modules/shebang-command": { + "shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { + "requires": { "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" } }, - "node_modules/shebang-regex": { + "shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { + "side-channel": { + "version": "1.1.1", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "requires": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-list": { + "side-channel-list": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { + "requires": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-map": { + "side-channel-map": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { + "requires": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-weakmap": { + "side-channel-weakmap": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { + "requires": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { + "siginfo": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" + "dev": true }, - "node_modules/source-map-js": { + "source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } + "dev": true }, - "node_modules/stackback": { + "stackback": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" + "integrity": "sha1-Gsig2Ug4SNFpXkGLbQMaPDzmjjs=", + "dev": true }, - "node_modules/statuses": { + "statuses": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" }, - "node_modules/std-env": { + "std-env": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/steno": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz", - "integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } + "dev": true }, - "node_modules/strip-json-comments": { + "strip-json-comments": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strnum": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", - "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" + "dev": true }, - "node_modules/supports-color": { + "supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" } }, - "node_modules/tinybench": { + "tinybench": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } + "tinyexec": { + "version": "1.2.4", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true }, - "node_modules/tinyglobby": { + "tinyglobby": { "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "fdir": "^6.5.0", "picomatch": "^4.0.4" }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { + "dependencies": { + "fdir": { + "version": "6.5.0", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, "picomatch": { - "optional": true + "version": "4.0.4", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinyrainbow": { + "tinyrainbow": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } + "dev": true }, - "node_modules/toidentifier": { + "toidentifier": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, - "node_modules/ts-api-utils": { + "ts-api-utils": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } + "requires": {} }, - "node_modules/tslib": { + "tslib": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "node_modules/tsx": { + "tsx": { "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { + "requires": { + "esbuild": "~0.28.0", "fsevents": "~2.3.3" } }, - "node_modules/type-check": { + "type-check": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" } }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", + "type-is": { + "version": "2.1.0", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "requires": { + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, - "engines": { - "node": ">= 0.6" + "dependencies": { + "content-type": { + "version": "2.0.0", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==" + } } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } + "typescript": { + "version": "5.9.3", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true }, - "node_modules/typescript-eslint": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", - "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "typescript-eslint": { + "version": "8.61.1", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.1", - "@typescript-eslint/parser": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "requires": { + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" } }, - "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "license": "MIT" + "undici-types": { + "version": "7.24.6", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "devOptional": true }, - "node_modules/unpipe": { + "unpipe": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" }, - "node_modules/uri-js": { + "uri-js": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { + "requires": { "punycode": "^2.1.0" } }, - "node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } + "uuid": { + "version": "14.0.1", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==" }, - "node_modules/validator": { + "validator": { "version": "13.15.35", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", - "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==" }, - "node_modules/vary": { + "vary": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, - "node_modules/vite": { + "vite": { "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { + "fsevents": "~2.3.3", "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true + "dependencies": { + "picomatch": { + "version": "4.0.4", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "vitest": { + "version": "4.1.9", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "requires": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6801,176 +8841,58 @@ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false + "dependencies": { + "picomatch": { + "version": "4.0.4", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/which": { + "which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { + "requires": { "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" } }, - "node_modules/why-is-node-running": { + "why-is-node-running": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "siginfo": "^2.0.0", "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" } }, - "node_modules/word-wrap": { + "word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "dev": true }, - "node_modules/wrappy": { + "wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } + "yaml": { + "version": "2.9.0", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==" }, - "node_modules/yocto-queue": { + "yocto-queue": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "dev": true }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } + "zod": { + "version": "4.4.3", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "peer": true }, - "node_modules/zod-to-json-schema": { + "zod-to-json-schema": { "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", "peer": true, - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } + "requires": {} } } } diff --git a/local-ai-sandbox/package.json b/local-ai-sandbox/package.json index 0577f1e91..41ecd5e16 100644 --- a/local-ai-sandbox/package.json +++ b/local-ai-sandbox/package.json @@ -8,6 +8,11 @@ "dev": "tsx --watch --env-file .env src/index.ts", "start": "node --env-file .env dist/src/index.js", "build": "tsc -p tsconfig.build.json", + "models:fetch": "tsx scripts/fetchModels.ts", + "notifications:fetch": "tsx scripts/fetchNotificationSchemas.ts", + "models:sync": "npm run models:fetch && npm run registry:generate", + "registry:generate": "tsx scripts/generateOperationRegistry.ts", + "registry:check": "tsx scripts/generateOperationRegistry.ts --check", "test": "vitest", "test:run": "vitest --run", "test:ui": "vitest --ui", @@ -27,26 +32,25 @@ "description": "", "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.985.0", - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", - "@opentelemetry/resources": "^2.7.0", - "@opentelemetry/sdk-metrics": "^2.6.1", - "@opentelemetry/semantic-conventions": "^1.40.0", + "@aws-sdk/client-sqs": "^3.1104.0", "@strands-agents/sdk": "^1.2.0", "class-validator": "^0.14.3", "express": "^5.2.1", - "lowdb": "^7.0.1", - "openapi-enforcer": "^1.23.0" + "jsonpath-plus": "^10.4.0", + "lokijs": "^1.5.12", + "openapi-enforcer": "^1.23.0", + "yaml": "^2.9.0" }, "devDependencies": { "@eslint/js": "^9.39.2", "@tsconfig/node22": "^22.0.5", "@types/express": "^5.0.6", + "@types/lokijs": "^1.5.14", "@types/node": "^25.2.0", "@vitest/coverage-v8": "^4.0.18", "@vitest/eslint-plugin": "^1.6.6", "eslint": "^9.39.2", - "http-proxy-middleware": "^3.0.7", + "fast-check": "^4.8.0", "prettier": "^3.8.1", "tsx": "^4.22.4", "typescript": "^5.9.3", diff --git a/local-ai-sandbox/public/app.js b/local-ai-sandbox/public/app.js new file mode 100644 index 000000000..20acb6aa7 --- /dev/null +++ b/local-ai-sandbox/public/app.js @@ -0,0 +1,2672 @@ +function escapeHtml(str) { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; +} + +function escapeAttr(str) { + return String(str).replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); +} + +// ===== Tab Switching Logic ===== +const tabs = document.querySelectorAll('.tab-bar .tab'); +const tabPanels = document.querySelectorAll('.tab-panel'); + +tabs.forEach((tab) => { + tab.addEventListener('click', () => { + tabs.forEach((t) => { + t.classList.remove('active'); + t.setAttribute('aria-selected', 'false'); + }); + tabPanels.forEach((p) => p.classList.remove('active')); + + tab.classList.add('active'); + tab.setAttribute('aria-selected', 'true'); + const panelId = tab.getAttribute('aria-controls'); + document.getElementById(panelId).classList.add('active'); + }); + + tab.addEventListener('keydown', (e) => { + const tabList = Array.from(tabs); + const index = tabList.indexOf(tab); + let newIndex = index; + if (e.key === 'ArrowRight') newIndex = (index + 1) % tabList.length; + if (e.key === 'ArrowLeft') newIndex = (index - 1 + tabList.length) % tabList.length; + if (newIndex !== index) { + e.preventDefault(); + tabList[newIndex].focus(); + tabList[newIndex].click(); + } + }); +}); + +// ===== Data Generator (Chat) Logic ===== +const promptInput = document.getElementById("prompt-input"); +const sendBtn = document.getElementById("send"); +const clearBtn = document.getElementById("clear"); +const responseContainer = document.getElementById("response-container"); +const loadingContainer = document.getElementById("loading-container"); +const responseContent = document.getElementById("response-content"); +const statusBadge = document.getElementById("status-badge"); +const statusText = document.getElementById("status-text"); + +function setLoading(loading) { + sendBtn.disabled = loading; + loadingContainer.style.display = loading ? "" : "none"; + if (loading) responseContainer.style.display = "none"; +} + +function showResult(text, isError) { + responseContainer.style.display = ""; + responseContent.textContent = text; + statusBadge.className = "status-badge " + (isError ? "error" : "success"); + statusText.textContent = isError ? "Error" : "Success"; +} + +function showError(message, errorType, statusCode) { + responseContainer.style.display = ""; + responseContent.innerHTML = ""; + + const errorBox = document.createElement("div"); + errorBox.className = "error-detail"; + + const errorHeader = document.createElement("div"); + errorHeader.className = "error-detail-header"; + + if (errorType) { + const typeBadge = document.createElement("span"); + typeBadge.className = "error-type-badge"; + typeBadge.textContent = errorType; + errorHeader.appendChild(typeBadge); + } + + if (statusCode) { + const codeBadge = document.createElement("span"); + codeBadge.className = "error-status-code"; + codeBadge.textContent = "HTTP " + statusCode; + errorHeader.appendChild(codeBadge); + } + + const errorMessage = document.createElement("p"); + errorMessage.className = "error-detail-message"; + errorMessage.textContent = message; + + errorBox.appendChild(errorHeader); + errorBox.appendChild(errorMessage); + responseContent.appendChild(errorBox); + + statusBadge.className = "status-badge error"; + statusText.textContent = "Error"; +} + +sendBtn.addEventListener("click", async () => { + const prompt = promptInput.value.trim(); + if (!prompt) return; + setLoading(true); + try { + const res = await fetch("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt }), + }); + const data = await res.json(); + setLoading(false); + if (!res.ok && data.error) { + showError(data.error, data.errorType, res.status); + } else { + showResult(data.result, !res.ok); + } + } catch (err) { + setLoading(false); + showError("Request failed: " + err.message, "NetworkError"); + } +}); + +promptInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendBtn.click(); + } +}); + +clearBtn.addEventListener("click", () => { + promptInput.value = ""; + responseContainer.style.display = "none"; + loadingContainer.style.display = "none"; + promptInput.focus(); +}); + +// ===== Clear Data Button ===== +const clearDataBtn = document.getElementById("clearDataBtn"); + +clearDataBtn.addEventListener("click", () => { + const overlay = document.createElement("div"); + overlay.className = "modal-overlay"; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + + const closeModal = () => overlay.remove(); + overlay.querySelector(".modal-close").addEventListener("click", closeModal); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) closeModal(); + }); + overlay.querySelector("#clearDataCancel").addEventListener("click", closeModal); + + overlay.querySelector("#clearDataConfirm").addEventListener("click", async () => { + try { + const res = await fetch("/data", { method: "DELETE" }); + if (res.ok) { + closeModal(); + fetchDataViewer(); + } else { + const data = await res.json(); + closeModal(); + const content = document.getElementById("data-viewer-content"); + content.innerHTML = '

Failed to clear data: ' + escapeHtml(data.error || "Unknown error") + '

'; + } + } catch (err) { + closeModal(); + const content = document.getElementById("data-viewer-content"); + content.innerHTML = '

Failed to clear data: ' + escapeHtml(err.message) + '

'; + } + }); +}); + +// ===== Notifications Tab Logic ===== +let availableSchemas = []; +let currentSchema = null; +let notificationsSchemasLoaded = false; + +const notificationTypeSelect = document.getElementById("notification-type-select"); +const notificationFormContainer = document.getElementById("notification-form-container"); + +async function loadNotificationSchemas() { + if (notificationsSchemasLoaded) return; + notificationsSchemasLoaded = true; + + try { + const res = await fetch("/manage/notifications/schemas"); + if (!res.ok) { + notificationFormContainer.innerHTML = + '

Unable to load notification schemas

'; + notificationsSchemasLoaded = false; + return; + } + const schemas = await res.json(); + availableSchemas = schemas; + + // Clear existing options beyond the placeholder + notificationTypeSelect.innerHTML = ''; + + if (schemas.length === 0) { + notificationFormContainer.innerHTML = + '

No notification types are configured

'; + return; + } + + schemas.forEach((entry) => { + const option = document.createElement("option"); + option.value = entry.notificationType; + option.textContent = entry.notificationType; + notificationTypeSelect.appendChild(option); + }); + } catch (err) { + notificationFormContainer.innerHTML = + '

Unable to load notification schemas

'; + notificationsSchemasLoaded = false; + } +} + +notificationTypeSelect.addEventListener("change", () => { + const selectedType = notificationTypeSelect.value; + const prefillActions = document.getElementById("prefill-actions"); + if (!selectedType) { + currentSchema = null; + notificationFormContainer.innerHTML = ""; + if (prefillActions) prefillActions.style.display = "none"; + return; + } + const entry = availableSchemas.find((s) => s.notificationType === selectedType); + currentSchema = entry ? entry.schema : null; + if (currentSchema) { + if (prefillActions) prefillActions.style.display = ""; + renderSchemaForm(currentSchema, notificationFormContainer); + } +}); + +// ===== Dynamic Form Generator ===== + +/** + * Renders a dynamic form from a JSON Schema into the given container. + * @param {object} schema - The JSON Schema object + * @param {HTMLElement} container - The DOM container to render into + */ +function renderSchemaForm(schema, container) { + container.innerHTML = ""; + if (!schema || !schema.properties) return; + const requiredFields = Array.isArray(schema.required) ? schema.required : []; + for (const [name, propSchema] of Object.entries(schema.properties)) { + renderField(name, propSchema, container, requiredFields, name); + } + // Attach validation listeners for real-time error clearing + attachValidationListeners(container, schema); +} + +/** + * Recursively renders a single field based on its JSON Schema definition. + * @param {string} name - The property name + * @param {object} propSchema - The JSON Schema for this property + * @param {HTMLElement} parentContainer - The DOM container to append to + * @param {string[]} requiredFields - Array of required field names at this level + * @param {string} path - The dot-separated path for data-path attribute + */ +function renderField(name, propSchema, parentContainer, requiredFields, path) { + const isRequired = requiredFields.includes(name); + const type = resolveSchemaType(propSchema); + + if (type === "object" && propSchema.properties) { + // Render as collapsible section. It carries data-path (like the leaf + // inputs and array sections below) so validateForm can look up whether + // this object itself is required and check it as a unit — a required + // object whose individual children are all optional would otherwise + // never be enforced. + const section = document.createElement("div"); + section.className = "notification-collapsible"; + section.setAttribute("data-path", path); + section.setAttribute("data-container-type", "object"); + + const header = document.createElement("button"); + header.type = "button"; + header.className = "notification-collapsible-header"; + header.textContent = formatLabel(name) + (isRequired ? " *" : ""); + header.addEventListener("click", (e) => { + e.stopPropagation(); + section.classList.toggle("open"); + }); + + const errorSpan = document.createElement("span"); + errorSpan.className = "notification-container-error"; + errorSpan.style.display = "none"; + + const content = document.createElement("div"); + content.className = "notification-collapsible-content"; + + const nestedRequired = Array.isArray(propSchema.required) ? propSchema.required : []; + for (const [childName, childSchema] of Object.entries(propSchema.properties)) { + renderField(childName, childSchema, content, nestedRequired, path + "." + childName); + } + + section.appendChild(header); + section.appendChild(errorSpan); + section.appendChild(content); + parentContainer.appendChild(section); + return; + } + + if (type === "array" && propSchema.items) { + // Render as a repeatable section with an "Add" button. This handles both + // object-item arrays (each item is a group of fields) and scalar/enum-item + // arrays (each item is a single input). addArrayItem renders the correct + // per-item content based on the item schema. + const section = document.createElement("div"); + section.className = "notification-array-section"; + section.setAttribute("data-path", path); + section.setAttribute("data-container-type", "array"); + + const label = document.createElement("label"); + label.style.fontWeight = "600"; + label.style.fontSize = "13px"; + label.style.display = "block"; + label.style.marginBottom = "var(--space-s)"; + label.textContent = formatLabel(name) + (isRequired ? " *" : ""); + section.appendChild(label); + + const errorSpan = document.createElement("span"); + errorSpan.className = "notification-container-error"; + errorSpan.style.display = "none"; + section.appendChild(errorSpan); + + const itemsContainer = document.createElement("div"); + itemsContainer.className = "notification-array-items"; + section.appendChild(itemsContainer); + + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "notification-add-btn"; + addBtn.textContent = "+ Add " + formatLabel(name); + addBtn.addEventListener("click", () => { + addArrayItem(propSchema.items, itemsContainer, path); + // Adding an item satisfies "at least one item" — clear any stale error. + clearContainerError(section); + }); + section.appendChild(addBtn); + + parentContainer.appendChild(section); + return; + } + + // Render as a simple input field + const fieldDiv = document.createElement("div"); + fieldDiv.className = "order-form-field"; + + const labelEl = document.createElement("label"); + labelEl.textContent = formatLabel(name) + (isRequired ? " *" : ""); + fieldDiv.appendChild(labelEl); + + let input; + + if (propSchema.enum) { + // Select dropdown for enum + input = document.createElement("select"); + input.setAttribute("data-path", path); + const defaultOption = document.createElement("option"); + defaultOption.value = ""; + defaultOption.textContent = "-- Select --"; + input.appendChild(defaultOption); + propSchema.enum.forEach((val) => { + const option = document.createElement("option"); + option.value = val; + option.textContent = val; + input.appendChild(option); + }); + } else if (type === "boolean") { + input = document.createElement("input"); + input.type = "checkbox"; + input.setAttribute("data-path", path); + } else if (type === "integer" || type === "number") { + input = document.createElement("input"); + input.type = "number"; + input.setAttribute("data-path", path); + if (propSchema.examples && propSchema.examples.length > 0) { + input.placeholder = String(propSchema.examples[0]); + } + } else { + // Default to text input (string or unknown) + input = document.createElement("input"); + input.type = "text"; + input.setAttribute("data-path", path); + if (propSchema.examples && propSchema.examples.length > 0) { + const example = propSchema.examples[0]; + input.placeholder = typeof example === "string" ? example : JSON.stringify(example); + } + } + + fieldDiv.appendChild(input); + + // Add error span + const errorSpan = document.createElement("span"); + errorSpan.className = "field-error"; + errorSpan.style.display = "none"; + fieldDiv.appendChild(errorSpan); + + parentContainer.appendChild(fieldDiv); +} + +/** + * Adds a new array item to a repeatable section. + * @param {object} itemSchema - The JSON Schema for array items + * @param {HTMLElement} itemsContainer - The container for array items + * @param {string} basePath - The base path for the array + */ +function addArrayItem(itemSchema, itemsContainer, basePath) { + // Count only direct children so nested array items (e.g. a scalar array + // inside an object item) don't inflate this array's next index. + const index = itemsContainer.querySelectorAll(":scope > .notification-array-item").length; + const itemDiv = document.createElement("div"); + itemDiv.className = "notification-array-item"; + + if (resolveSchemaType(itemSchema) === "object" && itemSchema.properties) { + // Object items: render each property as its own field. + const itemRequired = Array.isArray(itemSchema.required) ? itemSchema.required : []; + for (const [childName, childSchema] of Object.entries(itemSchema.properties)) { + renderField(childName, childSchema, itemDiv, itemRequired, basePath + "[" + index + "]." + childName); + } + } else { + // Scalar/enum items: the element itself is a single leaf value. Render one + // input whose data-path is the indexed path (e.g. "OrderPrograms[0]") so it + // is collected as an array element rather than a JSON-encoded string. + renderField("", itemSchema, itemDiv, [], basePath + "[" + index + "]"); + } + + // Add remove button + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "btn-remove-item"; + removeBtn.textContent = "Remove"; + removeBtn.style.position = "absolute"; + removeBtn.style.top = "var(--space-s)"; + removeBtn.style.right = "var(--space-s)"; + removeBtn.addEventListener("click", () => { + itemDiv.remove(); + }); + itemDiv.appendChild(removeBtn); + + itemsContainer.appendChild(itemDiv); + + // Attach validation listeners for the new inputs + if (currentSchema) { + attachValidationListeners(itemDiv, currentSchema); + } +} + +/** + * Resolves the effective type from a JSON Schema property. + * Handles array-type definitions like ["string", "null"]. + * @param {object} propSchema - The property schema + * @returns {string} The resolved type string + */ +function resolveSchemaType(propSchema) { + if (!propSchema || !propSchema.type) return "string"; + if (Array.isArray(propSchema.type)) { + // Return the first non-null type + return propSchema.type.find((t) => t !== "null") || "string"; + } + return propSchema.type; +} + +/** + * Converts a camelCase or PascalCase field name to a human-readable label. + * @param {string} name - The field name + * @returns {string} Formatted label + */ +function formatLabel(name) { + return name.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()).trim(); +} + +// ===== Prefill Form Logic ===== + +/** + * Prefills the notification form with sample data from the schema's top-level examples[0]. + * Recursively populates nested objects and array items. + * @param {object} schema - The JSON Schema object with an examples array + * @param {HTMLElement} container - The form container element + */ +function prefillForm(schema, container) { + if (!schema || !schema.examples || schema.examples.length === 0) return; + const exampleData = schema.examples[0]; + if (!exampleData || typeof exampleData !== "object") return; + + // Clear existing validation errors + container.querySelectorAll(".field-error").forEach((el) => { + el.textContent = ""; + el.style.display = "none"; + }); + container.querySelectorAll(".invalid").forEach((el) => el.classList.remove("invalid")); + container.querySelectorAll(".notification-array-section, .notification-collapsible").forEach(clearContainerError); + + // Recursively fill fields from example data + prefillFields(exampleData, schema, container); +} + +/** + * Recursively fills form fields based on example data and schema structure. + * Handles leaf inputs, nested objects, and nested arrays (including arrays + * nested inside array items) uniformly. All lookups are scoped to `scope`, so + * the same logic works at the top level and within an individual array item. + * @param {object} data - The example data object + * @param {object} schema - The JSON Schema for this level + * @param {HTMLElement} scope - The DOM element to search for inputs within + * @param {string} [pathPrefix] - The current path prefix for data-path matching + */ +function prefillFields(data, schema, scope, pathPrefix) { + if (!data || typeof data !== "object" || !schema || !schema.properties) return; + + for (const [key, value] of Object.entries(data)) { + const propSchema = schema.properties[key]; + if (!propSchema) continue; + + const currentPath = pathPrefix ? pathPrefix + "." + key : key; + const type = resolveSchemaType(propSchema); + + if (type === "array" && Array.isArray(value) && propSchema.items) { + // Find the array section by data-path, scoped to the current element + const arraySection = scope.querySelector('.notification-array-section[data-path="' + currentPath + '"]'); + if (!arraySection) continue; + + const itemsContainer = arraySection.querySelector(".notification-array-items"); + if (!itemsContainer) continue; + + const itemIsObject = resolveSchemaType(propSchema.items) === "object"; + + // Add array items and fill them + value.forEach((itemData, index) => { + addArrayItem(propSchema.items, itemsContainer, currentPath); + // The just-added item is the last direct child; scoping to ":scope >" + // avoids matching items belonging to any nested array sections. + const addedItems = itemsContainer.querySelectorAll(":scope > .notification-array-item"); + const addedItem = addedItems[addedItems.length - 1]; + if (!addedItem) return; + + if (itemIsObject) { + if (itemData && typeof itemData === "object") { + // Recurse with the new item element as scope so nested objects AND + // nested arrays within the item are populated. + prefillFields(itemData, propSchema.items, addedItem, currentPath + "[" + index + "]"); + } + } else { + // Scalar/enum item: set the single indexed input directly. + const input = addedItem.querySelector('[data-path="' + currentPath + "[" + index + "]" + '"]'); + if (input) { + if (input.type === "checkbox") { + input.checked = Boolean(itemData); + } else { + input.value = typeof itemData === "object" ? JSON.stringify(itemData) : String(itemData); + } + } + } + }); + } else if (type === "object" && propSchema.properties && typeof value === "object" && !Array.isArray(value)) { + // Recurse into nested object (same scope, extended path) + prefillFields(value, propSchema, scope, currentPath); + } else { + // Set value on input field matching data-path + const input = scope.querySelector('[data-path="' + currentPath + '"]'); + if (!input) continue; + + if (input.type === "checkbox") { + input.checked = Boolean(value); + } else { + input.value = typeof value === "object" ? JSON.stringify(value) : String(value); + } + } + } +} + +// Wire the "Prefill Sample Data" button for notifications +const btnPrefillNotification = document.getElementById("btn-prefill-notification"); +btnPrefillNotification.addEventListener("click", () => { + if (!currentSchema) return; + // If no form is rendered yet, render it first + if (notificationFormContainer.innerHTML.trim() === "" || !notificationFormContainer.querySelector("[data-path]")) { + renderSchemaForm(currentSchema, notificationFormContainer); + } + prefillForm(currentSchema, notificationFormContainer); +}); + +// ===== Notification Form Validation ===== + +/** + * Clears a container-level (required array / required object) validation error. + * @param {HTMLElement} containerEl - A `.notification-array-section` or `.notification-collapsible` element + */ +function clearContainerError(containerEl) { + containerEl.classList.remove("invalid"); + const errorSpan = Array.from(containerEl.children).find((el) => el.classList.contains("notification-container-error")); + if (errorSpan) { + errorSpan.textContent = ""; + errorSpan.style.display = "none"; + } +} + +/** + * Shows a container-level (required array / required object) validation error. + * @param {HTMLElement} containerEl - A `.notification-array-section` or `.notification-collapsible` element + * @param {string} message - The error message + */ +function showContainerError(containerEl, message) { + containerEl.classList.add("invalid"); + const errorSpan = Array.from(containerEl.children).find((el) => el.classList.contains("notification-container-error")); + if (errorSpan) { + errorSpan.textContent = message; + errorSpan.style.display = "block"; + } +} + +/** + * Determines whether a rendered object/array container currently holds any + * user-provided data: a non-empty leaf input/select value, a checked + * checkbox, or a nested array section with at least one item. + * @param {HTMLElement} containerEl - The DOM subtree to inspect + * @returns {boolean} + */ +function hasPopulatedContent(containerEl) { + const leafInputs = containerEl.querySelectorAll("input[data-path], select[data-path]"); + for (const input of leafInputs) { + if (input.type === "checkbox") { + if (input.checked) return true; + } else if (String(input.value).trim() !== "") { + return true; + } + } + + const nestedArraySections = containerEl.querySelectorAll(".notification-array-section"); + for (const arraySection of nestedArraySections) { + const itemsContainer = arraySection.querySelector(".notification-array-items"); + if (itemsContainer && itemsContainer.querySelectorAll(".notification-array-item").length > 0) { + return true; + } + } + + return false; +} + +/** + * Validates the notification form against the schema and returns collected form data or null. + * Checks required leaf fields have non-empty values, numeric fields have valid numbers, + * required arrays have at least one item, and required objects have at least one + * populated descendant field. Displays inline errors next to invalid fields/sections. + * @param {object} schema - The JSON Schema object + * @param {HTMLElement} container - The DOM container holding the form + * @returns {object|null} Collected form data as nested JSON, or null if validation fails + */ +function validateForm(schema, container) { + let isValid = true; + + // Leaf form controls only. Object sections (.notification-collapsible) and + // array sections (.notification-array-section) also carry data-path (for + // isFieldRequired lookups and prefill matching), but they're containers, + // not value-holding controls, so they're excluded here and validated as + // units below — otherwise a required array with zero items, or a required + // object whose own children are all optional, would silently pass. + const inputs = container.querySelectorAll("input[data-path], select[data-path]"); + const arraySections = container.querySelectorAll(".notification-array-section[data-path]"); + const objectSections = container.querySelectorAll(".notification-collapsible[data-path]"); + + // Clear all existing errors first + inputs.forEach((input) => { + const errorSpan = input.parentElement.querySelector(".field-error"); + if (errorSpan) { + errorSpan.textContent = ""; + errorSpan.style.display = "none"; + } + input.classList.remove("invalid"); + }); + arraySections.forEach(clearContainerError); + objectSections.forEach(clearContainerError); + + // Validate each leaf input + inputs.forEach((input) => { + const path = input.getAttribute("data-path"); + const inputType = input.type; + const value = inputType === "checkbox" ? input.checked : input.value; + + // Check if this field is required based on the schema + const isRequired = isFieldRequired(schema, path); + + // Required field validation (skip checkboxes - they always have a boolean value) + if (isRequired && inputType !== "checkbox") { + const strValue = String(value).trim(); + if (!strValue) { + showNotificationFieldError(input, "This field is required"); + isValid = false; + return; + } + } + + // Numeric validation for number-type inputs + if (inputType === "number") { + const strValue = input.value.trim(); + if (strValue !== "") { + const num = Number(strValue); + if (isNaN(num)) { + showNotificationFieldError(input, "Must be a valid number"); + isValid = false; + return; + } + } + } + }); + + // Validate required array containers — must contain at least one item + arraySections.forEach((section) => { + const path = section.getAttribute("data-path"); + if (!isFieldRequired(schema, path)) return; + const itemsContainer = section.querySelector(".notification-array-items"); + const itemCount = itemsContainer ? itemsContainer.querySelectorAll(".notification-array-item").length : 0; + if (itemCount === 0) { + showContainerError(section, "At least one item is required"); + isValid = false; + } + }); + + // Validate required object containers — must have at least one populated field + objectSections.forEach((section) => { + const path = section.getAttribute("data-path"); + if (!isFieldRequired(schema, path)) return; + const content = section.querySelector(".notification-collapsible-content"); + if (!content || !hasPopulatedContent(content)) { + section.classList.add("open"); // reveal so the user can see what to fill in + showContainerError(section, "At least one field in this section is required"); + isValid = false; + } + }); + + if (!isValid) return null; + + // Collect form data + const data = {}; + inputs.forEach((input) => { + const path = input.getAttribute("data-path"); + const inputType = input.type; + let value; + + if (inputType === "checkbox") { + value = input.checked; + } else if (inputType === "number") { + const strValue = input.value.trim(); + value = strValue === "" ? undefined : Number(strValue); + } else { + value = input.value; + } + + // Skip empty non-required fields (don't include them in the payload) + if (value === undefined || (typeof value === "string" && value === "")) return; + + setNestedValue(data, path, value); + }); + + return data; +} + +/** + * Determines if a field is required based on the schema's required arrays at each nesting level. + * @param {object} schema - The root JSON Schema + * @param {string} path - The dot-separated data-path (e.g., "Payload.OrderChangeNotification.SellerId") + * @returns {boolean} + */ +function isFieldRequired(schema, path) { + // Parse the path into segments, handling array indices + const segments = parseDataPath(path); + let currentSchema = schema; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // If this is an array index segment, traverse into items schema + if (segment.isIndex) { + if (currentSchema && resolveSchemaType(currentSchema) === "array" && currentSchema.items) { + currentSchema = currentSchema.items; + } else { + return false; + } + continue; + } + + // For the last segment, check if it's in the current schema's required array + if (i === segments.length - 1) { + const requiredList = Array.isArray(currentSchema.required) ? currentSchema.required : []; + return requiredList.includes(segment.name); + } + + // Navigate into the property schema for the next level + if (currentSchema.properties && currentSchema.properties[segment.name]) { + const propSchema = currentSchema.properties[segment.name]; + const type = resolveSchemaType(propSchema); + if (type === "object") { + currentSchema = propSchema; + } else if (type === "array" && propSchema.items) { + currentSchema = propSchema; + } else { + return false; + } + } else { + return false; + } + } + + return false; +} + +/** + * Parses a data-path string into segments. + * Handles dot-separated paths and array index notation. + * E.g., "Payload.Items[0].Name" → [{name:"Payload"}, {name:"Items"}, {isIndex:true, index:0}, {name:"Name"}] + * @param {string} path + * @returns {Array<{name?: string, isIndex?: boolean, index?: number}>} + */ +function parseDataPath(path) { + const segments = []; + const parts = path.split("."); + + for (const part of parts) { + // Check for array notation like "Items[0]" + const arrayMatch = part.match(/^([^\[]+)\[(\d+)\]$/); + if (arrayMatch) { + segments.push({ name: arrayMatch[1] }); + segments.push({ isIndex: true, index: parseInt(arrayMatch[2], 10) }); + } else { + segments.push({ name: part }); + } + } + + return segments; +} + +/** + * Sets a value in a nested object based on a data-path string. + * Handles dot-separated paths and array index notation. + * @param {object} obj - The root object to set into + * @param {string} path - The data-path (e.g., "Payload.Items[0].Name") + * @param {*} value - The value to set + */ +function setNestedValue(obj, path, value) { + const segments = parseDataPath(path); + let current = obj; + + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + const nextSegment = segments[i + 1]; + + if (segment.isIndex) { + // Current segment is an array index - ensure the array slot exists + while (current.length <= segment.index) { + current.push(nextSegment && nextSegment.isIndex ? [] : {}); + } + current = current[segment.index]; + } else { + // Current segment is a property name - ensure it exists + if (!(segment.name in current)) { + // Look ahead to determine if next level is array or object + if (nextSegment && nextSegment.isIndex) { + current[segment.name] = []; + } else { + current[segment.name] = {}; + } + } + current = current[segment.name]; + } + } + + // Set the value at the last segment + const lastSegment = segments[segments.length - 1]; + if (lastSegment.isIndex) { + while (current.length <= lastSegment.index) { + current.push(undefined); + } + current[lastSegment.index] = value; + } else { + current[lastSegment.name] = value; + } +} + +/** + * Shows an inline error message for a notification form field. + * @param {HTMLElement} input - The input element + * @param {string} message - The error message + */ +function showNotificationFieldError(input, message) { + input.classList.add("invalid"); + const errorSpan = input.parentElement.querySelector(".field-error"); + if (errorSpan) { + errorSpan.textContent = message; + errorSpan.style.display = "block"; + } +} + +/** + * Attaches input event listeners to clear validation errors when a field becomes valid. + * @param {HTMLElement} container - The form container + * @param {object} schema - The JSON Schema for re-validation + */ +function attachValidationListeners(container, schema) { + const inputs = container.querySelectorAll("input[data-path], select[data-path]"); + inputs.forEach((input) => { + input.addEventListener("input", () => { + const path = input.getAttribute("data-path"); + const inputType = input.type; + const isRequired = isFieldRequired(schema, path); + let hasError = false; + + if (isRequired && inputType !== "checkbox") { + const strValue = input.value.trim(); + if (!strValue) { + hasError = true; + } + } + + if (!hasError && inputType === "number") { + const strValue = input.value.trim(); + if (strValue !== "" && isNaN(Number(strValue))) { + hasError = true; + } + } + + if (!hasError) { + input.classList.remove("invalid"); + const errorSpan = input.parentElement.querySelector(".field-error"); + if (errorSpan) { + errorSpan.textContent = ""; + errorSpan.style.display = "none"; + } + } + + // A field just became populated (or was already valid) — clear any + // ancestor "required object" container error that this satisfies. + let ancestor = input.closest(".notification-collapsible"); + while (ancestor) { + if (ancestor.classList.contains("invalid")) { + const content = ancestor.querySelector(".notification-collapsible-content"); + if (content && hasPopulatedContent(content)) { + clearContainerError(ancestor); + } + } + const parent = ancestor.parentElement; + ancestor = parent ? parent.closest(".notification-collapsible") : null; + } + }); + }); +} + +// ===== Send Notification Flow ===== +const btnSendNotification = document.getElementById("btn-send-notification"); +const notificationStatus = document.getElementById("notification-status"); + +btnSendNotification.addEventListener("click", async () => { + // Hide any previous status message + notificationStatus.style.display = "none"; + notificationStatus.textContent = ""; + notificationStatus.className = "notification-status"; + + // Ensure a notification type is selected + if (!currentSchema) { + notificationStatus.textContent = "Please select a notification type"; + notificationStatus.className = "notification-status error"; + notificationStatus.style.display = "block"; + return; + } + + // Validate the form — returns payload object or null on failure + const payload = validateForm(currentSchema, notificationFormContainer); + if (!payload) return; + + // Disable button and show loading state + btnSendNotification.disabled = true; + const originalText = btnSendNotification.textContent; + btnSendNotification.textContent = "Sending..."; + + try { + const res = await fetch("/manage/notifications/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + const data = await res.json(); + notificationStatus.textContent = "Notification sent successfully. Message ID: " + (data.messageId || "unknown"); + notificationStatus.className = "notification-status success"; + notificationStatus.style.display = "block"; + } else { + const data = await res.json().catch(() => ({})); + notificationStatus.textContent = data.error || "Failed to send notification (HTTP " + res.status + ")"; + notificationStatus.className = "notification-status error"; + notificationStatus.style.display = "block"; + } + } catch (err) { + notificationStatus.textContent = "Network error. Please check your connection."; + notificationStatus.className = "notification-status error"; + notificationStatus.style.display = "block"; + } finally { + btnSendNotification.disabled = false; + btnSendNotification.textContent = originalText; + } +}); + +// Fetch schemas when Notifications tab is activated +const tabNotifications = document.getElementById("tab-notifications"); +tabNotifications.addEventListener("click", () => { + loadNotificationSchemas(); +}); + +// ===== Data Viewer Tab Logic ===== +const dataViewerDomainLabels = { + orders: "Orders", + listings: "Listings", + catalog: "Catalog Items", + inventory: "FBA Inventory", + pricing: "Product Pricing", + reports: "Reports", + extFulfillmentInventory: "Ext. Fulfillment Inventory", + extFulfillmentReturns: "Ext. Fulfillment Returns", + extFulfillmentShipments: "Ext. Fulfillment Shipments", + listingsRestrictions: "Listings Restrictions", + productTypeDefinitions: "Product Type Definitions", +}; + +function renderDataViewerDomainCard(domain, entities) { + const keys = Object.keys(entities); + const label = dataViewerDomainLabels[domain] || domain; + const card = document.createElement("div"); + card.style.cssText = "border: 1px solid var(--color-border-divider-default); border-radius: var(--border-radius-input); margin-bottom: var(--space-m); overflow: hidden;"; + + const header = document.createElement("div"); + header.style.cssText = "display: flex; align-items: center; justify-content: space-between; padding: var(--space-m) var(--space-l); background: var(--color-background-container-header);"; + header.innerHTML = + '' + + escapeHtml(label) + + '' + + keys.length + + (keys.length === 1 ? " entity" : " entities") + + ""; + card.appendChild(header); + + if (keys.length > 0) { + const body = document.createElement("div"); + body.style.cssText = "padding: 0; max-height: 300px; overflow-y: auto;"; + keys.forEach(function (key) { + const row = document.createElement("details"); + row.style.cssText = "border-top: 1px solid var(--color-border-divider-default);"; + const summary = document.createElement("summary"); + summary.style.cssText = "padding: var(--space-s) var(--space-l); font-size: 13px; cursor: pointer; font-family: monospace; color: var(--color-text-interactive-default);"; + summary.textContent = key; + row.appendChild(summary); + const detailContent = document.createElement("div"); + detailContent.style.cssText = "padding: var(--space-s) var(--space-l) var(--space-m); background: var(--color-background-navigation); overflow-x: auto;"; + const pre = document.createElement("pre"); + pre.style.cssText = "font-size: 12px; color: #c5c8c6; margin: 0; white-space: pre-wrap; word-break: break-word;"; + pre.textContent = JSON.stringify(entities[key], null, 2); + detailContent.appendChild(pre); + row.appendChild(detailContent); + body.appendChild(row); + }); + card.appendChild(body); + } + + return card; +} + +async function fetchDataViewer() { + const content = document.getElementById("data-viewer-content"); + content.innerHTML = '

Loading database contents...

'; + try { + const res = await fetch("/data"); + const data = await res.json(); + content.textContent = ""; + + const domains = Object.keys(data); + const populated = domains.filter(function (d) { + return Object.keys(data[d]).length > 0; + }); + const empty = domains.filter(function (d) { + return Object.keys(data[d]).length === 0; + }); + + if (populated.length === 0) { + content.innerHTML = + '
No data in the sandbox. Use the data generator or seed a scenario to get started.
'; + return; + } + + // Summary bar + const totalEntities = populated.reduce(function (sum, d) { + return sum + Object.keys(data[d]).length; + }, 0); + const summaryBar = document.createElement("div"); + summaryBar.style.cssText = "margin-bottom: var(--space-l); padding: var(--space-m) var(--space-l); background: var(--color-background-status-info); border-radius: var(--border-radius-input); font-size: 13px; color: var(--color-text-status-info);"; + summaryBar.textContent = + totalEntities + + " entities across " + + populated.length + + " domain" + + (populated.length === 1 ? "" : "s") + + (empty.length > 0 ? " · " + empty.length + " empty" : ""); + content.appendChild(summaryBar); + + // Render populated domains + populated.forEach(function (domain) { + content.appendChild(renderDataViewerDomainCard(domain, data[domain])); + }); + } catch (err) { + content.innerHTML = '

Failed to load data: ' + escapeHtml(err.message) + '

'; + } +} + +// Fetch data when Data Viewer tab is activated +const tabDataViewer = document.getElementById("tab-data-viewer"); +tabDataViewer.addEventListener("click", () => { + fetchDataViewer(); +}); + +// ===== Orders Tab Logic ===== +const ordersListContent = document.getElementById("orders-list-content"); +let ordersCache = []; + +function formatDateTime(isoString) { + if (!isoString) return "N/A"; + try { + const d = new Date(isoString); + if (isNaN(d.getTime())) return isoString; + const pad = (n) => String(n).padStart(2, "0"); + return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()); + } catch { + return isoString; + } +} + +function getFulfillmentStatus(order) { + if (order.fulfillment && order.fulfillment.fulfillmentStatus) { + return order.fulfillment.fulfillmentStatus; + } + return "N/A"; +} + +function renderOrderList(orders) { + ordersCache = orders || []; + ordersListContent.innerHTML = ""; + + if (ordersCache.length === 0) { + ordersListContent.innerHTML = '

No orders exist yet.

'; + return; + } + + ordersCache.forEach((order) => { + const orderId = order.orderId || order._key || "Unknown"; + const status = getFulfillmentStatus(order); + const createdTime = formatDateTime(order.createdTime); + + const row = document.createElement("div"); + row.className = "order-row"; + row.setAttribute("data-order-id", orderId); + + row.innerHTML = + '
' + + '
' + escapeHtml(orderId) + '
' + + '
' + escapeHtml(createdTime) + '
' + + '
' + + '' + escapeHtml(status) + '' + + ''; + + ordersListContent.appendChild(row); + }); +} + +async function fetchOrders() { + ordersListContent.innerHTML = '

Loading orders...

'; + try { + const res = await fetch("/data"); + if (!res.ok) { + ordersListContent.innerHTML = '

Unable to load orders. Please try again.

'; + return; + } + const data = await res.json(); + const ordersObj = data.orders || {}; + const orders = Object.entries(ordersObj).map(([key, val]) => ({ orderId: key, ...val })); + renderOrderList(orders); + } catch (err) { + ordersListContent.innerHTML = '

Unable to load orders. Please try again.

'; + } +} + +// Delete order with confirmation +async function deleteOrder(orderId, deleteBtn) { + const confirmed = confirm("Are you sure you want to delete order " + orderId + "?"); + if (!confirmed) return; + + deleteBtn.disabled = true; + + const row = deleteBtn.closest(".order-row"); + const existingError = row.parentElement.querySelector('.order-delete-error[data-order-id="' + CSS.escape(orderId) + '"]'); + if (existingError) existingError.remove(); + + try { + const res = await fetch("/manage/orders/" + encodeURIComponent(orderId), { + method: "DELETE", + }); + + if (res.ok) { + row.remove(); + ordersCache = ordersCache.filter((o) => o.orderId !== orderId); + if (editingOrderId === orderId) { + switchToCreateMode(); + } + if (ordersCache.length === 0) { + ordersListContent.innerHTML = '

No orders exist yet.

'; + } + } else { + deleteBtn.disabled = false; + const errorData = await res.json().catch(() => ({ error: "Delete failed" })); + const errorMsg = errorData.error || errorData.message || "Delete failed"; + const errorEl = document.createElement("div"); + errorEl.className = "order-delete-error"; + errorEl.setAttribute("data-order-id", orderId); + errorEl.textContent = errorMsg; + row.insertAdjacentElement("afterend", errorEl); + } + } catch (err) { + deleteBtn.disabled = false; + const errorEl = document.createElement("div"); + errorEl.className = "order-delete-error"; + errorEl.setAttribute("data-order-id", orderId); + errorEl.textContent = "Network error. Please check your connection."; + row.insertAdjacentElement("afterend", errorEl); + } +} + +// Event delegation for delete buttons +ordersListContent.addEventListener("click", (e) => { + const deleteBtn = e.target.closest(".order-row-delete"); + if (!deleteBtn) return; + e.stopPropagation(); + const row = deleteBtn.closest(".order-row"); + const orderId = row.getAttribute("data-order-id"); + if (orderId) { + deleteOrder(orderId, deleteBtn); + } +}); + +// Fetch orders when Orders tab is activated +const tabOrders = document.getElementById("tab-orders"); +tabOrders.addEventListener("click", () => { + fetchOrders(); +}); + +// ===== Order Editor Logic ===== +let editorMode = "create"; // "create" or "edit" +let editingOrderId = null; +let programsList = []; + +const editorPlaceholder = document.getElementById("orders-editor-placeholder"); +const editorFormContainer = document.getElementById("orders-editor-form-container"); +const editorTitle = document.getElementById("editor-title"); +const btnNewOrder = document.getElementById("btn-new-order"); +const btnSubmitOrder = document.getElementById("btn-submit-order"); +const btnPrefill = document.getElementById("btn-prefill"); +const btnAddItem = document.getElementById("btn-add-item"); +const orderItemsContainer = document.getElementById("order-items-container"); +const programsTagsEl = document.getElementById("programs-tags"); +const programInput = document.getElementById("field-programInput"); +const formErrorMessage = document.getElementById("form-error-message"); + +function showEditorForm() { + editorPlaceholder.style.display = "none"; + editorFormContainer.style.display = ""; +} + +function hideEditorForm() { + editorPlaceholder.style.display = ""; + editorFormContainer.style.display = "none"; +} + +function switchToCreateMode() { + editorMode = "create"; + editingOrderId = null; + editorTitle.textContent = "Create Order"; + btnSubmitOrder.textContent = "Create Order"; + btnPrefill.style.display = "inline-flex"; + document.getElementById("field-orderId").readOnly = false; + clearEditorForm(); + showEditorForm(); + addOrderItem(); // Ensure at least one item + // Deselect any active order row + document.querySelectorAll(".order-row.active").forEach((r) => r.classList.remove("active")); +} + +function switchToEditMode(order) { + editorMode = "edit"; + editingOrderId = order.orderId; + editorTitle.textContent = "Edit Order"; + btnSubmitOrder.textContent = "Update Order"; + btnPrefill.style.display = "none"; + clearEditorForm(); + populateEditorForm(order); + document.getElementById("field-orderId").readOnly = true; + showEditorForm(); +} + +function clearEditorForm() { + document.getElementById("field-orderId").value = ""; + document.getElementById("field-createdTime").value = ""; + document.getElementById("field-lastUpdatedTime").value = ""; + document.getElementById("field-channelName").value = ""; + document.getElementById("field-marketplaceId").value = ""; + document.getElementById("field-marketplaceName").value = ""; + document.getElementById("field-fulfillmentStatus").value = ""; + document.getElementById("field-fulfilledBy").value = ""; + document.getElementById("field-fulfillmentServiceLevel").value = ""; + document.getElementById("field-shipByEarliest").value = ""; + document.getElementById("field-shipByLatest").value = ""; + document.getElementById("field-deliverByEarliest").value = ""; + document.getElementById("field-deliverByLatest").value = ""; + document.getElementById("field-buyerName").value = ""; + document.getElementById("field-buyerEmail").value = ""; + document.getElementById("field-buyerCompanyName").value = ""; + document.getElementById("field-buyerPurchaseOrderNumber").value = ""; + document.getElementById("field-recipientName").value = ""; + document.getElementById("field-recipientCompanyName").value = ""; + document.getElementById("field-addressLine1").value = ""; + document.getElementById("field-addressLine2").value = ""; + document.getElementById("field-addressLine3").value = ""; + document.getElementById("field-city").value = ""; + document.getElementById("field-districtOrCounty").value = ""; + document.getElementById("field-stateOrRegion").value = ""; + document.getElementById("field-municipality").value = ""; + document.getElementById("field-postalCode").value = ""; + document.getElementById("field-countryCode").value = ""; + document.getElementById("field-phone").value = ""; + document.getElementById("field-addressType").value = ""; + document.getElementById("field-dropOffLocation").value = ""; + document.getElementById("field-addressInstruction").value = ""; + document.getElementById("field-grandTotalAmount").value = ""; + document.getElementById("field-grandTotalCurrency").value = ""; + document.getElementById("field-buyerInvoicePreference").value = ""; + document.getElementById("field-invoiceStatus").value = ""; + orderItemsContainer.innerHTML = ""; + document.getElementById("order-aliases-container").innerHTML = ""; + document.getElementById("associated-orders-container").innerHTML = ""; + document.getElementById("proceeds-breakdowns-container").innerHTML = ""; + document.getElementById("payment-executions-container").innerHTML = ""; + document.getElementById("tax-registrations-container").innerHTML = ""; + programsList = []; + renderProgramsTags(); + clearValidationErrors(); + hideFormError(); +} + +function populateEditorForm(order) { + document.getElementById("field-orderId").value = order.orderId || ""; + document.getElementById("field-createdTime").value = order.createdTime || ""; + document.getElementById("field-lastUpdatedTime").value = order.lastUpdatedTime || ""; + document.getElementById("field-channelName").value = (order.salesChannel && order.salesChannel.channelName) || ""; + document.getElementById("field-marketplaceId").value = (order.salesChannel && order.salesChannel.marketplaceId) || ""; + document.getElementById("field-marketplaceName").value = (order.salesChannel && order.salesChannel.marketplaceName) || ""; + + // Fulfillment + const ful = order.fulfillment || {}; + document.getElementById("field-fulfillmentStatus").value = ful.fulfillmentStatus || ""; + document.getElementById("field-fulfilledBy").value = ful.fulfilledBy || ""; + document.getElementById("field-fulfillmentServiceLevel").value = ful.fulfillmentServiceLevel || ""; + const shipBy = ful.shipByWindow || {}; + document.getElementById("field-shipByEarliest").value = shipBy.earliestDateTime || ""; + document.getElementById("field-shipByLatest").value = shipBy.latestDateTime || ""; + const deliverBy = ful.deliverByWindow || {}; + document.getElementById("field-deliverByEarliest").value = deliverBy.earliestDateTime || ""; + document.getElementById("field-deliverByLatest").value = deliverBy.latestDateTime || ""; + + // Buyer + const buyer = order.buyer || {}; + document.getElementById("field-buyerName").value = buyer.buyerName || ""; + document.getElementById("field-buyerEmail").value = buyer.buyerEmail || ""; + document.getElementById("field-buyerCompanyName").value = buyer.buyerCompanyName || ""; + document.getElementById("field-buyerPurchaseOrderNumber").value = buyer.buyerPurchaseOrderNumber || ""; + + // Recipient + const recipient = order.recipient || {}; + const addr = recipient.deliveryAddress || {}; + const deliveryPref = recipient.deliveryPreference || {}; + document.getElementById("field-recipientName").value = addr.name || ""; + document.getElementById("field-recipientCompanyName").value = addr.companyName || ""; + document.getElementById("field-addressLine1").value = addr.addressLine1 || ""; + document.getElementById("field-addressLine2").value = addr.addressLine2 || ""; + document.getElementById("field-addressLine3").value = addr.addressLine3 || ""; + document.getElementById("field-city").value = addr.city || ""; + document.getElementById("field-districtOrCounty").value = addr.districtOrCounty || ""; + document.getElementById("field-stateOrRegion").value = addr.stateOrRegion || ""; + document.getElementById("field-municipality").value = addr.municipality || ""; + document.getElementById("field-postalCode").value = addr.postalCode || ""; + document.getElementById("field-countryCode").value = addr.countryCode || ""; + document.getElementById("field-phone").value = addr.phone || ""; + document.getElementById("field-addressType").value = addr.addressType || ""; + document.getElementById("field-dropOffLocation").value = deliveryPref.dropOffLocation || ""; + document.getElementById("field-addressInstruction").value = deliveryPref.addressInstruction || ""; + + // Proceeds + const proceeds = order.proceeds || {}; + const grandTotal = proceeds.grandTotal || {}; + document.getElementById("field-grandTotalAmount").value = grandTotal.amount || ""; + document.getElementById("field-grandTotalCurrency").value = grandTotal.currencyCode || ""; + (proceeds.breakdowns || []).forEach((b) => addProceedsBreakdown(b)); + + // Payment + const payment = order.payment || {}; + (payment.paymentExecutions || []).forEach((pe) => addPaymentExecution(pe)); + + // Tax + const tax = order.tax || {}; + const taxInvoicing = tax.taxInvoicing || {}; + document.getElementById("field-buyerInvoicePreference").value = taxInvoicing.buyerInvoicePreference || ""; + document.getElementById("field-invoiceStatus").value = taxInvoicing.invoiceStatus || ""; + (tax.taxRegistrations || []).forEach((tr) => addTaxRegistration(tr)); + + // Order Aliases + (order.orderAliases || []).forEach((a) => addAlias(a)); + + // Associated Orders + (order.associatedOrders || []).forEach((ao) => addAssociatedOrder(ao)); + + // Populate order items + const items = order.orderItems || []; + if (items.length === 0) { + addOrderItem(); + } else { + items.forEach((item) => addOrderItem(item)); + } + + // Populate programs + programsList = Array.isArray(order.programs) ? [...order.programs] : []; + renderProgramsTags(); +} + +// ===== Dynamic Section Helpers ===== + +// --- Order Aliases --- +function addAlias(data) { + const container = document.getElementById("order-aliases-container"); + const block = document.createElement("div"); + block.className = "order-item-block"; + block.innerHTML = + '
' + + 'Alias' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + container.appendChild(block); +} + +document.getElementById("btn-add-alias").addEventListener("click", () => addAlias()); +document.getElementById("order-aliases-container").addEventListener("click", (e) => { + if (e.target.closest(".btn-remove-alias")) e.target.closest(".order-item-block").remove(); +}); + +// --- Associated Orders --- +function addAssociatedOrder(data) { + const container = document.getElementById("associated-orders-container"); + const block = document.createElement("div"); + block.className = "order-item-block"; + block.innerHTML = + '
' + + 'Associated Order' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + container.appendChild(block); +} + +document.getElementById("btn-add-associated-order").addEventListener("click", () => addAssociatedOrder()); +document.getElementById("associated-orders-container").addEventListener("click", (e) => { + if (e.target.closest(".btn-remove-associated")) e.target.closest(".order-item-block").remove(); +}); + +// --- Proceeds Breakdowns --- +function addProceedsBreakdown(data) { + const container = document.getElementById("proceeds-breakdowns-container"); + const block = document.createElement("div"); + block.className = "order-item-block"; + const subtotal = (data && data.subtotal) || {}; + block.innerHTML = + '
' + + 'Breakdown' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + container.appendChild(block); +} + +document.getElementById("btn-add-proceeds-breakdown").addEventListener("click", () => addProceedsBreakdown()); +document.getElementById("proceeds-breakdowns-container").addEventListener("click", (e) => { + if (e.target.closest(".btn-remove-breakdown")) e.target.closest(".order-item-block").remove(); +}); + +// --- Payment Executions --- +function addPaymentExecution(data) { + const container = document.getElementById("payment-executions-container"); + const block = document.createElement("div"); + block.className = "order-item-block"; + const paymentAmount = (data && data.paymentAmount) || {}; + block.innerHTML = + '
' + + 'Payment Execution' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + container.appendChild(block); +} + +document.getElementById("btn-add-payment-execution").addEventListener("click", () => addPaymentExecution()); +document.getElementById("payment-executions-container").addEventListener("click", (e) => { + if (e.target.closest(".btn-remove-payment")) e.target.closest(".order-item-block").remove(); +}); + +// --- Tax Registrations --- +function addTaxRegistration(data) { + const container = document.getElementById("tax-registrations-container"); + const block = document.createElement("div"); + block.className = "order-item-block"; + block.innerHTML = + '
' + + 'Tax Registration' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + container.appendChild(block); +} + +document.getElementById("btn-add-tax-registration").addEventListener("click", () => addTaxRegistration()); +document.getElementById("tax-registrations-container").addEventListener("click", (e) => { + if (e.target.closest(".btn-remove-tax-reg")) e.target.closest(".order-item-block").remove(); +}); + +// ===== Order Items Management ===== +let orderItemCount = 0; + +function addOrderItem(data) { + const currentItems = orderItemsContainer.querySelectorAll(".order-item-block"); + if (currentItems.length >= 50) return; + + orderItemCount++; + const idx = orderItemCount; + const block = document.createElement("div"); + block.className = "order-item-block"; + block.setAttribute("data-item-idx", idx); + + const product = (data && data.product) || {}; + const condition = product.condition || {}; + const price = product.price || {}; + const unitPrice = price.unitPrice || {}; + const itemFulfillment = (data && data.fulfillment) || {}; + const cancellation = (data && data.cancellation && data.cancellation.cancellationRequest) || {}; + const itemProceeds = (data && data.proceeds) || {}; + const proceedsTotal = itemProceeds.proceedsTotal || {}; + const itemPrograms = (data && data.programs) || []; + + block.innerHTML = + '
' + + 'Item #' + (currentItems.length + 1) + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + + orderItemsContainer.appendChild(block); + updateItemNumbers(); + updateAddRemoveButtons(); +} + +function removeOrderItem(idx) { + const block = orderItemsContainer.querySelector('.order-item-block[data-item-idx="' + idx + '"]'); + if (block) { + block.remove(); + updateItemNumbers(); + updateAddRemoveButtons(); + } +} + +function updateItemNumbers() { + const blocks = orderItemsContainer.querySelectorAll(".order-item-block"); + blocks.forEach((block, i) => { + block.querySelector(".order-item-block-title").textContent = "Item #" + (i + 1); + }); +} + +function updateAddRemoveButtons() { + const blocks = orderItemsContainer.querySelectorAll(".order-item-block"); + const count = blocks.length; + btnAddItem.disabled = count >= 50; + blocks.forEach((block) => { + const removeBtn = block.querySelector(".btn-remove-item"); + removeBtn.disabled = count <= 1; + }); +} + +btnAddItem.addEventListener("click", () => { + addOrderItem(); +}); + +orderItemsContainer.addEventListener("click", (e) => { + const removeBtn = e.target.closest(".btn-remove-item"); + if (removeBtn) { + const idx = removeBtn.getAttribute("data-item-idx"); + removeOrderItem(idx); + } +}); + +// ===== Programs Management ===== +programInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + const val = programInput.value.trim(); + if (val && !programsList.includes(val)) { + programsList.push(val); + renderProgramsTags(); + } + programInput.value = ""; + } +}); + +function renderProgramsTags() { + programsTagsEl.innerHTML = ""; + programsList.forEach((prog, idx) => { + const tag = document.createElement("span"); + tag.className = "program-tag"; + tag.innerHTML = escapeHtml(prog) + ' '; + programsTagsEl.appendChild(tag); + }); +} + +programsTagsEl.addEventListener("click", (e) => { + const btn = e.target.closest("button[data-prog-idx]"); + if (btn) { + const idx = parseInt(btn.getAttribute("data-prog-idx"), 10); + programsList.splice(idx, 1); + renderProgramsTags(); + } +}); + +// New Order button +btnNewOrder.addEventListener("click", () => { + switchToCreateMode(); +}); + +// Select order from list (edit mode) +function selectOrder(order) { + document.querySelectorAll(".order-row").forEach((r) => r.classList.remove("active")); + const row = document.querySelector('.order-row[data-order-id="' + CSS.escape(order.orderId) + '"]'); + if (row) row.classList.add("active"); + switchToEditMode(order); +} + +// Delegate click on order rows to selectOrder +ordersListContent.addEventListener("click", (e) => { + if (e.target.closest(".order-row-delete")) return; + const row = e.target.closest(".order-row"); + if (row) { + const orderId = row.getAttribute("data-order-id"); + const order = ordersCache.find((o) => o.orderId === orderId); + if (order) selectOrder(order); + } +}); + +// ===== Validation Helpers ===== +function clearValidationErrors() { + document.querySelectorAll("#order-editor-form .invalid").forEach((el) => el.classList.remove("invalid")); + document.querySelectorAll("#order-editor-form .field-error").forEach((el) => { + el.textContent = ""; + el.style.display = "none"; + }); +} + +function showFieldError(input, errorEl, message) { + input.classList.add("invalid"); + errorEl.textContent = message; + errorEl.style.display = "block"; +} + +function hideFormError() { + formErrorMessage.textContent = ""; + formErrorMessage.style.display = "none"; +} + +function showFormError(message) { + formErrorMessage.textContent = message; + formErrorMessage.style.display = "inline"; +} + +// ===== Validation Functions ===== +function validateOrderId(value) { + return /^\d{3}-\d{7}-\d{7}$/.test(value); +} + +function validateISODate(value) { + if (!value) return false; + const d = new Date(value); + return !isNaN(d.getTime()); +} + +function validateQuantity(value) { + const num = Number(value); + return Number.isInteger(num) && num >= 1; +} + +// Full form validation - returns true if valid, false otherwise +function validateOrderForm() { + clearValidationErrors(); + hideFormError(); + let isValid = true; + + // Validate orderId + const orderIdInput = document.getElementById("field-orderId"); + const orderIdError = document.getElementById("error-orderId"); + const orderIdVal = orderIdInput.value.trim(); + if (!orderIdVal) { + showFieldError(orderIdInput, orderIdError, "Order ID is required"); + isValid = false; + } else if (!validateOrderId(orderIdVal)) { + showFieldError(orderIdInput, orderIdError, "Order ID must match format: 123-1234567-1234567"); + isValid = false; + } + + // Validate createdTime + const createdTimeInput = document.getElementById("field-createdTime"); + const createdTimeError = document.getElementById("error-createdTime"); + const createdTimeVal = createdTimeInput.value.trim(); + if (!createdTimeVal) { + showFieldError(createdTimeInput, createdTimeError, "Created Time is required"); + isValid = false; + } else if (!validateISODate(createdTimeVal)) { + showFieldError(createdTimeInput, createdTimeError, "Created Time must be a valid ISO 8601 date-time"); + isValid = false; + } + + // Validate lastUpdatedTime + const lastUpdatedTimeInput = document.getElementById("field-lastUpdatedTime"); + const lastUpdatedTimeError = document.getElementById("error-lastUpdatedTime"); + const lastUpdatedTimeVal = lastUpdatedTimeInput.value.trim(); + if (!lastUpdatedTimeVal) { + showFieldError(lastUpdatedTimeInput, lastUpdatedTimeError, "Last Updated Time is required"); + isValid = false; + } else if (!validateISODate(lastUpdatedTimeVal)) { + showFieldError(lastUpdatedTimeInput, lastUpdatedTimeError, "Last Updated Time must be a valid ISO 8601 date-time"); + isValid = false; + } + + // Validate salesChannel.channelName + const channelNameInput = document.getElementById("field-channelName"); + const channelNameError = document.getElementById("error-channelName"); + if (!channelNameInput.value) { + showFieldError(channelNameInput, channelNameError, "Channel Name is required"); + isValid = false; + } + + // Validate order items + const blocks = orderItemsContainer.querySelectorAll(".order-item-block"); + if (blocks.length === 0) { + const orderItemsError = document.getElementById("error-orderItems"); + orderItemsError.textContent = "At least one order item is required"; + orderItemsError.style.display = "block"; + isValid = false; + } + + blocks.forEach((block) => { + const itemIdInput = block.querySelector(".item-orderItemId"); + const itemIdError = block.querySelector(".item-error-orderItemId"); + const itemIdVal = itemIdInput.value.trim(); + if (!itemIdVal) { + showFieldError(itemIdInput, itemIdError, "Order Item ID is required"); + isValid = false; + } + + const qtyInput = block.querySelector(".item-quantityOrdered"); + const qtyError = block.querySelector(".item-error-quantityOrdered"); + const qtyVal = qtyInput.value.trim(); + if (!qtyVal) { + showFieldError(qtyInput, qtyError, "Quantity is required"); + isValid = false; + } else if (!validateQuantity(qtyVal)) { + showFieldError(qtyInput, qtyError, "Quantity must be an integer >= 1"); + isValid = false; + } + + const asinInput = block.querySelector(".item-asin"); + const asinError = block.querySelector(".item-error-asin"); + const asinVal = asinInput.value.trim(); + if (!asinVal) { + showFieldError(asinInput, asinError, "Product ASIN is required"); + isValid = false; + } + }); + + return isValid; +} + +// ===== Prefill Logic ===== +function generateOrderId() { + const digits = (n) => { + let s = ""; + for (let i = 0; i < n; i++) s += Math.floor(Math.random() * 10); + return s; + }; + return digits(3) + "-" + digits(7) + "-" + digits(7); +} + +function generateAsin() { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let result = "B"; + for (let i = 0; i < 9; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +function generateUniqueOrderId(existingIds) { + let id = generateOrderId(); + let attempts = 0; + while (existingIds.includes(id) && attempts < 1000) { + id = generateOrderId(); + attempts++; + } + return id; +} + +function prefillOrder() { + if (editorMode !== "create") return; + + const existingIds = ordersCache.map((o) => o.orderId); + const orderId = generateUniqueOrderId(existingIds); + const now = new Date().toISOString(); + const shipByDate = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(); + const deliverByDate = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(); + + document.getElementById("field-orderId").value = orderId; + document.getElementById("field-createdTime").value = now; + document.getElementById("field-lastUpdatedTime").value = now; + document.getElementById("field-channelName").value = "AMAZON"; + document.getElementById("field-marketplaceId").value = "ATVPDKIKX0DER"; + document.getElementById("field-marketplaceName").value = "Amazon.com"; + document.getElementById("field-fulfillmentStatus").value = "UNSHIPPED"; + document.getElementById("field-fulfilledBy").value = "MERCHANT"; + document.getElementById("field-fulfillmentServiceLevel").value = "STANDARD"; + document.getElementById("field-shipByEarliest").value = now; + document.getElementById("field-shipByLatest").value = shipByDate; + document.getElementById("field-deliverByEarliest").value = shipByDate; + document.getElementById("field-deliverByLatest").value = deliverByDate; + document.getElementById("field-buyerName").value = "Test Buyer"; + document.getElementById("field-buyerEmail").value = "testbuyer@marketplace.amazon.com"; + document.getElementById("field-recipientName").value = "Test Buyer"; + document.getElementById("field-addressLine1").value = "123 Main Street"; + document.getElementById("field-city").value = "Seattle"; + document.getElementById("field-stateOrRegion").value = "WA"; + document.getElementById("field-postalCode").value = "98101"; + document.getElementById("field-countryCode").value = "US"; + document.getElementById("field-addressType").value = "RESIDENTIAL"; + document.getElementById("field-grandTotalAmount").value = "29.99"; + document.getElementById("field-grandTotalCurrency").value = "USD"; + + orderItemsContainer.innerHTML = ""; + const orderItemId = generateOrderId(); + const asin = generateAsin(); + const sellerSku = "SKU-" + Math.random().toString(36).substring(2, 8).toUpperCase(); + addOrderItem({ + orderItemId: orderItemId, + quantityOrdered: 1, + product: { + asin: asin, + sellerSku: sellerSku, + title: "Sample Product", + condition: { conditionType: "NEW", conditionSubtype: "NEW" }, + price: { unitPrice: { amount: "29.99", currencyCode: "USD" } }, + }, + proceeds: { proceedsTotal: { amount: "29.99", currencyCode: "USD" } }, + fulfillment: { quantityFulfilled: 0, quantityUnfulfilled: 1 }, + }); + + clearValidationErrors(); + hideFormError(); +} + +btnPrefill.addEventListener("click", () => { + prefillOrder(); +}); + +// ===== Collect Form Data ===== +function collectFormData() { + const order = {}; + order.orderId = document.getElementById("field-orderId").value.trim(); + order.createdTime = document.getElementById("field-createdTime").value.trim(); + order.lastUpdatedTime = document.getElementById("field-lastUpdatedTime").value.trim(); + + // Sales Channel + const channelName = document.getElementById("field-channelName").value; + const marketplaceId = document.getElementById("field-marketplaceId").value.trim(); + const marketplaceName = document.getElementById("field-marketplaceName").value.trim(); + order.salesChannel = { channelName }; + if (marketplaceId) order.salesChannel.marketplaceId = marketplaceId; + if (marketplaceName) order.salesChannel.marketplaceName = marketplaceName; + + // Order Aliases + const aliasBlocks = document.getElementById("order-aliases-container").querySelectorAll(".order-item-block"); + if (aliasBlocks.length > 0) { + order.orderAliases = []; + aliasBlocks.forEach((block) => { + const aliasType = block.querySelector(".alias-type").value.trim(); + const aliasId = block.querySelector(".alias-id").value.trim(); + if (aliasType || aliasId) { + order.orderAliases.push({ aliasType, aliasId }); + } + }); + if (order.orderAliases.length === 0) delete order.orderAliases; + } + + // Associated Orders + const assocBlocks = document.getElementById("associated-orders-container").querySelectorAll(".order-item-block"); + if (assocBlocks.length > 0) { + order.associatedOrders = []; + assocBlocks.forEach((block) => { + const assocOrderId = block.querySelector(".assoc-orderId").value.trim(); + const associationType = block.querySelector(".assoc-type").value; + if (assocOrderId || associationType) { + const ao = {}; + if (assocOrderId) ao.orderId = assocOrderId; + if (associationType) ao.associationType = associationType; + order.associatedOrders.push(ao); + } + }); + if (order.associatedOrders.length === 0) delete order.associatedOrders; + } + + // Order items + order.orderItems = []; + const blocks = orderItemsContainer.querySelectorAll(".order-item-block"); + blocks.forEach((block) => { + const item = { + orderItemId: block.querySelector(".item-orderItemId").value.trim(), + quantityOrdered: parseInt(block.querySelector(".item-quantityOrdered").value, 10) || 0, + product: { + asin: block.querySelector(".item-asin").value.trim(), + }, + }; + + const title = block.querySelector(".item-title").value.trim(); + const sellerSku = block.querySelector(".item-sellerSku").value.trim(); + if (title) item.product.title = title; + if (sellerSku) item.product.sellerSku = sellerSku; + + const conditionType = block.querySelector(".item-conditionType").value; + const conditionSubtype = block.querySelector(".item-conditionSubtype").value; + const conditionNote = block.querySelector(".item-conditionNote").value.trim(); + if (conditionType || conditionSubtype || conditionNote) { + item.product.condition = {}; + if (conditionType) item.product.condition.conditionType = conditionType; + if (conditionSubtype) item.product.condition.conditionSubtype = conditionSubtype; + if (conditionNote) item.product.condition.conditionNote = conditionNote; + } + + const unitPriceAmount = block.querySelector(".item-unitPriceAmount").value.trim(); + const unitPriceCurrency = block.querySelector(".item-unitPriceCurrency").value.trim(); + const priceDesignation = block.querySelector(".item-priceDesignation").value.trim(); + if (unitPriceAmount || unitPriceCurrency || priceDesignation) { + item.product.price = {}; + if (unitPriceAmount || unitPriceCurrency) { + item.product.price.unitPrice = {}; + if (unitPriceAmount) item.product.price.unitPrice.amount = unitPriceAmount; + if (unitPriceCurrency) item.product.price.unitPrice.currencyCode = unitPriceCurrency; + } + if (priceDesignation) item.product.price.priceDesignation = priceDesignation; + } + + const qtyFulfilled = block.querySelector(".item-quantityFulfilled").value.trim(); + const qtyUnfulfilled = block.querySelector(".item-quantityUnfulfilled").value.trim(); + if (qtyFulfilled || qtyUnfulfilled) { + item.fulfillment = {}; + if (qtyFulfilled) item.fulfillment.quantityFulfilled = parseInt(qtyFulfilled, 10); + if (qtyUnfulfilled) item.fulfillment.quantityUnfulfilled = parseInt(qtyUnfulfilled, 10); + } + + const proceedsTotalAmount = block.querySelector(".item-proceedsTotalAmount").value.trim(); + const proceedsTotalCurrency = block.querySelector(".item-proceedsTotalCurrency").value.trim(); + if (proceedsTotalAmount || proceedsTotalCurrency) { + item.proceeds = { proceedsTotal: {} }; + if (proceedsTotalAmount) item.proceeds.proceedsTotal.amount = proceedsTotalAmount; + if (proceedsTotalCurrency) item.proceeds.proceedsTotal.currencyCode = proceedsTotalCurrency; + } + + const cancelRequester = block.querySelector(".item-cancelRequester").value.trim(); + const cancelReason = block.querySelector(".item-cancelReason").value.trim(); + if (cancelRequester || cancelReason) { + item.cancellation = { cancellationRequest: {} }; + if (cancelRequester) item.cancellation.cancellationRequest.requester = cancelRequester; + if (cancelReason) item.cancellation.cancellationRequest.cancelReason = cancelReason; + } + + const itemProgramsVal = block.querySelector(".item-programs").value.trim(); + if (itemProgramsVal) { + item.programs = itemProgramsVal.split(",").map((s) => s.trim()).filter(Boolean); + } + + order.orderItems.push(item); + }); + + // Fulfillment + const fulfillmentStatus = document.getElementById("field-fulfillmentStatus").value; + const fulfilledBy = document.getElementById("field-fulfilledBy").value; + const fulfillmentServiceLevel = document.getElementById("field-fulfillmentServiceLevel").value; + const shipByEarliest = document.getElementById("field-shipByEarliest").value.trim(); + const shipByLatest = document.getElementById("field-shipByLatest").value.trim(); + const deliverByEarliest = document.getElementById("field-deliverByEarliest").value.trim(); + const deliverByLatest = document.getElementById("field-deliverByLatest").value.trim(); + if (fulfillmentStatus || fulfilledBy || fulfillmentServiceLevel || shipByEarliest || shipByLatest || deliverByEarliest || deliverByLatest) { + order.fulfillment = {}; + if (fulfillmentStatus) order.fulfillment.fulfillmentStatus = fulfillmentStatus; + if (fulfilledBy) order.fulfillment.fulfilledBy = fulfilledBy; + if (fulfillmentServiceLevel) order.fulfillment.fulfillmentServiceLevel = fulfillmentServiceLevel; + if (shipByEarliest || shipByLatest) { + order.fulfillment.shipByWindow = {}; + if (shipByEarliest) order.fulfillment.shipByWindow.earliestDateTime = shipByEarliest; + if (shipByLatest) order.fulfillment.shipByWindow.latestDateTime = shipByLatest; + } + if (deliverByEarliest || deliverByLatest) { + order.fulfillment.deliverByWindow = {}; + if (deliverByEarliest) order.fulfillment.deliverByWindow.earliestDateTime = deliverByEarliest; + if (deliverByLatest) order.fulfillment.deliverByWindow.latestDateTime = deliverByLatest; + } + } + + // Buyer + const buyerName = document.getElementById("field-buyerName").value.trim(); + const buyerEmail = document.getElementById("field-buyerEmail").value.trim(); + const buyerCompanyName = document.getElementById("field-buyerCompanyName").value.trim(); + const buyerPurchaseOrderNumber = document.getElementById("field-buyerPurchaseOrderNumber").value.trim(); + if (buyerName || buyerEmail || buyerCompanyName || buyerPurchaseOrderNumber) { + order.buyer = {}; + if (buyerName) order.buyer.buyerName = buyerName; + if (buyerEmail) order.buyer.buyerEmail = buyerEmail; + if (buyerCompanyName) order.buyer.buyerCompanyName = buyerCompanyName; + if (buyerPurchaseOrderNumber) order.buyer.buyerPurchaseOrderNumber = buyerPurchaseOrderNumber; + } + + // Recipient + const recipientName = document.getElementById("field-recipientName").value.trim(); + const recipientCompanyName = document.getElementById("field-recipientCompanyName").value.trim(); + const addressLine1 = document.getElementById("field-addressLine1").value.trim(); + const addressLine2 = document.getElementById("field-addressLine2").value.trim(); + const addressLine3 = document.getElementById("field-addressLine3").value.trim(); + const city = document.getElementById("field-city").value.trim(); + const districtOrCounty = document.getElementById("field-districtOrCounty").value.trim(); + const stateOrRegion = document.getElementById("field-stateOrRegion").value.trim(); + const municipality = document.getElementById("field-municipality").value.trim(); + const postalCode = document.getElementById("field-postalCode").value.trim(); + const countryCode = document.getElementById("field-countryCode").value.trim(); + const phone = document.getElementById("field-phone").value.trim(); + const addressType = document.getElementById("field-addressType").value; + const dropOffLocation = document.getElementById("field-dropOffLocation").value.trim(); + const addressInstruction = document.getElementById("field-addressInstruction").value.trim(); + + const hasAddress = recipientName || recipientCompanyName || addressLine1 || addressLine2 || addressLine3 || city || districtOrCounty || stateOrRegion || municipality || postalCode || countryCode || phone || addressType; + const hasDeliveryPref = dropOffLocation || addressInstruction; + + if (hasAddress || hasDeliveryPref) { + order.recipient = {}; + if (hasAddress) { + order.recipient.deliveryAddress = {}; + if (recipientName) order.recipient.deliveryAddress.name = recipientName; + if (recipientCompanyName) order.recipient.deliveryAddress.companyName = recipientCompanyName; + if (addressLine1) order.recipient.deliveryAddress.addressLine1 = addressLine1; + if (addressLine2) order.recipient.deliveryAddress.addressLine2 = addressLine2; + if (addressLine3) order.recipient.deliveryAddress.addressLine3 = addressLine3; + if (city) order.recipient.deliveryAddress.city = city; + if (districtOrCounty) order.recipient.deliveryAddress.districtOrCounty = districtOrCounty; + if (stateOrRegion) order.recipient.deliveryAddress.stateOrRegion = stateOrRegion; + if (municipality) order.recipient.deliveryAddress.municipality = municipality; + if (postalCode) order.recipient.deliveryAddress.postalCode = postalCode; + if (countryCode) order.recipient.deliveryAddress.countryCode = countryCode; + if (phone) order.recipient.deliveryAddress.phone = phone; + if (addressType) order.recipient.deliveryAddress.addressType = addressType; + } + if (hasDeliveryPref) { + order.recipient.deliveryPreference = {}; + if (dropOffLocation) order.recipient.deliveryPreference.dropOffLocation = dropOffLocation; + if (addressInstruction) order.recipient.deliveryPreference.addressInstruction = addressInstruction; + } + } + + // Programs + if (programsList.length > 0) { + order.programs = [...programsList]; + } + + // Proceeds + const grandTotalAmount = document.getElementById("field-grandTotalAmount").value.trim(); + const grandTotalCurrency = document.getElementById("field-grandTotalCurrency").value.trim(); + const breakdownBlocks = document.getElementById("proceeds-breakdowns-container").querySelectorAll(".order-item-block"); + if (grandTotalAmount || grandTotalCurrency || breakdownBlocks.length > 0) { + order.proceeds = {}; + if (grandTotalAmount || grandTotalCurrency) { + order.proceeds.grandTotal = {}; + if (grandTotalAmount) order.proceeds.grandTotal.amount = grandTotalAmount; + if (grandTotalCurrency) order.proceeds.grandTotal.currencyCode = grandTotalCurrency; + } + if (breakdownBlocks.length > 0) { + order.proceeds.breakdowns = []; + breakdownBlocks.forEach((block) => { + const bType = block.querySelector(".breakdown-type").value; + const bAmount = block.querySelector(".breakdown-amount").value.trim(); + const bCurrency = block.querySelector(".breakdown-currency").value.trim(); + const bStatus = block.querySelector(".breakdown-status").value.trim(); + if (bType || bAmount || bCurrency) { + const breakdown = {}; + if (bType) breakdown.type = bType; + if (bAmount || bCurrency) { + breakdown.subtotal = {}; + if (bAmount) breakdown.subtotal.amount = bAmount; + if (bCurrency) breakdown.subtotal.currencyCode = bCurrency; + } + if (bStatus) breakdown.status = bStatus; + order.proceeds.breakdowns.push(breakdown); + } + }); + if (order.proceeds.breakdowns.length === 0) delete order.proceeds.breakdowns; + } + } + + // Payment + const paymentBlocks = document.getElementById("payment-executions-container").querySelectorAll(".order-item-block"); + if (paymentBlocks.length > 0) { + const executions = []; + paymentBlocks.forEach((block) => { + const method = block.querySelector(".payment-method").value.trim(); + const amount = block.querySelector(".payment-amount").value.trim(); + const currency = block.querySelector(".payment-currency").value.trim(); + const acquirerId = block.querySelector(".payment-acquirerId").value.trim(); + const cardBrand = block.querySelector(".payment-cardBrand").value.trim(); + const authCode = block.querySelector(".payment-authCode").value.trim(); + if (method || amount || currency) { + const pe = {}; + if (method) pe.paymentMethod = method; + if (amount || currency) { + pe.paymentAmount = {}; + if (amount) pe.paymentAmount.amount = amount; + if (currency) pe.paymentAmount.currencyCode = currency; + } + if (acquirerId) pe.acquirerId = acquirerId; + if (cardBrand) pe.cardBrand = cardBrand; + if (authCode) pe.authorizationCode = authCode; + executions.push(pe); + } + }); + if (executions.length > 0) { + order.payment = { paymentExecutions: executions }; + } + } + + // Tax + const buyerInvoicePreference = document.getElementById("field-buyerInvoicePreference").value; + const invoiceStatus = document.getElementById("field-invoiceStatus").value; + const taxRegBlocks = document.getElementById("tax-registrations-container").querySelectorAll(".order-item-block"); + if (buyerInvoicePreference || invoiceStatus || taxRegBlocks.length > 0) { + order.tax = {}; + if (buyerInvoicePreference || invoiceStatus) { + order.tax.taxInvoicing = {}; + if (buyerInvoicePreference) order.tax.taxInvoicing.buyerInvoicePreference = buyerInvoicePreference; + if (invoiceStatus) order.tax.taxInvoicing.invoiceStatus = invoiceStatus; + } + if (taxRegBlocks.length > 0) { + order.tax.taxRegistrations = []; + taxRegBlocks.forEach((block) => { + const entityType = block.querySelector(".taxreg-entityType").value; + const legalName = block.querySelector(".taxreg-legalName").value.trim(); + const regType = block.querySelector(".taxreg-type").value; + const regNumber = block.querySelector(".taxreg-number").value.trim(); + if (entityType || legalName || regType || regNumber) { + const tr = {}; + if (entityType) tr.entityType = entityType; + if (legalName) tr.legalName = legalName; + if (regType) tr.taxRegistrationType = regType; + if (regNumber) tr.taxRegistrationNumber = regNumber; + order.tax.taxRegistrations.push(tr); + } + }); + if (order.tax.taxRegistrations.length === 0) delete order.tax.taxRegistrations; + } + } + + return order; +} + +// ===== Form Submit Handler ===== +document.getElementById("order-editor-form").addEventListener("submit", async (e) => { + e.preventDefault(); + hideFormError(); + + if (!validateOrderForm()) { + return; + } + + const orderData = collectFormData(); + btnSubmitOrder.disabled = true; + + try { + if (editorMode === "create") { + const res = await fetch("/manage/orders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(orderData), + }); + if (res.ok) { + await fetchOrders(); + switchToCreateMode(); + } else { + const data = await res.json().catch(() => ({})); + showFormError(data.error || "Failed to create order (HTTP " + res.status + ")"); + } + } else { + const res = await fetch("/manage/orders", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(orderData), + }); + if (res.ok) { + await fetchOrders(); + const updatedOrder = ordersCache.find((o) => o.orderId === editingOrderId); + if (updatedOrder) switchToEditMode(updatedOrder); + } else { + const data = await res.json().catch(() => ({})); + showFormError(data.error || "Failed to update order (HTTP " + res.status + ")"); + } + } + } catch (err) { + showFormError("Network error. Please check your connection."); + } finally { + btnSubmitOrder.disabled = false; + } +}); + +// ===== Guided Scenarios ===== +const scenariosBody = document.getElementById("scenarios-body"); + +function chipLabel(status) { + return status === "runnable" ? "Runnable" : status === "pass-through" ? "Pass-through" : "Planned"; +} + +function renderTrack(track) { + const section = document.createElement("details"); + section.className = "scenario-steps"; + const summary = document.createElement("summary"); + summary.innerHTML = '' + escapeHtml(track.title) + ' — ' + track.steps.length + ' steps (' + track.runnableCount + ' runnable)'; + section.appendChild(summary); + + const desc = document.createElement("div"); + desc.style.cssText = "font-size: 12px; color: var(--color-text-body-secondary); padding: var(--space-xs) 0 var(--space-s) 0;"; + desc.textContent = track.description; + section.appendChild(desc); + + track.steps.forEach(function (step, i) { + const row = document.createElement("div"); + row.className = "scenario-step"; + + const chip = document.createElement("span"); + chip.className = "step-chip " + step.status; + chip.textContent = chipLabel(step.status); + + const main = document.createElement("div"); + main.className = "step-main"; + const stepTitle = document.createElement("div"); + stepTitle.className = "step-title"; + stepTitle.textContent = (i + 1) + ". " + step.title; + const stepPath = document.createElement("div"); + stepPath.className = "step-path"; + stepPath.textContent = step.method + " " + step.path; + const stepNote = document.createElement("div"); + stepNote.className = "step-note"; + stepNote.textContent = step.note; + main.appendChild(stepTitle); + main.appendChild(stepPath); + main.appendChild(stepNote); + + // Try it button for runnable steps + if (step.status === "runnable") { + const tryBtn = document.createElement("button"); + tryBtn.className = "btn btn-normal btn-seed"; + tryBtn.textContent = "Try it"; + tryBtn.style.cssText = "margin-top: var(--space-s); font-size: 12px; padding: 4px 12px;"; + tryBtn.addEventListener("click", async function () { + tryBtn.disabled = true; + tryBtn.textContent = "Running..."; + // Remove previous inline result if any + const prevResult = main.querySelector(".step-result"); + if (prevResult) prevResult.remove(); + + try { + const fetchOpts = { method: step.method, headers: { "Content-Type": "application/json" } }; + if (step.body && !["GET", "HEAD", "DELETE"].includes(step.method.toUpperCase())) { + fetchOpts.body = JSON.stringify(step.body); + } + const res = await fetch(step.path, fetchOpts); + const text = await res.text(); + let display; + try { + display = JSON.stringify(JSON.parse(text), null, 2); + } catch (e) { + display = text; + } + + const resultBox = document.createElement("div"); + resultBox.className = "step-result"; + resultBox.style.cssText = "margin-top: var(--space-s); border-radius: var(--border-radius-input); padding: var(--space-s) var(--space-m); font-size: 12px; overflow-x: auto; max-height: 200px; overflow-y: auto;"; + if (res.ok) { + resultBox.style.background = "var(--color-background-status-success)"; + resultBox.style.border = "1px solid var(--color-border-status-success)"; + } else { + resultBox.style.background = "var(--color-background-status-error)"; + resultBox.style.border = "1px solid var(--color-border-status-error)"; + } + const statusLine = document.createElement("div"); + statusLine.style.cssText = "font-weight: 700; margin-bottom: var(--space-xs); color: " + (res.ok ? "var(--color-text-status-success)" : "var(--color-text-status-error)") + ";"; + statusLine.textContent = "HTTP " + res.status; + resultBox.appendChild(statusLine); + const pre = document.createElement("pre"); + pre.style.cssText = "margin: 0; white-space: pre-wrap; word-break: break-word; font-family: monospace; font-size: 12px;"; + pre.textContent = display || "(empty response)"; + resultBox.appendChild(pre); + main.appendChild(resultBox); + } catch (err) { + const resultBox = document.createElement("div"); + resultBox.className = "step-result"; + resultBox.style.cssText = "margin-top: var(--space-s); padding: var(--space-s) var(--space-m); background: var(--color-background-status-error); border: 1px solid var(--color-border-status-error); border-radius: var(--border-radius-input); font-size: 12px; color: var(--color-text-status-error);"; + resultBox.textContent = "Request failed: " + err.message; + main.appendChild(resultBox); + } finally { + tryBtn.textContent = "Try it"; + tryBtn.disabled = false; + } + }); + main.appendChild(tryBtn); + } + + row.appendChild(chip); + row.appendChild(main); + section.appendChild(row); + }); + + return section; +} + +function renderScenarioCard(scenario) { + const card = document.createElement("div"); + card.className = "scenario-card"; + + const header = document.createElement("div"); + header.className = "scenario-card-header"; + + const titleWrap = document.createElement("div"); + const title = document.createElement("div"); + title.className = "scenario-title"; + title.textContent = scenario.title; + const tagline = document.createElement("div"); + tagline.className = "scenario-tagline"; + tagline.textContent = scenario.tagline; + titleWrap.appendChild(title); + titleWrap.appendChild(tagline); + + const actions = document.createElement("div"); + actions.className = "scenario-actions"; + const seedBtn = document.createElement("button"); + seedBtn.className = "btn btn-primary btn-seed"; + seedBtn.textContent = scenario.seedCount > 0 ? "Seed data" : "No seed data yet"; + seedBtn.disabled = scenario.seedCount === 0; + seedBtn.addEventListener("click", async function () { + seedBtn.disabled = true; + const original = seedBtn.textContent; + seedBtn.textContent = "Seeding..."; + try { + const res = await fetch("/scenarios/" + scenario.id + "/seed", { method: "POST" }); + const data = await res.json(); + if (res.ok) { + showResult(data.message, false); + } else { + showError((data.errors && data.errors[0] && data.errors[0].message) || "Seeding failed", "SeedError", res.status); + } + } catch (err) { + showError("Seeding failed: " + err.message, "NetworkError"); + } finally { + seedBtn.textContent = original; + seedBtn.disabled = false; + } + }); + actions.appendChild(seedBtn); + + header.appendChild(titleWrap); + header.appendChild(actions); + card.appendChild(header); + + // Track summary line + const totalSteps = scenario.tracks.reduce(function (sum, t) { + return sum + t.steps.length; + }, 0); + const totalRunnable = scenario.tracks.reduce(function (sum, t) { + return sum + t.runnableCount; + }, 0); + const trackSummary = document.createElement("div"); + trackSummary.style.cssText = "font-size: 12px; color: var(--color-text-body-secondary); padding: var(--space-s) 0 var(--space-m) 0;"; + trackSummary.textContent = scenario.tracks.length + " tracks · " + totalSteps + " steps · " + totalRunnable + " runnable now"; + card.appendChild(trackSummary); + + // Render each track + scenario.tracks.forEach(function (track) { + card.appendChild(renderTrack(track)); + }); + + return card; +} + +async function loadScenarioCards() { + try { + const res = await fetch("/scenarios"); + const data = await res.json(); + if (!res.ok) { + throw new Error((data.errors && data.errors[0] && data.errors[0].message) || "Server error (HTTP " + res.status + ")"); + } + scenariosBody.textContent = ""; + const list = document.createElement("div"); + list.className = "scenario-list"; + data.scenarios.forEach(function (s) { + list.appendChild(renderScenarioCard(s)); + }); + scenariosBody.appendChild(list); + } catch (err) { + scenariosBody.textContent = "Failed to load scenarios: " + err.message; + } +} + +loadScenarioCards(); diff --git a/local-ai-sandbox/public/index.html b/local-ai-sandbox/public/index.html index 7d6c93346..fdc349de9 100644 --- a/local-ai-sandbox/public/index.html +++ b/local-ai-sandbox/public/index.html @@ -4,66 +4,7 @@ AI Sandbox Data Generator - + @@ -72,739 +13,585 @@
Amazon Selling Partner API
-
- - -
+ + +
-
- -
-

Generate Test Data

-
-
- - + +
+
+ +
+

Generate Test Data

+
+
+ + +
+
+
+
-
- -
-
- -
- - -
- © 2026, Amazon.com Services LLC. -
- - - - - + diff --git a/local-ai-sandbox/public/styles.css b/local-ai-sandbox/public/styles.css new file mode 100644 index 000000000..d28785933 --- /dev/null +++ b/local-ai-sandbox/public/styles.css @@ -0,0 +1,1048 @@ +/* Cloudscape Design Tokens */ +:root { + --color-background-layout-main: #ffffff; + --color-background-container-content: #ffffff; + --color-background-container-header: #ffffff; + --color-background-navigation: #171d25; + --color-text-heading-default: #000716; + --color-text-body-default: #000716; + --color-text-body-secondary: #414d5c; + --color-text-interactive-default: #0972d3; + --color-text-interactive-hover: #033160; + --color-text-inverse-default: #ffffff; + --color-border-divider-default: #e9ebed; + --color-border-container-top: transparent; + --color-border-input-default: #7d8998; + --color-border-input-focused: #0972d3; + --color-background-button-primary-default: #0972d3; + --color-background-button-primary-hover: #033160; + --color-background-button-primary-active: #033160; + --color-background-input-default: #ffffff; + --color-background-status-info: #f2f8fd; + --color-background-status-success: #f2fcf3; + --color-background-status-error: #fff7f7; + --color-text-status-info: #0972d3; + --color-text-status-success: #037f0c; + --color-text-status-error: #d91515; + --color-border-status-info: #0972d3; + --color-border-status-success: #037f0c; + --color-border-status-error: #d91515; + --shadow-container: 0 1px 1px 0 rgba(0, 28, 36, 0.3), 1px 1px 1px 0 rgba(0, 28, 36, 0.15), -1px 1px 1px 0 rgba(0, 28, 36, 0.15); + --font-family-base: "Amazon Ember", "Helvetica Neue", Roboto, Arial, sans-serif; + --space-xs: 4px; + --space-s: 8px; + --space-m: 12px; + --space-l: 16px; + --space-xl: 20px; + --space-xxl: 24px; + --space-xxxl: 32px; + --border-radius-container: 16px; + --border-radius-button: 8px; + --border-radius-input: 8px; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-family-base); + background-color: var(--color-background-layout-main); + color: var(--color-text-body-default); + line-height: 1.5; + min-height: 100vh; +} + +/* Top Navigation */ +.top-nav { + background-color: var(--color-background-navigation); + color: var(--color-text-inverse-default); + height: 56px; + display: flex; + align-items: center; + padding: 0 var(--space-xl); + position: sticky; + top: 0; + z-index: 100; +} +.top-nav-inner { + display: flex; + align-items: center; + width: 100%; + max-width: 1200px; +} +.top-nav-brand { + display: flex; + align-items: center; + gap: var(--space-s); + font-size: 16px; + font-weight: 700; + letter-spacing: 0.2px; + white-space: nowrap; +} +.top-nav-title { + flex: 1; + text-align: center; + font-size: 14px; + color: #8d99a8; + font-weight: 400; +} + +/* Tab Bar */ +.tab-bar { + display: flex; + align-items: stretch; + background: var(--color-background-container-content); + border-bottom: 1px solid var(--color-border-divider-default); + padding: 0 var(--space-xl); + position: sticky; + top: 56px; + z-index: 99; +} +.tab { + display: inline-flex; + align-items: center; + gap: var(--space-s); + padding: var(--space-m) var(--space-l); + border: none; + background: none; + font-family: var(--font-family-base); + font-size: 14px; + font-weight: 600; + color: var(--color-text-body-secondary); + cursor: pointer; + border-bottom: 3px solid transparent; + transition: color 0.15s, border-color 0.15s; + white-space: nowrap; +} +.tab:hover { + color: var(--color-text-interactive-hover); +} +.tab.active { + color: var(--color-text-interactive-default); + border-bottom-color: var(--color-text-interactive-default); +} +.tab:focus-visible { + outline: 2px solid var(--color-border-input-focused); + outline-offset: -2px; + border-radius: 4px 4px 0 0; +} + +/* Tab Panels */ +.tab-panel { + display: none; +} +.tab-panel.active { + display: block; +} + +/* Data Viewer */ +.data-viewer-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-xl); +} +.data-viewer-header .btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} +#data-viewer-container { + min-height: 300px; + overflow: hidden; + display: flex; + flex-direction: column; + border: none; + box-shadow: none; +} +.data-viewer-body { + flex: 1; + padding: var(--space-l); + overflow-y: auto; + color: var(--color-text-body-default); + background: var(--color-background-layout-main); +} + +/* Empty state */ +.empty-state-text { + font-size: 14px; + color: var(--color-text-body-secondary); + margin-top: var(--space-s); +} + +/* Layout */ +.layout-main { + padding: var(--space-xxxl) var(--space-xl); + min-height: calc(100vh - 56px - 48px - 45px); +} +.content-wrapper { + max-width: 800px; + margin: 0 auto; + display: flex; + flex-direction: column; +} + +/* Container (Cloudscape-style card) */ +.container { + background: var(--color-background-container-content); + border-radius: var(--border-radius-container); + box-shadow: var(--shadow-container); + overflow: hidden; +} +.container-header { + padding: var(--space-xl) var(--space-xxl); +} +.container-header h2 { + font-size: 18px; + font-weight: 700; + color: var(--color-text-heading-default); + margin-bottom: var(--space-xs); +} +.header-description { + font-size: 14px; + color: var(--color-text-body-secondary); + margin: 0; +} +.container-body { + padding: var(--space-xxl); +} + +/* Form Elements */ +.form-textarea { + width: 100%; + padding: var(--space-s) var(--space-m); + border: 1px solid var(--color-border-input-default); + border-radius: var(--border-radius-input); + font-family: var(--font-family-base); + font-size: 14px; + color: var(--color-text-body-default); + background: var(--color-background-input-default); + resize: none; + transition: border-color 0.15s; + line-height: 1.5; +} +.form-textarea:focus { + outline: none; + border-color: var(--color-border-input-focused); + box-shadow: 0 0 0 1px var(--color-border-input-focused); +} +.form-textarea::placeholder { + color: #8d99a8; + font-style: italic; +} + +/* Buttons */ +.form-actions { + display: flex; + gap: var(--space-s); + justify-content: flex-end; +} +.btn { + display: inline-flex; + align-items: center; + gap: var(--space-s); + padding: 8px 20px; + border-radius: var(--border-radius-button); + font-family: var(--font-family-base); + font-size: 14px; + font-weight: 700; + cursor: pointer; + border: 1px solid transparent; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; + letter-spacing: 0.2px; +} +.btn-primary { + background: var(--color-background-button-primary-default); + color: var(--color-text-inverse-default); + border-color: var(--color-background-button-primary-default); +} +.btn-primary:hover { + background: var(--color-background-button-primary-hover); + border-color: var(--color-background-button-primary-hover); +} +.btn-primary:disabled { + background: #c6c6cd; + border-color: #c6c6cd; + cursor: not-allowed; +} +.btn-normal { + background: var(--color-background-container-content); + color: var(--color-text-body-default); + border-color: var(--color-border-input-default); +} +.btn-normal:hover { + background: #f4f4f4; + border-color: var(--color-text-body-default); +} +.btn-icon { + flex-shrink: 0; +} + +/* Textarea with embedded send button */ +.textarea-wrapper { + position: relative; +} +.btn-send { + position: absolute; + right: 8px; + bottom: 8px; + background: none; + border: none; + cursor: pointer; + color: var(--color-text-body-secondary); + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s; +} +.btn-send:hover { + color: var(--color-background-button-primary-default); +} +.btn-send:disabled { + color: #c6c6cd; + cursor: not-allowed; +} + +/* Hero Section */ +.hero-title { + font-size: 28px; + font-weight: 700; + color: var(--color-text-heading-default); + margin-bottom: var(--space-xs); +} + +/* Response */ +.response-header-row { + display: flex; + align-items: center; + justify-content: space-between; +} +.response-header-row h2 { + margin-bottom: 0; +} +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + padding: 4px 10px; + border-radius: 16px; +} +.status-badge.success { + background: var(--color-background-status-success); + color: var(--color-text-status-success); +} +.status-badge.error { + background: var(--color-background-status-error); + color: var(--color-text-status-error); +} +.status-badge.info { + background: var(--color-background-status-info); + color: var(--color-text-status-info); +} +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} +.response-content { + font-size: 14px; + color: var(--color-text-body-default); + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; +} + +/* Loading */ +.loading-body { + display: flex; + align-items: center; + gap: var(--space-m); + padding: var(--space-xl) var(--space-xxl); +} +.spinner { + width: 20px; + height: 20px; + border: 3px solid var(--color-border-divider-default); + border-top-color: var(--color-background-button-primary-default); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.loading-text { + font-size: 14px; + color: var(--color-text-body-secondary); +} + +/* Error Detail */ +.error-detail { + background: var(--color-background-status-error); + border: 1px solid var(--color-border-status-error); + border-radius: var(--border-radius-input); + padding: var(--space-l); +} +.error-detail-header { + display: flex; + align-items: center; + gap: var(--space-s); + margin-bottom: var(--space-s); +} +.error-type-badge { + display: inline-block; + font-size: 12px; + font-weight: 700; + font-family: monospace; + background: rgba(217, 21, 21, 0.1); + color: var(--color-text-status-error); + padding: 2px 8px; + border-radius: 4px; +} +.error-status-code { + display: inline-block; + font-size: 12px; + font-weight: 600; + color: var(--color-text-body-secondary); + padding: 2px 8px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.05); +} +.error-detail-message { + font-size: 14px; + color: var(--color-text-status-error); + line-height: 1.6; + margin: 0; +} + +/* Footer */ +.footer { + height: 48px; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-s); + font-size: 12px; + color: #c6c6cd; + border-top: 1px solid var(--color-border-divider-default); + background: #171d25; +} +.footer-sep { + color: var(--color-border-divider-default); +} + +/* Orders Split Panel */ +.orders-split-panel { + display: flex; + height: calc(100vh - 56px - 48px - 45px); + overflow: hidden; + margin: calc(-1 * var(--space-xxxl)) calc(-1 * var(--space-xl)); +} +.orders-list-panel { + width: 35%; + min-width: 280px; + border-right: 1px solid var(--color-border-divider-default); + display: flex; + flex-direction: column; + overflow: hidden; +} +.orders-editor-panel { + width: 65%; + overflow-y: auto; + padding: var(--space-xxl); +} +.orders-list-header { + padding: var(--space-l) var(--space-xl); + border-bottom: 1px solid var(--color-border-divider-default); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} +.orders-list-title { + font-size: 18px; + font-weight: 700; + color: var(--color-text-heading-default); +} +.orders-list-content { + flex: 1; + overflow-y: auto; + padding: var(--space-s); +} +.orders-empty-state { + font-size: 14px; + color: var(--color-text-body-secondary); + padding: var(--space-xl); + text-align: center; +} +.orders-error-state { + font-size: 14px; + color: var(--color-text-status-error); + padding: var(--space-xl); + text-align: center; +} +.orders-editor-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +/* Order Editor Form Styles */ +.orders-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-xl); +} +.orders-editor-title { + font-size: 18px; + font-weight: 700; + color: var(--color-text-heading-default); +} +.order-form-section { + margin-bottom: var(--space-xxl); +} +.order-form-section-title { + font-size: 14px; + font-weight: 700; + color: var(--color-text-heading-default); + margin-bottom: var(--space-m); + padding-bottom: var(--space-xs); + border-bottom: 1px solid var(--color-border-divider-default); +} +.order-form-row { + display: flex; + gap: var(--space-m); + margin-bottom: var(--space-m); + flex-wrap: wrap; +} +.order-form-field { + display: flex; + flex-direction: column; + flex: 1; + min-width: 160px; +} +.order-form-field label { + font-size: 12px; + font-weight: 600; + color: var(--color-text-body-secondary); + margin-bottom: var(--space-xs); +} +.order-form-field input, +.order-form-field select { + padding: 6px 10px; + border: 1px solid var(--color-border-input-default); + border-radius: var(--border-radius-input); + font-family: var(--font-family-base); + font-size: 13px; + color: var(--color-text-body-default); + background: var(--color-background-input-default); + transition: border-color 0.15s; +} +.order-form-field input:focus, +.order-form-field select:focus { + outline: none; + border-color: var(--color-border-input-focused); + box-shadow: 0 0 0 1px var(--color-border-input-focused); +} +.order-form-field input:read-only { + background: #f4f4f4; + color: var(--color-text-body-secondary); +} +.order-form-field .field-error { + font-size: 11px; + color: var(--color-text-status-error); + margin-top: 2px; + display: none; +} +.order-form-field input.invalid, +.order-form-field select.invalid { + border-color: var(--color-border-status-error); +} +.order-item-block { + border: 1px solid var(--color-border-divider-default); + border-radius: var(--border-radius-input); + padding: var(--space-m); + margin-bottom: var(--space-m); + position: relative; +} +.order-item-block-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-s); +} +.order-item-block-title { + font-size: 12px; + font-weight: 700; + color: var(--color-text-body-secondary); +} +.btn-remove-item { + background: none; + border: none; + cursor: pointer; + color: var(--color-text-body-secondary); + padding: 4px; + border-radius: 4px; + font-size: 12px; + font-family: var(--font-family-base); + display: flex; + align-items: center; + gap: 4px; + transition: color 0.15s, background 0.15s; +} +.btn-remove-item:hover { + color: var(--color-text-status-error); + background: var(--color-background-status-error); +} +.btn-remove-item:disabled { + color: #c6c6cd; + cursor: not-allowed; +} +.btn-add-item { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: 6px 14px; + border: 1px dashed var(--color-border-input-default); + border-radius: var(--border-radius-input); + background: none; + cursor: pointer; + font-size: 13px; + font-family: var(--font-family-base); + color: var(--color-text-interactive-default); + transition: border-color 0.15s, background 0.15s; +} +.btn-add-item:hover { + border-color: var(--color-text-interactive-default); + background: var(--color-background-status-info); +} +.btn-add-item:disabled { + color: #c6c6cd; + border-color: #c6c6cd; + cursor: not-allowed; +} +.order-form-actions { + display: flex; + gap: var(--space-s); + align-items: center; + padding-top: var(--space-l); + border-top: 1px solid var(--color-border-divider-default); +} +.order-form-actions .form-error-message { + font-size: 13px; + color: var(--color-text-status-error); + margin-left: auto; + display: none; +} +.programs-input-wrapper { + display: flex; + gap: var(--space-s); + align-items: center; + flex-wrap: wrap; +} +.programs-input-wrapper input { + flex: 1; + min-width: 150px; +} +.programs-tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + margin-top: var(--space-xs); +} +.program-tag { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--color-background-status-info); + color: var(--color-text-status-info); + font-size: 12px; + font-weight: 600; + padding: 2px 8px; + border-radius: 12px; +} +.program-tag button { + background: none; + border: none; + cursor: pointer; + color: inherit; + font-size: 14px; + line-height: 1; + padding: 0 2px; +} +.order-row { + display: flex; + align-items: center; + gap: var(--space-s); + padding: var(--space-m) var(--space-l); + border-radius: var(--border-radius-input); + cursor: pointer; + transition: background 0.12s; + border: 1px solid transparent; +} +.order-row:hover { + background: #f4f4f4; +} +.order-row.active { + background: var(--color-background-status-info); + border-color: var(--color-border-status-info); +} +.order-row-info { + flex: 1; + min-width: 0; +} +.order-row-id { + font-size: 13px; + font-weight: 600; + color: var(--color-text-heading-default); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.order-row-meta { + font-size: 12px; + color: var(--color-text-body-secondary); + margin-top: 2px; +} +.order-status-badge { + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + border-radius: 12px; + background: var(--color-background-status-info); + color: var(--color-text-status-info); + white-space: nowrap; + flex-shrink: 0; +} +.order-row-delete { + background: none; + border: none; + cursor: pointer; + color: var(--color-text-body-secondary); + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: color 0.15s, background 0.15s; +} +.order-row-delete:hover { + color: var(--color-text-status-error); + background: var(--color-background-status-error); +} +.order-row-delete:disabled { + color: #c6c6cd; + cursor: not-allowed; +} +.order-delete-error { + font-size: 12px; + color: var(--color-text-status-error); + background: var(--color-background-status-error); + border: 1px solid var(--color-border-status-error); + border-radius: var(--border-radius-input); + padding: var(--space-xs) var(--space-l); + margin: 0 var(--space-l) var(--space-xs) var(--space-l); +} + +/* Data Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 7, 22, 0.5); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-xxl); +} +.modal { + background: var(--color-background-container-content); + border-radius: var(--border-radius-container); + box-shadow: 0 4px 20px rgba(0, 7, 22, 0.3); + width: 100%; + max-width: 700px; + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; +} +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-l) var(--space-xxl); + border-bottom: 1px solid var(--color-border-divider-default); +} +.modal-header h2 { + font-size: 18px; + font-weight: 700; +} +.modal-close { + background: none; + border: none; + cursor: pointer; + color: var(--color-text-body-secondary); + padding: var(--space-xs); + border-radius: 4px; + display: flex; +} +.modal-close:hover { + background: #f4f4f4; +} +.modal-body { + padding: var(--space-xs); + overflow-y: auto; + color: var(--color-text-body-default); + background: var(--color-background-navigation); +} + +/* Guided Scenarios */ +.scenarios-header { + margin-bottom: var(--space-xl); +} +#scenarios-container { + border: none; + box-shadow: none; +} +#scenarios-body { + padding-left: 0; + padding-right: 0; +} +.scenario-list { + display: flex; + flex-direction: column; + gap: var(--space-m); +} +.scenario-card { + border: 1px solid var(--color-border-divider-default); + border-radius: var(--border-radius-input); + padding: var(--space-l); +} +.scenario-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-m); +} +.scenario-title { + font-size: 15px; + font-weight: 700; + color: var(--color-text-heading-default); +} +.scenario-tagline { + font-size: 13px; + color: var(--color-text-body-secondary); + margin-top: 2px; +} +.scenario-actions { + display: flex; + gap: var(--space-s); + flex-shrink: 0; +} +.btn-seed { + padding: 6px 14px; + font-size: 13px; +} +.scenario-steps { + margin-top: var(--space-m); +} +.scenario-steps summary { + cursor: pointer; + font-size: 13px; + color: var(--color-text-interactive-default); + user-select: none; +} +.scenario-steps summary:hover { + color: var(--color-text-interactive-hover); +} +.scenario-step { + display: flex; + gap: var(--space-s); + align-items: baseline; + padding: var(--space-s) 0; + border-bottom: 1px solid var(--color-border-divider-default); + font-size: 13px; +} +.scenario-step:last-child { + border-bottom: none; +} +.step-chip { + flex-shrink: 0; + font-size: 11px; + font-weight: 700; + padding: 1px 8px; + border-radius: 10px; + white-space: nowrap; +} +.step-chip.runnable { + background: var(--color-background-status-success); + color: var(--color-text-status-success); +} +.step-chip.pass-through { + background: var(--color-background-status-info); + color: var(--color-text-status-info); +} +.step-chip.planned { + background: #f4f4f4; + color: var(--color-text-body-secondary); +} +.step-main { + min-width: 0; +} +.step-title { + font-weight: 700; +} +.step-path { + font-family: monospace; + font-size: 12px; + color: var(--color-text-body-secondary); + word-break: break-all; +} +.step-note { + font-size: 12px; + color: var(--color-text-body-secondary); + margin-top: 2px; +} + +/* Notifications Panel */ +.notifications-header { + margin-bottom: var(--space-xl); +} +#notifications-container { + border: none; + box-shadow: none; +} +#notifications-body { + padding: 0; +} +.notification-status { + font-size: 13px; + font-weight: 600; + padding: var(--space-m) var(--space-l); + border-radius: var(--border-radius-input); + margin-top: var(--space-l); +} +.notification-status.success { + background: var(--color-background-status-success); + color: var(--color-text-status-success); + border: 1px solid var(--color-border-status-success); +} +.notification-status.error { + background: var(--color-background-status-error); + color: var(--color-text-status-error); + border: 1px solid var(--color-border-status-error); +} + +.notification-collapsible { + border: 1px solid var(--color-border-divider-default); + border-radius: var(--border-radius-input); + margin-bottom: var(--space-m); + overflow: hidden; +} +.notification-collapsible-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-s) var(--space-m); + background: #f9fafa; + border: none; + width: 100%; + cursor: pointer; + font-family: var(--font-family-base); + font-size: 13px; + font-weight: 600; + color: var(--color-text-heading-default); + transition: background 0.15s; +} +.notification-collapsible-header:hover { + background: #f4f4f4; +} +.notification-collapsible-header::after { + content: "\25B6"; + font-size: 10px; + color: var(--color-text-body-secondary); + transition: transform 0.2s; +} +.notification-collapsible.open > .notification-collapsible-header::after { + transform: rotate(90deg); +} +.notification-collapsible-content { + display: none; + padding: var(--space-m); + border-top: 1px solid var(--color-border-divider-default); +} +.notification-collapsible.open > .notification-collapsible-content { + display: block; +} + +.notification-array-section { + margin-bottom: var(--space-m); +} +.notification-container-error { + font-size: 11px; + color: var(--color-text-status-error); + padding: 0 var(--space-m) var(--space-s); +} +.notification-array-section > .notification-container-error { + padding: 0; + margin: 4px 0 var(--space-s); +} +.notification-collapsible.invalid > .notification-collapsible-header { + color: var(--color-text-status-error); +} +.notification-array-item { + border: 1px solid var(--color-border-divider-default); + border-radius: var(--border-radius-input); + padding: var(--space-m); + margin-bottom: var(--space-s); + position: relative; +} +.notification-add-btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: 6px 14px; + border: 1px dashed var(--color-border-input-default); + border-radius: var(--border-radius-input); + background: none; + cursor: pointer; + font-size: 13px; + font-family: var(--font-family-base); + color: var(--color-text-interactive-default); + transition: border-color 0.15s, background 0.15s; +} +.notification-add-btn:hover { + border-color: var(--color-text-interactive-default); + background: var(--color-background-status-info); +} + +#notification-form-container > .order-form-field, +#notification-form-container > .notification-collapsible, +#notification-form-container > .notification-array-section, +.notification-collapsible-content > .order-form-field, +.notification-collapsible-content > .notification-collapsible, +.notification-collapsible-content > .notification-array-section, +.notification-array-item > .order-form-field { + margin-bottom: var(--space-m); +} + +#tab-panel-notifications .order-form-section { + margin-bottom: var(--space-m); +} diff --git a/local-ai-sandbox/res/generated/operationRegistry.json b/local-ai-sandbox/res/generated/operationRegistry.json new file mode 100644 index 000000000..1d10ba9a0 --- /dev/null +++ b/local-ai-sandbox/res/generated/operationRegistry.json @@ -0,0 +1,638 @@ +{ + "operations": [ + { + "operationId": "searchCatalogItems", + "apiName": "Catalog Items", + "apiVersion": "2022-04-01", + "modelFile": "catalogItems_2022-04-01.json", + "path": "/catalog/2022-04-01/items", + "method": "get", + "dbNamespace": "catalog", + "pathPrefix": "/catalog/2022-04-01/items" + }, + { + "operationId": "getCatalogItem", + "apiName": "Catalog Items", + "apiVersion": "2022-04-01", + "modelFile": "catalogItems_2022-04-01.json", + "path": "/catalog/2022-04-01/items/{asin}", + "method": "get", + "dbNamespace": "catalog", + "pathPrefix": "/catalog/2022-04-01/items" + }, + { + "operationId": "getDocument", + "apiName": "Data Kiosk", + "apiVersion": "2023-11-15", + "modelFile": "dataKiosk_2023-11-15.json", + "path": "/dataKiosk/2023-11-15/documents/{documentId}", + "method": "get", + "dbNamespace": "dataKiosk", + "pathPrefix": "/dataKiosk/2023-11-15" + }, + { + "operationId": "getQueries", + "apiName": "Data Kiosk", + "apiVersion": "2023-11-15", + "modelFile": "dataKiosk_2023-11-15.json", + "path": "/dataKiosk/2023-11-15/queries", + "method": "get", + "dbNamespace": "dataKiosk", + "pathPrefix": "/dataKiosk/2023-11-15" + }, + { + "operationId": "createQuery", + "apiName": "Data Kiosk", + "apiVersion": "2023-11-15", + "modelFile": "dataKiosk_2023-11-15.json", + "path": "/dataKiosk/2023-11-15/queries", + "method": "post", + "dbNamespace": "dataKiosk", + "pathPrefix": "/dataKiosk/2023-11-15" + }, + { + "operationId": "cancelQuery", + "apiName": "Data Kiosk", + "apiVersion": "2023-11-15", + "modelFile": "dataKiosk_2023-11-15.json", + "path": "/dataKiosk/2023-11-15/queries/{queryId}", + "method": "delete", + "dbNamespace": "dataKiosk", + "pathPrefix": "/dataKiosk/2023-11-15" + }, + { + "operationId": "getQuery", + "apiName": "Data Kiosk", + "apiVersion": "2023-11-15", + "modelFile": "dataKiosk_2023-11-15.json", + "path": "/dataKiosk/2023-11-15/queries/{queryId}", + "method": "get", + "dbNamespace": "dataKiosk", + "pathPrefix": "/dataKiosk/2023-11-15" + }, + { + "operationId": "batchInventory", + "apiName": "External Fulfillment Inventory", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentInventory_2024-09-11.json", + "path": "/externalFulfillment/inventory/2024-09-11/inventories", + "method": "post", + "dbNamespace": "extFulfillmentInventory", + "pathPrefix": "/externalFulfillment/inventory/2024-09-11/inventories" + }, + { + "operationId": "listReturns", + "apiName": "External Fulfillment Returns", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentReturns_2024-09-11.json", + "path": "/externalFulfillment/2024-09-11/returns", + "method": "get", + "dbNamespace": "extFulfillmentReturns", + "pathPrefix": "/externalFulfillment/2024-09-11/returns" + }, + { + "operationId": "getReturn", + "apiName": "External Fulfillment Returns", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentReturns_2024-09-11.json", + "path": "/externalFulfillment/2024-09-11/returns/{returnId}", + "method": "get", + "dbNamespace": "extFulfillmentReturns", + "pathPrefix": "/externalFulfillment/2024-09-11/returns" + }, + { + "operationId": "getShipments", + "apiName": "External Fulfillment Shipments", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentShipments_2024-09-11.json", + "path": "/externalFulfillment/2024-09-11/shipments", + "method": "get", + "dbNamespace": "extFulfillmentShipments", + "pathPrefix": "/externalFulfillment/2024-09-11/shipments" + }, + { + "operationId": "getShipment", + "apiName": "External Fulfillment Shipments", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentShipments_2024-09-11.json", + "path": "/externalFulfillment/2024-09-11/shipments/{shipmentId}", + "method": "get", + "dbNamespace": "extFulfillmentShipments", + "pathPrefix": "/externalFulfillment/2024-09-11/shipments" + }, + { + "operationId": "processShipment", + "apiName": "External Fulfillment Shipments", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentShipments_2024-09-11.json", + "path": "/externalFulfillment/2024-09-11/shipments/{shipmentId}", + "method": "post", + "dbNamespace": "extFulfillmentShipments", + "pathPrefix": "/externalFulfillment/2024-09-11/shipments" + }, + { + "operationId": "retrieveInvoice", + "apiName": "External Fulfillment Shipments", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentShipments_2024-09-11.json", + "path": "/externalFulfillment/2024-09-11/shipments/{shipmentId}/invoice", + "method": "get", + "dbNamespace": "extFulfillmentShipments", + "pathPrefix": "/externalFulfillment/2024-09-11/shipments" + }, + { + "operationId": "generateInvoice", + "apiName": "External Fulfillment Shipments", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentShipments_2024-09-11.json", + "path": "/externalFulfillment/2024-09-11/shipments/{shipmentId}/invoice", + "method": "post", + "dbNamespace": "extFulfillmentShipments", + "pathPrefix": "/externalFulfillment/2024-09-11/shipments" + }, + { + "operationId": "createPackages", + "apiName": "External Fulfillment Shipments", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentShipments_2024-09-11.json", + "path": "/externalFulfillment/2024-09-11/shipments/{shipmentId}/packages", + "method": "post", + "dbNamespace": "extFulfillmentShipments", + "pathPrefix": "/externalFulfillment/2024-09-11/shipments" + }, + { + "operationId": "updatePackageStatus", + "apiName": "External Fulfillment Shipments", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentShipments_2024-09-11.json", + "path": "/externalFulfillment/2024-09-11/shipments/{shipmentId}/packages/{packageId}", + "method": "patch", + "dbNamespace": "extFulfillmentShipments", + "pathPrefix": "/externalFulfillment/2024-09-11/shipments" + }, + { + "operationId": "updatePackage", + "apiName": "External Fulfillment Shipments", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentShipments_2024-09-11.json", + "path": "/externalFulfillment/2024-09-11/shipments/{shipmentId}/packages/{packageId}", + "method": "put", + "dbNamespace": "extFulfillmentShipments", + "pathPrefix": "/externalFulfillment/2024-09-11/shipments" + }, + { + "operationId": "generateShipLabels", + "apiName": "External Fulfillment Shipments", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentShipments_2024-09-11.json", + "path": "/externalFulfillment/2024-09-11/shipments/{shipmentId}/shipLabels", + "method": "put", + "dbNamespace": "extFulfillmentShipments", + "pathPrefix": "/externalFulfillment/2024-09-11/shipments" + }, + { + "operationId": "retrieveShippingOptions", + "apiName": "External Fulfillment Shipments", + "apiVersion": "2024-09-11", + "modelFile": "externalFulfillmentShipments_2024-09-11.json", + "path": "/externalFulfillment/2024-09-11/shipments/{shipmentId}/shippingOptions", + "method": "get", + "dbNamespace": "extFulfillmentShipments", + "pathPrefix": "/externalFulfillment/2024-09-11/shipments" + }, + { + "operationId": "getInventorySummaries", + "apiName": "FBA Inventory", + "apiVersion": "v1", + "modelFile": "fbaInventory.json", + "path": "/fba/inventory/v1/summaries", + "method": "get", + "dbNamespace": "inventory", + "pathPrefix": "/fba/inventory/v1" + }, + { + "operationId": "searchListingsItems", + "apiName": "Listings", + "apiVersion": "2021-08-01", + "modelFile": "listingsItems_2021-08-01.json", + "path": "/listings/2021-08-01/items/{sellerId}", + "method": "get", + "dbNamespace": "listings", + "pathPrefix": "/listings/2021-08-01/items" + }, + { + "operationId": "deleteListingsItem", + "apiName": "Listings", + "apiVersion": "2021-08-01", + "modelFile": "listingsItems_2021-08-01.json", + "path": "/listings/2021-08-01/items/{sellerId}/{sku}", + "method": "delete", + "dbNamespace": "listings", + "pathPrefix": "/listings/2021-08-01/items" + }, + { + "operationId": "getListingsItem", + "apiName": "Listings", + "apiVersion": "2021-08-01", + "modelFile": "listingsItems_2021-08-01.json", + "path": "/listings/2021-08-01/items/{sellerId}/{sku}", + "method": "get", + "dbNamespace": "listings", + "pathPrefix": "/listings/2021-08-01/items" + }, + { + "operationId": "patchListingsItem", + "apiName": "Listings", + "apiVersion": "2021-08-01", + "modelFile": "listingsItems_2021-08-01.json", + "path": "/listings/2021-08-01/items/{sellerId}/{sku}", + "method": "patch", + "dbNamespace": "listings", + "pathPrefix": "/listings/2021-08-01/items" + }, + { + "operationId": "putListingsItem", + "apiName": "Listings", + "apiVersion": "2021-08-01", + "modelFile": "listingsItems_2021-08-01.json", + "path": "/listings/2021-08-01/items/{sellerId}/{sku}", + "method": "put", + "dbNamespace": "listings", + "pathPrefix": "/listings/2021-08-01/items" + }, + { + "operationId": "getListingsRestrictions", + "apiName": "Listings Restrictions", + "apiVersion": "2021-08-01", + "modelFile": "listingsRestrictions_2021-08-01.json", + "path": "/listings/2021-08-01/restrictions", + "method": "get", + "dbNamespace": "listingsRestrictions", + "pathPrefix": "/listings/2021-08-01/restrictions" + }, + { + "operationId": "getDestinations", + "apiName": "Notifications", + "apiVersion": "v1", + "modelFile": "notifications.json", + "path": "/notifications/v1/destinations", + "method": "get", + "dbNamespace": "notifications", + "pathPrefix": "/notifications/v1" + }, + { + "operationId": "createDestination", + "apiName": "Notifications", + "apiVersion": "v1", + "modelFile": "notifications.json", + "path": "/notifications/v1/destinations", + "method": "post", + "dbNamespace": "notifications", + "pathPrefix": "/notifications/v1" + }, + { + "operationId": "deleteDestination", + "apiName": "Notifications", + "apiVersion": "v1", + "modelFile": "notifications.json", + "path": "/notifications/v1/destinations/{destinationId}", + "method": "delete", + "dbNamespace": "notifications", + "pathPrefix": "/notifications/v1" + }, + { + "operationId": "getDestination", + "apiName": "Notifications", + "apiVersion": "v1", + "modelFile": "notifications.json", + "path": "/notifications/v1/destinations/{destinationId}", + "method": "get", + "dbNamespace": "notifications", + "pathPrefix": "/notifications/v1" + }, + { + "operationId": "getSubscriptions", + "apiName": "Notifications", + "apiVersion": "v1", + "modelFile": "notifications.json", + "path": "/notifications/v1/subscriptions", + "method": "get", + "dbNamespace": "notifications", + "pathPrefix": "/notifications/v1" + }, + { + "operationId": "getSubscription", + "apiName": "Notifications", + "apiVersion": "v1", + "modelFile": "notifications.json", + "path": "/notifications/v1/subscriptions/{notificationType}", + "method": "get", + "dbNamespace": "notifications", + "pathPrefix": "/notifications/v1" + }, + { + "operationId": "createSubscription", + "apiName": "Notifications", + "apiVersion": "v1", + "modelFile": "notifications.json", + "path": "/notifications/v1/subscriptions/{notificationType}", + "method": "post", + "dbNamespace": "notifications", + "pathPrefix": "/notifications/v1" + }, + { + "operationId": "deleteSubscriptionById", + "apiName": "Notifications", + "apiVersion": "v1", + "modelFile": "notifications.json", + "path": "/notifications/v1/subscriptions/{notificationType}/{subscriptionId}", + "method": "delete", + "dbNamespace": "notifications", + "pathPrefix": "/notifications/v1" + }, + { + "operationId": "getSubscriptionById", + "apiName": "Notifications", + "apiVersion": "v1", + "modelFile": "notifications.json", + "path": "/notifications/v1/subscriptions/{notificationType}/{subscriptionId}", + "method": "get", + "dbNamespace": "notifications", + "pathPrefix": "/notifications/v1" + }, + { + "operationId": "searchOrders", + "apiName": "Orders", + "apiVersion": "2026-01-01", + "modelFile": "orders_2026-01-01.json", + "path": "/orders/2026-01-01/orders", + "method": "get", + "dbNamespace": "orders", + "pathPrefix": "/orders/2026-01-01/orders" + }, + { + "operationId": "getOrder", + "apiName": "Orders", + "apiVersion": "2026-01-01", + "modelFile": "orders_2026-01-01.json", + "path": "/orders/2026-01-01/orders/{orderId}", + "method": "get", + "dbNamespace": "orders", + "pathPrefix": "/orders/2026-01-01/orders" + }, + { + "operationId": "confirmShipment", + "apiName": "Orders", + "apiVersion": "v0", + "modelFile": "ordersV0.json", + "path": "/orders/v0/orders/{orderId}/shipmentConfirmation", + "method": "post", + "dbNamespace": "orders", + "pathPrefix": "/orders/v0/orders" + }, + { + "operationId": "getCompetitiveSummary", + "apiName": "Product Pricing", + "apiVersion": "2022-05-01", + "modelFile": "productPricing_2022-05-01.json", + "path": "/batches/products/pricing/2022-05-01/items/competitiveSummary", + "method": "post", + "dbNamespace": "pricing", + "pathPrefix": "/batches/products/pricing/2022-05-01" + }, + { + "operationId": "getFeaturedOfferExpectedPriceBatch", + "apiName": "Product Pricing", + "apiVersion": "2022-05-01", + "modelFile": "productPricing_2022-05-01.json", + "path": "/batches/products/pricing/2022-05-01/offer/featuredOfferExpectedPrice", + "method": "post", + "dbNamespace": "pricing", + "pathPrefix": "/batches/products/pricing/2022-05-01" + }, + { + "operationId": "searchDefinitionsProductTypes", + "apiName": "Product Type Definitions", + "apiVersion": "2020-09-01", + "modelFile": "definitionsProductTypes_2020-09-01.json", + "path": "/definitions/2020-09-01/productTypes", + "method": "get", + "dbNamespace": "productTypeDefinitions", + "pathPrefix": "/definitions/2020-09-01/productTypes" + }, + { + "operationId": "getDefinitionsProductType", + "apiName": "Product Type Definitions", + "apiVersion": "2020-09-01", + "modelFile": "definitionsProductTypes_2020-09-01.json", + "path": "/definitions/2020-09-01/productTypes/{productType}", + "method": "get", + "dbNamespace": "productTypeDefinitions", + "pathPrefix": "/definitions/2020-09-01/productTypes" + }, + { + "operationId": "getReportDocument", + "apiName": "Reports", + "apiVersion": "2021-06-30", + "modelFile": "reports_2021-06-30.json", + "path": "/reports/2021-06-30/documents/{reportDocumentId}", + "method": "get", + "dbNamespace": "reports", + "pathPrefix": "/reports/2021-06-30" + }, + { + "operationId": "getReports", + "apiName": "Reports", + "apiVersion": "2021-06-30", + "modelFile": "reports_2021-06-30.json", + "path": "/reports/2021-06-30/reports", + "method": "get", + "dbNamespace": "reports", + "pathPrefix": "/reports/2021-06-30" + }, + { + "operationId": "createReport", + "apiName": "Reports", + "apiVersion": "2021-06-30", + "modelFile": "reports_2021-06-30.json", + "path": "/reports/2021-06-30/reports", + "method": "post", + "dbNamespace": "reports", + "pathPrefix": "/reports/2021-06-30" + }, + { + "operationId": "cancelReport", + "apiName": "Reports", + "apiVersion": "2021-06-30", + "modelFile": "reports_2021-06-30.json", + "path": "/reports/2021-06-30/reports/{reportId}", + "method": "delete", + "dbNamespace": "reports", + "pathPrefix": "/reports/2021-06-30" + }, + { + "operationId": "getReport", + "apiName": "Reports", + "apiVersion": "2021-06-30", + "modelFile": "reports_2021-06-30.json", + "path": "/reports/2021-06-30/reports/{reportId}", + "method": "get", + "dbNamespace": "reports", + "pathPrefix": "/reports/2021-06-30" + }, + { + "operationId": "getReportSchedules", + "apiName": "Reports", + "apiVersion": "2021-06-30", + "modelFile": "reports_2021-06-30.json", + "path": "/reports/2021-06-30/schedules", + "method": "get", + "dbNamespace": "reports", + "pathPrefix": "/reports/2021-06-30" + }, + { + "operationId": "createReportSchedule", + "apiName": "Reports", + "apiVersion": "2021-06-30", + "modelFile": "reports_2021-06-30.json", + "path": "/reports/2021-06-30/schedules", + "method": "post", + "dbNamespace": "reports", + "pathPrefix": "/reports/2021-06-30" + }, + { + "operationId": "cancelReportSchedule", + "apiName": "Reports", + "apiVersion": "2021-06-30", + "modelFile": "reports_2021-06-30.json", + "path": "/reports/2021-06-30/schedules/{reportScheduleId}", + "method": "delete", + "dbNamespace": "reports", + "pathPrefix": "/reports/2021-06-30" + }, + { + "operationId": "getReportSchedule", + "apiName": "Reports", + "apiVersion": "2021-06-30", + "modelFile": "reports_2021-06-30.json", + "path": "/reports/2021-06-30/schedules/{reportScheduleId}", + "method": "get", + "dbNamespace": "reports", + "pathPrefix": "/reports/2021-06-30" + } + ], + "models": [ + { + "modelFile": "catalogItems_2022-04-01.json", + "apiName": "Catalog Items", + "apiVersion": "2022-04-01", + "pathPrefix": "/catalog/2022-04-01/items", + "dbNamespace": "catalog", + "resourcePath": null + }, + { + "modelFile": "dataKiosk_2023-11-15.json", + "apiName": "Data Kiosk", + "apiVersion": "2023-11-15", + "pathPrefix": "/dataKiosk/2023-11-15", + "dbNamespace": "dataKiosk", + "resourcePath": null + }, + { + "modelFile": "externalFulfillmentInventory_2024-09-11.json", + "apiName": "External Fulfillment Inventory", + "apiVersion": "2024-09-11", + "pathPrefix": "/externalFulfillment/inventory/2024-09-11/inventories", + "dbNamespace": "extFulfillmentInventory", + "resourcePath": null + }, + { + "modelFile": "externalFulfillmentReturns_2024-09-11.json", + "apiName": "External Fulfillment Returns", + "apiVersion": "2024-09-11", + "pathPrefix": "/externalFulfillment/2024-09-11/returns", + "dbNamespace": "extFulfillmentReturns", + "resourcePath": null + }, + { + "modelFile": "externalFulfillmentShipments_2024-09-11.json", + "apiName": "External Fulfillment Shipments", + "apiVersion": "2024-09-11", + "pathPrefix": "/externalFulfillment/2024-09-11/shipments", + "dbNamespace": "extFulfillmentShipments", + "resourcePath": null + }, + { + "modelFile": "fbaInventory.json", + "apiName": "FBA Inventory", + "apiVersion": "v1", + "pathPrefix": "/fba/inventory/v1", + "dbNamespace": "inventory", + "resourcePath": null + }, + { + "modelFile": "listingsItems_2021-08-01.json", + "apiName": "Listings", + "apiVersion": "2021-08-01", + "pathPrefix": "/listings/2021-08-01/items", + "dbNamespace": "listings", + "resourcePath": null + }, + { + "modelFile": "listingsRestrictions_2021-08-01.json", + "apiName": "Listings Restrictions", + "apiVersion": "2021-08-01", + "pathPrefix": "/listings/2021-08-01/restrictions", + "dbNamespace": "listingsRestrictions", + "resourcePath": null + }, + { + "modelFile": "notifications.json", + "apiName": "Notifications", + "apiVersion": "v1", + "pathPrefix": "/notifications/v1", + "dbNamespace": "notifications", + "resourcePath": null + }, + { + "modelFile": "orders_2026-01-01.json", + "apiName": "Orders", + "apiVersion": "2026-01-01", + "pathPrefix": "/orders/2026-01-01/orders", + "dbNamespace": "orders", + "resourcePath": null + }, + { + "modelFile": "ordersV0.json", + "apiName": "Orders", + "apiVersion": "v0", + "pathPrefix": "/orders/v0/orders", + "dbNamespace": "orders", + "resourcePath": null + }, + { + "modelFile": "productPricing_2022-05-01.json", + "apiName": "Product Pricing", + "apiVersion": "2022-05-01", + "pathPrefix": "/batches/products/pricing/2022-05-01", + "dbNamespace": "pricing", + "resourcePath": null + }, + { + "modelFile": "definitionsProductTypes_2020-09-01.json", + "apiName": "Product Type Definitions", + "apiVersion": "2020-09-01", + "pathPrefix": "/definitions/2020-09-01/productTypes", + "dbNamespace": "productTypeDefinitions", + "resourcePath": null + }, + { + "modelFile": "reports_2021-06-30.json", + "apiName": "Reports", + "apiVersion": "2021-06-30", + "pathPrefix": "/reports/2021-06-30", + "dbNamespace": "reports", + "resourcePath": null + } + ] +} diff --git a/local-ai-sandbox/res/models/catalogItems_2022-04-01.json b/local-ai-sandbox/res/models/catalogItems_2022-04-01.json index eef80ec7d..04d95898b 100644 --- a/local-ai-sandbox/res/models/catalogItems_2022-04-01.json +++ b/local-ai-sandbox/res/models/catalogItems_2022-04-01.json @@ -14,17 +14,29 @@ } }, "host": "sellingpartnerapi-na.amazon.com", - "schemes": ["https"], - "consumes": ["application/json"], - "produces": ["application/json"], + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "paths": { "/catalog/2022-04-01/items": { "get": { - "tags": ["catalog"], + "tags": [ + "catalog" + ], "description": "Search for a list of Amazon catalog items and item-related information. You can search by identifier or by keywords.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 2 | 2 |\n\nThe `x-amzn-RateLimit-Limit` response header contains the usage plan rate limits for the operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput might have higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", "operationId": "searchCatalogItems", - "consumes": ["application/json"], - "produces": ["application/json"], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "parameters": [ { "name": "identifiers", @@ -45,7 +57,16 @@ "in": "query", "required": false, "type": "string", - "enum": ["ASIN", "EAN", "GTIN", "ISBN", "JAN", "MINSAN", "SKU", "UPC"], + "enum": [ + "ASIN", + "EAN", + "GTIN", + "ISBN", + "JAN", + "MINSAN", + "SKU", + "UPC" + ], "x-docgen-enum-table-extension": [ { "value": "ASIN", @@ -160,7 +181,9 @@ }, "collectionFormat": "csv", "x-example": "summaries", - "default": ["summaries"] + "default": [ + "summaries" + ] }, { "name": "locale", @@ -256,1122 +279,6 @@ "description": "Unique request reference identifier.", "type": "string" } - }, - "examples": { - "application/json": { - "numberOfResults": 1, - "pagination": { - "nextToken": "xsdflkj324lkjsdlkj3423klkjsdfkljlk2j34klj2l3k4jlksdjl234", - "previousToken": "ilkjsdflkj234lkjds234234lkjl234lksjdflkj234234lkjsfsdflkj333d" - }, - "refinements": { - "brands": [ - { - "numberOfResults": 1, - "brandName": "SAMSUNG" - } - ], - "classifications": [ - { - "numberOfResults": 1, - "displayName": "Electronics", - "classificationId": "493964" - } - ] - }, - "items": [ - { - "asin": "B07N4M94X4", - "attributes": { - "total_hdmi_ports": [ - { - "value": 4, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "resolution": [ - { - "language_tag": "en_US", - "value": "4K", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_weight": [ - { - "unit": "pounds", - "value": 107.6, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "product_subcategory": [ - { - "value": "50400120", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "bullet_point": [ - { - "language_tag": "en_US", - "value": "SMART TV WITH UNIVERSAL GUIDE: Simple on-screen Guide is an easy way to find streaming content and live TV shows", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "100% COLOR VOLUME WITH QUANTUM DOTS: Powered by Quantum dots, Samsung’s 4K QLED TV offers over a billion shades of brilliant color and 100% color volume for exceptional depth of detail that will draw you in to the picture for the best 4K TV experience", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "QUANTUM PROCESSOR 4K: Intelligently powered processor instantly upscales content to 4K for sharp detail and refined color", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "QUANTUM HDR 4X: 4K depth of detail with high dynamic range powered by HDR10+ delivers the lightest to darkest colors, scene by scene, for amazing picture realism", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "AMBIENT MODE: Customizes and complements your living space by turning a blank screen of this big screen TV into enticing visuals including décor, info, photos and artwork", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "SMART TV FEATURES: OneRemote to control all compatible devices, Bixby voice command, on-screen universal guide, SmartThings to control compatible home appliances and devices, smart speaker expandability with Alexa and Google Assistant compatibility, and more", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_dimensions": [ - { - "width": { - "unit": "inches", - "value": 72.4 - }, - "length": { - "unit": "inches", - "value": 2.4 - }, - "height": { - "unit": "inches", - "value": 41.4 - }, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "brand": [ - { - "language_tag": "en_US", - "value": "SAMSUNG", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "control_method": [ - { - "value": "voice", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_package_dimensions": [ - { - "length": { - "unit": "centimeters", - "value": 26.67 - }, - "width": { - "unit": "centimeters", - "value": 121.92 - }, - "height": { - "unit": "centimeters", - "value": 203.2 - }, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "image_aspect_ratio": [ - { - "language_tag": "en_US", - "value": "16:9", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "part_number": [ - { - "value": "QN82Q60RAFXZA", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "includes_remote": [ - { - "value": true, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "style": [ - { - "language_tag": "en_US", - "value": "TV only", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_type_name": [ - { - "language_tag": "en_US", - "value": "TV", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "battery": [ - { - "cell_composition": [ - { - "value": "alkaline" - } - ], - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "image_contrast_ratio": [ - { - "language_tag": "en_US", - "value": "QLED 4K", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "manufacturer": [ - { - "language_tag": "en_US", - "value": "Samsung", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "number_of_boxes": [ - { - "value": 1, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "total_usb_ports": [ - { - "value": 2, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "model_number": [ - { - "value": "QN82Q60RAFXZA", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "supplier_declared_dg_hz_regulation": [ - { - "value": "not_applicable", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "num_batteries": [ - { - "quantity": 2, - "type": "aaa", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "california_proposition_65": [ - { - "compliance_type": "on_product_combined_cancer_reproductive", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "compliance_type": "chemical", - "chemical_names": ["di_2_ethylhexyl_phthalate_dehp"], - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "display": [ - { - "resolution_maximum": [ - { - "unit": "pixels", - "language_tag": "en_US", - "value": "3840 x 2160" - } - ], - "size": [ - { - "unit": "inches", - "value": 82 - } - ], - "type": [ - { - "language_tag": "en_US", - "value": "QLED" - } - ], - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_name": [ - { - "language_tag": "en_US", - "value": "Samsung QN82Q60RAFXZA Flat 82-Inch QLED 4K Q60 Series (2019) Ultra HD Smart TV with HDR and Alexa Compatibility", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "list_price": [ - { - "currency": "USD", - "value": 3799.99, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "batteries_required": [ - { - "value": false, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "includes_rechargable_battery": [ - { - "value": false, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "product_site_launch_date": [ - { - "value": "2019-03-11T08:00:01.000Z", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "product_category": [ - { - "value": "50400100", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "batteries_included": [ - { - "value": false, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "connectivity_technology": [ - { - "language_tag": "en_US", - "value": "Bluetooth", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "USB", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Wireless", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "HDMI", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "included_components": [ - { - "language_tag": "en_US", - "value": "QLED Standard Smart Remote", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Power Cable", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Stand", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Samsung Smart Control", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "specification_met": [ - { - "language_tag": "en_US", - "value": "", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "parental_control_technology": [ - { - "value": "V-Chip", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "power_consumption": [ - { - "unit": "watts", - "value": 120, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "cpsia_cautionary_statement": [ - { - "value": "no_warning_applicable", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_type_keyword": [ - { - "value": "qled-televisions", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "number_of_items": [ - { - "value": 1, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "warranty_description": [ - { - "language_tag": "en_US", - "value": "1 year manufacturer", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "max_resolution": [ - { - "unit": "pixels", - "value": 8.3, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "color": [ - { - "language_tag": "en_US", - "value": "Black", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "screen_surface_description": [ - { - "language_tag": "en_US", - "value": "Flat", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_package_weight": [ - { - "unit": "kilograms", - "value": 62.142, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "speaker_type": [ - { - "language_tag": "en_US", - "value": "2CH", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "supported_internet_services": [ - { - "language_tag": "en_US", - "value": "Amazon Instant Video", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "YouTube", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Netflix", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Hulu", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Browser", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "tuner_technology": [ - { - "language_tag": "en_US", - "value": "Analog Tuner", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "controller_type": [ - { - "language_tag": "en_US", - "value": "SmartThings", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Voice Control", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "special_feature": [ - { - "language_tag": "en_US", - "value": "100% Color Volume with Quantum Dot; Quantum Processor 4K; Ambient Mode; Quantum HDR 4X; Real Game Enhancer", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "wireless_communication_technology": [ - { - "language_tag": "en_US", - "value": "Wi-Fi::Wi-Fi Direct::Bluetooth", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "model_year": [ - { - "value": 2019, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "power_source_type": [ - { - "language_tag": "en_US", - "value": "Corded Electric", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "street_date": [ - { - "value": "2019-03-21T00:00:01Z", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "mounting_type": [ - { - "language_tag": "en_US", - "value": "Table Mount", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Wall Mount", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "refresh_rate": [ - { - "unit": "hertz", - "language_tag": "en_US", - "value": "120", - "marketplace_id": "ATVPDKIKX0DER" - } - ] - }, - "classifications": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "classifications": [ - { - "displayName": "QLED TVs", - "classificationId": "21489946011", - "parent": { - "displayName": "Televisions", - "classificationId": "172659", - "parent": { - "displayName": "Television & Video", - "classificationId": "1266092011", - "parent": { - "displayName": "Electronics", - "classificationId": "172282" - } - } - } - } - ] - } - ], - "dimensions": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "item": { - "height": { - "unit": "inches", - "value": 41.4 - }, - "length": { - "unit": "inches", - "value": 2.4 - }, - "weight": { - "unit": "pounds", - "value": 107.6 - }, - "width": { - "unit": "inches", - "value": 72.4 - } - }, - "package": { - "height": { - "unit": "inches", - "value": 10.49999998929 - }, - "length": { - "unit": "inches", - "value": 79.9999999184 - }, - "weight": { - "unit": "kilograms", - "value": 62.142 - }, - "width": { - "unit": "inches", - "value": 47.99999995104 - } - } - } - ], - "identifiers": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "identifiers": [ - { - "identifier": "0887276302195", - "identifierType": "EAN" - }, - { - "identifier": "00887276302195", - "identifierType": "GTIN" - }, - { - "identifier": "887276302195", - "identifierType": "UPC" - } - ] - } - ], - "images": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "images": [ - { - "variant": "MAIN", - "link": "https://m.media-amazon.com/images/I/91uohwV+k3L.jpg", - "height": 1707, - "width": 2560 - }, - { - "variant": "MAIN", - "link": "https://m.media-amazon.com/images/I/51DZzp3w3vL.jpg", - "height": 333, - "width": 500 - }, - { - "variant": "PT01", - "link": "https://m.media-amazon.com/images/I/81w2rTVShlL.jpg", - "height": 2560, - "width": 2560 - }, - { - "variant": "PT01", - "link": "https://m.media-amazon.com/images/I/41Px9eq9tkL.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT02", - "link": "https://m.media-amazon.com/images/I/51NTNhdhPyL.jpg", - "height": 375, - "width": 500 - }, - { - "variant": "PT03", - "link": "https://m.media-amazon.com/images/I/51o4zpL+A3L.jpg", - "height": 375, - "width": 500 - }, - { - "variant": "PT04", - "link": "https://m.media-amazon.com/images/I/71ux2k9GAZL.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT04", - "link": "https://m.media-amazon.com/images/I/61UUX63yw1L.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT05", - "link": "https://m.media-amazon.com/images/I/61LwHkljX-L.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT05", - "link": "https://m.media-amazon.com/images/I/51wJTQty3PL.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT06", - "link": "https://m.media-amazon.com/images/I/61uvoB4VvoL.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT06", - "link": "https://m.media-amazon.com/images/I/51ZexIO628L.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT07", - "link": "https://m.media-amazon.com/images/I/7121MGd2ncL.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT07", - "link": "https://m.media-amazon.com/images/I/61QK+JBMrGL.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT08", - "link": "https://m.media-amazon.com/images/I/61ECcGlG4IL.jpg", - "height": 1080, - "width": 1920 - }, - { - "variant": "PT08", - "link": "https://m.media-amazon.com/images/I/31TxwfqvB5L.jpg", - "height": 281, - "width": 500 - } - ] - } - ], - "productTypes": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "productType": "TELEVISION" - } - ], - "salesRanks": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "classificationRanks": [ - { - "classificationId": "21489946011", - "title": "QLED TVs", - "link": "http://www.amazon.com/gp/bestsellers/electronics/21489946011", - "rank": 113 - } - ], - "displayGroupRanks": [ - { - "websiteDisplayGroup": "ce_display_on_website", - "title": "Electronics", - "link": "http://www.amazon.com/gp/bestsellers/electronics", - "rank": 72855 - } - ] - } - ], - "summaries": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "brand": "SAMSUNG", - "browseClassification": { - "displayName": "QLED TVs", - "classificationId": "21489946011" - }, - "color": "Black", - "itemClassification": "BASE_PRODUCT", - "itemName": "Samsung QN82Q60RAFXZA Flat 82-Inch QLED 4K Q60 Series (2019) Ultra HD Smart TV with HDR and Alexa Compatibility", - "manufacturer": "Samsung", - "modelNumber": "QN82Q60RAFXZA", - "packageQuantity": 1, - "partNumber": "QN82Q60RAFXZA", - "size": "82-Inch", - "style": "TV only", - "websiteDisplayGroup": "home_theater_display_on_website", - "websiteDisplayGroupName": "Home Theater" - } - ], - "relationships": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "relationships": [ - { - "type": "VARIATION", - "parentAsins": ["B08J7TQ9FL"], - "variationTheme": { - "attributes": ["color", "size"], - "theme": "SIZE_NAME/COLOR_NAME" - } - } - ] - } - ], - "vendorDetails": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "brandCode": "SAMF9", - "manufacturerCode": "SAMF9", - "manufacturerCodeParent": "SAMF9", - "productCategory": { - "displayName": "Televisions", - "value": "50400100" - }, - "productGroup": "Home Entertainment", - "productSubcategory": { - "displayName": "Plasma TVs", - "value": "50400120" - }, - "replenishmentCategory": "OBSOLETE" - } - ] - } - ] - } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "keywords": { - "value": ["samsung", "tv"] - }, - "marketplaceIds": { - "value": ["ATVPDKIKX0DER"] - }, - "includedData": { - "value": [ - "classifications", - "dimensions", - "identifiers", - "images", - "productTypes", - "relationships", - "salesRanks", - "summaries", - "vendorDetails" - ] - } - } - }, - "response": { - "numberOfResults": 1, - "pagination": { - "nextToken": "xsdflkj324lkjsdlkj3423klkjsdfkljlk2j34klj2l3k4jlksdjl234", - "previousToken": "ilkjsdflkj234lkjds234234lkjl234lksjdflkj234234lkjsfsdflkj333d" - }, - "refinements": { - "brands": [ - { - "numberOfResults": 1, - "brandName": "SAMSUNG" - } - ], - "classifications": [ - { - "numberOfResults": 1, - "displayName": "Electronics", - "classificationId": "493964" - } - ] - }, - "items": [ - { - "asin": "B07N4M94X4", - "classifications": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "classifications": [ - { - "displayName": "QLED TVs", - "classificationId": "21489946011", - "parent": { - "displayName": "Televisions", - "classificationId": "172659", - "parent": { - "displayName": "Television & Video", - "classificationId": "1266092011", - "parent": { - "displayName": "Electronics", - "classificationId": "172282" - } - } - } - } - ] - } - ], - "dimensions": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "item": { - "height": { - "unit": "inches", - "value": 41.4 - }, - "length": { - "unit": "inches", - "value": 2.4 - }, - "weight": { - "unit": "pounds", - "value": 107.6 - }, - "width": { - "unit": "inches", - "value": 72.4 - } - }, - "package": { - "height": { - "unit": "inches", - "value": 10.49999998929 - }, - "length": { - "unit": "inches", - "value": 79.9999999184 - }, - "weight": { - "unit": "kilograms", - "value": 62.142 - }, - "width": { - "unit": "inches", - "value": 47.99999995104 - } - } - } - ], - "identifiers": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "identifiers": [ - { - "identifier": "0887276302195", - "identifierType": "EAN" - }, - { - "identifier": "00887276302195", - "identifierType": "GTIN" - }, - { - "identifier": "887276302195", - "identifierType": "UPC" - } - ] - } - ], - "images": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "images": [ - { - "variant": "MAIN", - "link": "https://m.media-amazon.com/images/I/91uohwV+k3L.jpg", - "height": 1707, - "width": 2560 - }, - { - "variant": "MAIN", - "link": "https://m.media-amazon.com/images/I/51DZzp3w3vL.jpg", - "height": 333, - "width": 500 - }, - { - "variant": "PT01", - "link": "https://m.media-amazon.com/images/I/81w2rTVShlL.jpg", - "height": 2560, - "width": 2560 - }, - { - "variant": "PT01", - "link": "https://m.media-amazon.com/images/I/41Px9eq9tkL.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT02", - "link": "https://m.media-amazon.com/images/I/51NTNhdhPyL.jpg", - "height": 375, - "width": 500 - }, - { - "variant": "PT03", - "link": "https://m.media-amazon.com/images/I/51o4zpL+A3L.jpg", - "height": 375, - "width": 500 - }, - { - "variant": "PT04", - "link": "https://m.media-amazon.com/images/I/71ux2k9GAZL.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT04", - "link": "https://m.media-amazon.com/images/I/61UUX63yw1L.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT05", - "link": "https://m.media-amazon.com/images/I/61LwHkljX-L.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT05", - "link": "https://m.media-amazon.com/images/I/51wJTQty3PL.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT06", - "link": "https://m.media-amazon.com/images/I/61uvoB4VvoL.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT06", - "link": "https://m.media-amazon.com/images/I/51ZexIO628L.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT07", - "link": "https://m.media-amazon.com/images/I/7121MGd2ncL.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT07", - "link": "https://m.media-amazon.com/images/I/61QK+JBMrGL.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT08", - "link": "https://m.media-amazon.com/images/I/61ECcGlG4IL.jpg", - "height": 1080, - "width": 1920 - }, - { - "variant": "PT08", - "link": "https://m.media-amazon.com/images/I/31TxwfqvB5L.jpg", - "height": 281, - "width": 500 - } - ] - } - ], - "productTypes": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "productType": "TELEVISION" - } - ], - "salesRanks": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "classificationRanks": [ - { - "classificationId": "21489946011", - "title": "QLED TVs", - "link": "http://www.amazon.com/gp/bestsellers/electronics/21489946011", - "rank": 113 - } - ], - "displayGroupRanks": [ - { - "websiteDisplayGroup": "ce_display_on_website", - "title": "Electronics", - "link": "http://www.amazon.com/gp/bestsellers/electronics", - "rank": 72855 - } - ] - } - ], - "summaries": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "brand": "SAMSUNG", - "browseClassification": { - "displayName": "QLED TVs", - "classificationId": "21489946011" - }, - "color": "Black", - "itemClassification": "BASE_PRODUCT", - "itemName": "Samsung QN82Q60RAFXZA Flat 82-Inch QLED 4K Q60 Series (2019) Ultra HD Smart TV with HDR and Alexa Compatibility", - "manufacturer": "Samsung", - "modelNumber": "QN82Q60RAFXZA", - "packageQuantity": 1, - "partNumber": "QN82Q60RAFXZA", - "size": "82-Inch", - "style": "TV only", - "websiteDisplayGroup": "home_theater_display_on_website", - "websiteDisplayGroupName": "Home Theater" - } - ], - "relationships": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "relationships": [ - { - "type": "VARIATION", - "parentAsins": ["B08J7TQ9FL"], - "variationTheme": { - "attributes": ["color", "size"], - "theme": "SIZE_NAME/COLOR_NAME" - } - } - ] - } - ], - "vendorDetails": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "brandCode": "SAMF9", - "manufacturerCode": "SAMF9", - "manufacturerCodeParent": "SAMF9", - "productCategory": { - "displayName": "Televisions", - "value": "50400100" - }, - "productGroup": "Home Entertainment", - "productSubcategory": { - "displayName": "Plasma TVs", - "value": "50400120" - }, - "replenishmentCategory": "OBSOLETE" - } - ] - } - ] - } - } - ] } }, "400": { @@ -1386,43 +293,6 @@ "type": "string" } }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "keywords": { - "value": ["samsung", "tv"] - }, - "marketplaceIds": { - "value": [""] - }, - "includedData": { - "value": [ - "classifications", - "dimensions", - "identifiers", - "images", - "productTypes", - "relationships", - "salesRanks", - "summaries", - "vendorDetails" - ] - } - } - }, - "response": { - "errors": [ - { - "code": "400", - "message": "Request has missing or invalid parameters and cannot be parsed." - } - ] - } - } - ] - }, "schema": { "$ref": "#/definitions/ErrorList" } @@ -1520,11 +390,17 @@ }, "/catalog/2022-04-01/items/{asin}": { "get": { - "tags": ["catalog"], + "tags": [ + "catalog" + ], "description": "Retrieves details for an item in the Amazon catalog.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 2 | 2 |\n\nThe `x-amzn-RateLimit-Limit` response header contains the usage plan rate limits for the operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput might have higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", "operationId": "getCatalogItem", - "consumes": ["application/json"], - "produces": ["application/json"], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "parameters": [ { "name": "asin", @@ -1610,7 +486,9 @@ }, "collectionFormat": "csv", "x-example": "summaries", - "default": ["summaries"] + "default": [ + "summaries" + ] }, { "name": "locale", @@ -1636,1074 +514,6 @@ "description": "Unique request reference identifier.", "type": "string" } - }, - "examples": { - "application/json": { - "asin": "B07N4M94X4", - "attributes": { - "total_hdmi_ports": [ - { - "value": 4, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "resolution": [ - { - "language_tag": "en_US", - "value": "4K", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_weight": [ - { - "unit": "pounds", - "value": 107.6, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "product_subcategory": [ - { - "value": "50400120", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "bullet_point": [ - { - "language_tag": "en_US", - "value": "SMART TV WITH UNIVERSAL GUIDE: Simple on-screen Guide is an easy way to find streaming content and live TV shows", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "100% COLOR VOLUME WITH QUANTUM DOTS: Powered by Quantum dots, Samsung’s 4K QLED TV offers over a billion shades of brilliant color and 100% color volume for exceptional depth of detail that will draw you in to the picture for the best 4K TV experience", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "QUANTUM PROCESSOR 4K: Intelligently powered processor instantly upscales content to 4K for sharp detail and refined color", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "QUANTUM HDR 4X: 4K depth of detail with high dynamic range powered by HDR10+ delivers the lightest to darkest colors, scene by scene, for amazing picture realism", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "AMBIENT MODE: Customizes and complements your living space by turning a blank screen of this big screen TV into enticing visuals including décor, info, photos and artwork", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "SMART TV FEATURES: OneRemote to control all compatible devices, Bixby voice command, on-screen universal guide, SmartThings to control compatible home appliances and devices, smart speaker expandability with Alexa and Google Assistant compatibility, and more", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_dimensions": [ - { - "width": { - "unit": "inches", - "value": 72.4 - }, - "length": { - "unit": "inches", - "value": 2.4 - }, - "height": { - "unit": "inches", - "value": 41.4 - }, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "brand": [ - { - "language_tag": "en_US", - "value": "SAMSUNG", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "control_method": [ - { - "value": "voice", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_package_dimensions": [ - { - "length": { - "unit": "centimeters", - "value": 26.67 - }, - "width": { - "unit": "centimeters", - "value": 121.92 - }, - "height": { - "unit": "centimeters", - "value": 203.2 - }, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "image_aspect_ratio": [ - { - "language_tag": "en_US", - "value": "16:9", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "part_number": [ - { - "value": "QN82Q60RAFXZA", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "includes_remote": [ - { - "value": true, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "style": [ - { - "language_tag": "en_US", - "value": "TV only", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_type_name": [ - { - "language_tag": "en_US", - "value": "TV", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "battery": [ - { - "cell_composition": [ - { - "value": "alkaline" - } - ], - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "image_contrast_ratio": [ - { - "language_tag": "en_US", - "value": "QLED 4K", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "manufacturer": [ - { - "language_tag": "en_US", - "value": "Samsung", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "number_of_boxes": [ - { - "value": 1, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "total_usb_ports": [ - { - "value": 2, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "model_number": [ - { - "value": "QN82Q60RAFXZA", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "supplier_declared_dg_hz_regulation": [ - { - "value": "not_applicable", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "num_batteries": [ - { - "quantity": 2, - "type": "aaa", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "california_proposition_65": [ - { - "compliance_type": "on_product_combined_cancer_reproductive", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "compliance_type": "chemical", - "chemical_names": ["di_2_ethylhexyl_phthalate_dehp"], - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "display": [ - { - "resolution_maximum": [ - { - "unit": "pixels", - "language_tag": "en_US", - "value": "3840 x 2160" - } - ], - "size": [ - { - "unit": "inches", - "value": 82 - } - ], - "type": [ - { - "language_tag": "en_US", - "value": "QLED" - } - ], - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_name": [ - { - "language_tag": "en_US", - "value": "Samsung QN82Q60RAFXZA Flat 82-Inch QLED 4K Q60 Series (2019) Ultra HD Smart TV with HDR and Alexa Compatibility", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "list_price": [ - { - "currency": "USD", - "value": 3799.99, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "batteries_required": [ - { - "value": false, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "includes_rechargable_battery": [ - { - "value": false, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "product_site_launch_date": [ - { - "value": "2019-03-11T08:00:01.000Z", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "product_category": [ - { - "value": "50400100", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "batteries_included": [ - { - "value": false, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "connectivity_technology": [ - { - "language_tag": "en_US", - "value": "Bluetooth", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "USB", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Wireless", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "HDMI", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "included_components": [ - { - "language_tag": "en_US", - "value": "QLED Standard Smart Remote", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Power Cable", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Stand", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Samsung Smart Control", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "specification_met": [ - { - "language_tag": "en_US", - "value": "", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "parental_control_technology": [ - { - "value": "V-Chip", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "power_consumption": [ - { - "unit": "watts", - "value": 120, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "cpsia_cautionary_statement": [ - { - "value": "no_warning_applicable", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_type_keyword": [ - { - "value": "qled-televisions", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "number_of_items": [ - { - "value": 1, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "warranty_description": [ - { - "language_tag": "en_US", - "value": "1 year manufacturer", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "max_resolution": [ - { - "unit": "pixels", - "value": 8.3, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "color": [ - { - "language_tag": "en_US", - "value": "Black", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "screen_surface_description": [ - { - "language_tag": "en_US", - "value": "Flat", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "item_package_weight": [ - { - "unit": "kilograms", - "value": 62.142, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "speaker_type": [ - { - "language_tag": "en_US", - "value": "2CH", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "supported_internet_services": [ - { - "language_tag": "en_US", - "value": "Amazon Instant Video", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "YouTube", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Netflix", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Hulu", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Browser", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "tuner_technology": [ - { - "language_tag": "en_US", - "value": "Analog Tuner", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "controller_type": [ - { - "language_tag": "en_US", - "value": "SmartThings", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Voice Control", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "special_feature": [ - { - "language_tag": "en_US", - "value": "100% Color Volume with Quantum Dot; Quantum Processor 4K; Ambient Mode; Quantum HDR 4X; Real Game Enhancer", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "wireless_communication_technology": [ - { - "language_tag": "en_US", - "value": "Wi-Fi::Wi-Fi Direct::Bluetooth", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "model_year": [ - { - "value": 2019, - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "power_source_type": [ - { - "language_tag": "en_US", - "value": "Corded Electric", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "street_date": [ - { - "value": "2019-03-21T00:00:01Z", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "mounting_type": [ - { - "language_tag": "en_US", - "value": "Table Mount", - "marketplace_id": "ATVPDKIKX0DER" - }, - { - "language_tag": "en_US", - "value": "Wall Mount", - "marketplace_id": "ATVPDKIKX0DER" - } - ], - "refresh_rate": [ - { - "unit": "hertz", - "language_tag": "en_US", - "value": "120", - "marketplace_id": "ATVPDKIKX0DER" - } - ] - }, - "classifications": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "classifications": [ - { - "displayName": "QLED TVs", - "classificationId": "21489946011", - "parent": { - "displayName": "Televisions", - "classificationId": "172659", - "parent": { - "displayName": "Television & Video", - "classificationId": "1266092011", - "parent": { - "displayName": "Electronics", - "classificationId": "172282" - } - } - } - } - ] - } - ], - "dimensions": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "item": { - "height": { - "unit": "inches", - "value": 41.4 - }, - "length": { - "unit": "inches", - "value": 2.4 - }, - "weight": { - "unit": "pounds", - "value": 107.6 - }, - "width": { - "unit": "inches", - "value": 72.4 - } - }, - "package": { - "height": { - "unit": "inches", - "value": 10.49999998929 - }, - "length": { - "unit": "inches", - "value": 79.9999999184 - }, - "weight": { - "unit": "kilograms", - "value": 62.142 - }, - "width": { - "unit": "inches", - "value": 47.99999995104 - } - } - } - ], - "identifiers": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "identifiers": [ - { - "identifier": "0887276302195", - "identifierType": "EAN" - }, - { - "identifier": "00887276302195", - "identifierType": "GTIN" - }, - { - "identifier": "887276302195", - "identifierType": "UPC" - } - ] - } - ], - "images": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "images": [ - { - "variant": "MAIN", - "link": "https://m.media-amazon.com/images/I/91uohwV+k3L.jpg", - "height": 1707, - "width": 2560 - }, - { - "variant": "MAIN", - "link": "https://m.media-amazon.com/images/I/51DZzp3w3vL.jpg", - "height": 333, - "width": 500 - }, - { - "variant": "PT01", - "link": "https://m.media-amazon.com/images/I/81w2rTVShlL.jpg", - "height": 2560, - "width": 2560 - }, - { - "variant": "PT01", - "link": "https://m.media-amazon.com/images/I/41Px9eq9tkL.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT02", - "link": "https://m.media-amazon.com/images/I/51NTNhdhPyL.jpg", - "height": 375, - "width": 500 - }, - { - "variant": "PT03", - "link": "https://m.media-amazon.com/images/I/51o4zpL+A3L.jpg", - "height": 375, - "width": 500 - }, - { - "variant": "PT04", - "link": "https://m.media-amazon.com/images/I/71ux2k9GAZL.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT04", - "link": "https://m.media-amazon.com/images/I/61UUX63yw1L.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT05", - "link": "https://m.media-amazon.com/images/I/61LwHkljX-L.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT05", - "link": "https://m.media-amazon.com/images/I/51wJTQty3PL.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT06", - "link": "https://m.media-amazon.com/images/I/61uvoB4VvoL.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT06", - "link": "https://m.media-amazon.com/images/I/51ZexIO628L.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT07", - "link": "https://m.media-amazon.com/images/I/7121MGd2ncL.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT07", - "link": "https://m.media-amazon.com/images/I/61QK+JBMrGL.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT08", - "link": "https://m.media-amazon.com/images/I/61ECcGlG4IL.jpg", - "height": 1080, - "width": 1920 - }, - { - "variant": "PT08", - "link": "https://m.media-amazon.com/images/I/31TxwfqvB5L.jpg", - "height": 281, - "width": 500 - } - ] - } - ], - "productTypes": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "productType": "TELEVISION" - } - ], - "salesRanks": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "classificationRanks": [ - { - "classificationId": "21489946011", - "title": "QLED TVs", - "link": "http://www.amazon.com/gp/bestsellers/electronics/21489946011", - "rank": 113 - } - ], - "displayGroupRanks": [ - { - "websiteDisplayGroup": "ce_display_on_website", - "title": "Electronics", - "link": "http://www.amazon.com/gp/bestsellers/electronics", - "rank": 72855 - } - ] - } - ], - "summaries": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "brand": "SAMSUNG", - "browseClassification": { - "displayName": "QLED TVs", - "classificationId": "21489946011" - }, - "color": "Black", - "itemClassification": "BASE_PRODUCT", - "itemName": "Samsung QN82Q60RAFXZA Flat 82-Inch QLED 4K Q60 Series (2019) Ultra HD Smart TV with HDR and Alexa Compatibility", - "manufacturer": "Samsung", - "modelNumber": "QN82Q60RAFXZA", - "packageQuantity": 1, - "partNumber": "QN82Q60RAFXZA", - "size": "82-Inch", - "style": "TV only", - "websiteDisplayGroup": "home_theater_display_on_website", - "websiteDisplayGroupName": "Home Theater" - } - ], - "relationships": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "relationships": [ - { - "type": "VARIATION", - "parentAsins": ["B08J7TQ9FL"], - "variationTheme": { - "attributes": ["color", "size"], - "theme": "SIZE_NAME/COLOR_NAME" - } - } - ] - } - ], - "vendorDetails": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "brandCode": "SAMF9", - "manufacturerCode": "SAMF9", - "manufacturerCodeParent": "SAMF9", - "productCategory": { - "displayName": "Televisions", - "value": "50400100" - }, - "productGroup": "Home Entertainment", - "productSubcategory": { - "displayName": "Plasma TVs", - "value": "50400120" - }, - "replenishmentCategory": "OBSOLETE" - } - ] - } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "asin": { - "value": "B07N4M94X4" - }, - "marketplaceIds": { - "value": ["ATVPDKIKX0DER"] - }, - "includedData": { - "value": [ - "classifications", - "dimensions", - "identifiers", - "images", - "productTypes", - "relationships", - "salesRanks", - "summaries", - "vendorDetails" - ] - } - } - }, - "response": { - "asin": "B07N4M94X4", - "classifications": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "classifications": [ - { - "displayName": "QLED TVs", - "classificationId": "21489946011", - "parent": { - "displayName": "Televisions", - "classificationId": "172659", - "parent": { - "displayName": "Television & Video", - "classificationId": "1266092011", - "parent": { - "displayName": "Electronics", - "classificationId": "172282" - } - } - } - } - ] - } - ], - "dimensions": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "item": { - "height": { - "unit": "inches", - "value": 41.4 - }, - "length": { - "unit": "inches", - "value": 2.4 - }, - "weight": { - "unit": "pounds", - "value": 107.6 - }, - "width": { - "unit": "inches", - "value": 72.4 - } - }, - "package": { - "height": { - "unit": "inches", - "value": 10.49999998929 - }, - "length": { - "unit": "inches", - "value": 79.9999999184 - }, - "weight": { - "unit": "kilograms", - "value": 62.142 - }, - "width": { - "unit": "inches", - "value": 47.99999995104 - } - } - } - ], - "identifiers": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "identifiers": [ - { - "identifier": "0887276302195", - "identifierType": "EAN" - }, - { - "identifier": "00887276302195", - "identifierType": "GTIN" - }, - { - "identifier": "887276302195", - "identifierType": "UPC" - } - ] - } - ], - "images": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "images": [ - { - "variant": "MAIN", - "link": "https://m.media-amazon.com/images/I/91uohwV+k3L.jpg", - "height": 1707, - "width": 2560 - }, - { - "variant": "MAIN", - "link": "https://m.media-amazon.com/images/I/51DZzp3w3vL.jpg", - "height": 333, - "width": 500 - }, - { - "variant": "PT01", - "link": "https://m.media-amazon.com/images/I/81w2rTVShlL.jpg", - "height": 2560, - "width": 2560 - }, - { - "variant": "PT01", - "link": "https://m.media-amazon.com/images/I/41Px9eq9tkL.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT02", - "link": "https://m.media-amazon.com/images/I/51NTNhdhPyL.jpg", - "height": 375, - "width": 500 - }, - { - "variant": "PT03", - "link": "https://m.media-amazon.com/images/I/51o4zpL+A3L.jpg", - "height": 375, - "width": 500 - }, - { - "variant": "PT04", - "link": "https://m.media-amazon.com/images/I/71ux2k9GAZL.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT04", - "link": "https://m.media-amazon.com/images/I/61UUX63yw1L.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT05", - "link": "https://m.media-amazon.com/images/I/61LwHkljX-L.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT05", - "link": "https://m.media-amazon.com/images/I/51wJTQty3PL.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT06", - "link": "https://m.media-amazon.com/images/I/61uvoB4VvoL.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT06", - "link": "https://m.media-amazon.com/images/I/51ZexIO628L.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT07", - "link": "https://m.media-amazon.com/images/I/7121MGd2ncL.jpg", - "height": 1000, - "width": 1000 - }, - { - "variant": "PT07", - "link": "https://m.media-amazon.com/images/I/61QK+JBMrGL.jpg", - "height": 500, - "width": 500 - }, - { - "variant": "PT08", - "link": "https://m.media-amazon.com/images/I/61ECcGlG4IL.jpg", - "height": 1080, - "width": 1920 - }, - { - "variant": "PT08", - "link": "https://m.media-amazon.com/images/I/31TxwfqvB5L.jpg", - "height": 281, - "width": 500 - } - ] - } - ], - "productTypes": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "productType": "TELEVISION" - } - ], - "salesRanks": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "classificationRanks": [ - { - "classificationId": "21489946011", - "title": "QLED TVs", - "link": "http://www.amazon.com/gp/bestsellers/electronics/21489946011", - "rank": 113 - } - ], - "displayGroupRanks": [ - { - "websiteDisplayGroup": "ce_display_on_website", - "title": "Electronics", - "link": "http://www.amazon.com/gp/bestsellers/electronics", - "rank": 72855 - } - ] - } - ], - "summaries": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "brand": "SAMSUNG", - "browseClassification": { - "displayName": "QLED TVs", - "classificationId": "21489946011" - }, - "color": "Black", - "itemClassification": "BASE_PRODUCT", - "itemName": "Samsung QN82Q60RAFXZA Flat 82-Inch QLED 4K Q60 Series (2019) Ultra HD Smart TV with HDR and Alexa Compatibility", - "manufacturer": "Samsung", - "modelNumber": "QN82Q60RAFXZA", - "packageQuantity": 1, - "partNumber": "QN82Q60RAFXZA", - "size": "82-Inch", - "style": "TV only", - "websiteDisplayGroup": "home_theater_display_on_website", - "websiteDisplayGroupName": "Home Theater" - } - ], - "relationships": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "relationships": [ - { - "type": "VARIATION", - "parentAsins": ["B08J7TQ9FL"], - "variationTheme": { - "attributes": ["color", "size"], - "theme": "SIZE_NAME/COLOR_NAME" - } - } - ] - } - ], - "vendorDetails": [ - { - "marketplaceId": "ATVPDKIKX0DER", - "brandCode": "SAMF9", - "manufacturerCode": "SAMF9", - "manufacturerCodeParent": "SAMF9", - "productCategory": { - "displayName": "Televisions", - "value": "50400100" - }, - "productGroup": "Home Entertainment", - "productSubcategory": { - "displayName": "Plasma TVs", - "value": "50400120" - }, - "replenishmentCategory": "OBSOLETE" - } - ] - } - } - ] } }, "400": { @@ -2718,43 +528,6 @@ "type": "string" } }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "asin": { - "value": "" - }, - "marketplaceIds": { - "value": ["ATVPDKIKX0DER"] - }, - "includedData": { - "value": [ - "classifications", - "dimensions", - "identifiers", - "images", - "productTypes", - "relationships", - "salesRanks", - "summaries", - "vendorDetails" - ] - } - } - }, - "response": { - "errors": [ - { - "code": "400", - "message": "Request has missing or invalid parameters and cannot be parsed." - } - ] - } - } - ] - }, "schema": { "$ref": "#/definitions/ErrorList" } @@ -2868,7 +641,10 @@ "type": "string" } }, - "required": ["code", "message"], + "required": [ + "code", + "message" + ], "type": "object" }, "ErrorList": { @@ -2883,7 +659,9 @@ } } }, - "required": ["errors"] + "required": [ + "errors" + ] }, "Item": { "description": "An item in the Amazon catalog.", @@ -2922,7 +700,9 @@ "$ref": "#/definitions/ItemVendorDetails" } }, - "required": ["asin"], + "required": [ + "asin" + ], "type": "object" }, "ItemAsin": { @@ -2950,7 +730,10 @@ "$ref": "#/definitions/ItemBrowseClassification" } }, - "required": ["displayName", "classificationId"], + "required": [ + "displayName", + "classificationId" + ], "type": "object" }, "ItemContributor": { @@ -2964,7 +747,10 @@ "type": "string" } }, - "required": ["value", "role"], + "required": [ + "value", + "role" + ], "type": "object" }, "ItemContributorRole": { @@ -2979,7 +765,9 @@ "type": "string" } }, - "required": ["value"], + "required": [ + "value" + ], "type": "object" }, "ItemBrowseClassifications": { @@ -3004,7 +792,9 @@ "type": "array" } }, - "required": ["marketplaceId"], + "required": [ + "marketplaceId" + ], "type": "object" }, "Dimension": { @@ -3066,7 +856,9 @@ "$ref": "#/definitions/Dimensions" } }, - "required": ["marketplaceId"], + "required": [ + "marketplaceId" + ], "type": "object" }, "ItemIdentifiers": { @@ -3091,7 +883,10 @@ "type": "array" } }, - "required": ["marketplaceId", "identifiers"], + "required": [ + "marketplaceId", + "identifiers" + ], "type": "object" }, "ItemIdentifier": { @@ -3106,7 +901,10 @@ "type": "string" } }, - "required": ["identifierType", "identifier"], + "required": [ + "identifierType", + "identifier" + ], "type": "object" }, "ItemImages": { @@ -3131,7 +929,10 @@ "type": "array" } }, - "required": ["marketplaceId", "images"], + "required": [ + "marketplaceId", + "images" + ], "type": "object" }, "ItemImage": { @@ -3141,7 +942,18 @@ "description": "Variant of the image, such as `MAIN` or `PT01`.", "example": "MAIN", "type": "string", - "enum": ["MAIN", "PT01", "PT02", "PT03", "PT04", "PT05", "PT06", "PT07", "PT08", "SWCH"], + "enum": [ + "MAIN", + "PT01", + "PT02", + "PT03", + "PT04", + "PT05", + "PT06", + "PT07", + "PT08", + "SWCH" + ], "x-docgen-enum-table-extension": [ { "value": "MAIN", @@ -3198,7 +1010,12 @@ "type": "integer" } }, - "required": ["variant", "link", "height", "width"], + "required": [ + "variant", + "link", + "height", + "width" + ], "type": "object" }, "ItemProductTypes": { @@ -3252,7 +1069,9 @@ "type": "array" } }, - "required": ["marketplaceId"], + "required": [ + "marketplaceId" + ], "type": "object" }, "ItemClassificationSalesRank": { @@ -3275,7 +1094,11 @@ "type": "integer" } }, - "required": ["classificationId", "title", "rank"], + "required": [ + "classificationId", + "title", + "rank" + ], "type": "object" }, "ItemDisplayGroupSalesRank": { @@ -3298,7 +1121,11 @@ "type": "integer" } }, - "required": ["websiteDisplayGroup", "title", "rank"], + "required": [ + "websiteDisplayGroup", + "title", + "rank" + ], "type": "object" }, "ItemSummaries": { @@ -3344,7 +1171,12 @@ }, "itemClassification": { "description": "Classification type that is associated with the Amazon catalog item.", - "enum": ["BASE_PRODUCT", "OTHER", "PRODUCT_BUNDLE", "VARIATION_PARENT"], + "enum": [ + "BASE_PRODUCT", + "OTHER", + "PRODUCT_BUNDLE", + "VARIATION_PARENT" + ], "x-docgen-enum-table-extension": [ { "value": "BASE_PRODUCT", @@ -3415,7 +1247,9 @@ "type": "string" } }, - "required": ["marketplaceId"], + "required": [ + "marketplaceId" + ], "type": "object" }, "ItemVariationTheme": { @@ -3458,7 +1292,10 @@ "type": "array" } }, - "required": ["marketplaceId", "relationships"], + "required": [ + "marketplaceId", + "relationships" + ], "type": "object" }, "ItemRelationship": { @@ -3485,7 +1322,10 @@ "type": { "description": "Type of relationship.", "example": "VARIATION", - "enum": ["VARIATION", "PACKAGE_HIERARCHY"], + "enum": [ + "VARIATION", + "PACKAGE_HIERARCHY" + ], "x-docgen-enum-table-extension": [ { "value": "VARIATION", @@ -3499,7 +1339,9 @@ "type": "string" } }, - "required": ["type"], + "required": [ + "type" + ], "type": "object" }, "ItemVendorDetailsCategory": { @@ -3613,7 +1455,9 @@ "type": "string" } }, - "required": ["marketplaceId"], + "required": [ + "marketplaceId" + ], "type": "object" }, "ItemSearchResults": { @@ -3639,7 +1483,10 @@ } } }, - "required": ["numberOfResults", "items"], + "required": [ + "numberOfResults", + "items" + ], "type": "object" }, "Pagination": { @@ -3674,7 +1521,10 @@ } } }, - "required": ["brands", "classifications"], + "required": [ + "brands", + "classifications" + ], "type": "object" }, "BrandRefinement": { @@ -3689,7 +1539,10 @@ "type": "string" } }, - "required": ["numberOfResults", "brandName"], + "required": [ + "numberOfResults", + "brandName" + ], "type": "object" }, "ClassificationRefinement": { @@ -3708,7 +1561,11 @@ "type": "string" } }, - "required": ["numberOfResults", "displayName", "classificationId"], + "required": [ + "numberOfResults", + "displayName", + "classificationId" + ], "type": "object" } } diff --git a/local-ai-sandbox/res/models/dataKiosk_2023-11-15.json b/local-ai-sandbox/res/models/dataKiosk_2023-11-15.json new file mode 100644 index 000000000..ef208c34b --- /dev/null +++ b/local-ai-sandbox/res/models/dataKiosk_2023-11-15.json @@ -0,0 +1,996 @@ +{ + "swagger": "2.0", + "info": { + "description": "The Selling Partner API for Data Kiosk lets you submit GraphQL queries from a variety of schemas to help selling partners manage their businesses.", + "version": "2023-11-15", + "title": "Selling Partner API for Data Kiosk", + "contact": { + "name": "Selling Partner API Developer Support", + "url": "https://sellercentral.amazon.com/gp/mws/contactus.html" + }, + "license": { + "name": "Apache License 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0" + } + }, + "host": "sellingpartnerapi-na.amazon.com", + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/dataKiosk/2023-11-15/queries": { + "get": { + "tags": [ + "queries" + ], + "operationId": "getQueries", + "description": "Returns details for the Data Kiosk queries that match the specified filters. See the `createQuery` operation for details about query retention.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0222 | 10 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "parameters": [ + { + "name": "processingStatuses", + "in": "query", + "description": "A list of processing statuses used to filter queries.", + "required": false, + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "CANCELLED", + "DONE", + "FATAL", + "IN_PROGRESS", + "IN_QUEUE" + ], + "x-docgen-enum-table-extension": [ + { + "value": "CANCELLED", + "description": "The query was cancelled before it began processing." + }, + { + "value": "DONE", + "description": "The query has completed processing." + }, + { + "value": "FATAL", + "description": "The query was aborted due to a fatal error." + }, + { + "value": "IN_PROGRESS", + "description": "The query is being processed." + }, + { + "value": "IN_QUEUE", + "description": "The query has not yet started processing. It may be waiting for another `IN_PROGRESS` query." + } + ] + } + }, + { + "name": "pageSize", + "in": "query", + "description": "The maximum number of queries to return in a single call.", + "required": false, + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 10 + }, + { + "name": "createdSince", + "in": "query", + "description": "The earliest query creation date and time for queries to include in the response, in ISO 8601 date time format. The default is 90 days ago.", + "required": false, + "type": "string", + "format": "date-time" + }, + { + "name": "createdUntil", + "in": "query", + "description": "The latest query creation date and time for queries to include in the response, in ISO 8601 date time format. The default is the time of the `getQueries` request.", + "required": false, + "type": "string", + "format": "date-time" + }, + { + "name": "paginationToken", + "in": "query", + "description": "A token to fetch a certain page of results when there are multiple pages of results available. The value of this token is fetched from the `pagination.nextToken` field returned in the `GetQueriesResponse` object. All other parameters must be provided with the same values that were provided with the request that generated this token, with the exception of `pageSize` which can be modified between calls to `getQueries`. In the absence of this token value, `getQueries` returns the first page of results.", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/GetQueriesResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + }, + "post": { + "tags": [ + "queries" + ], + "operationId": "createQuery", + "description": "Creates a Data Kiosk query request.\n\n**Note:** The retention of a query varies based on the fields requested. Each field within a schema is annotated with a `@resultRetention` directive that defines how long a query containing that field will be retained. When a query contains multiple fields with different retentions, the shortest (minimum) retention is applied. The retention of a query's resulting documents always matches the retention of the query.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0167 | 15 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "parameters": [ + { + "name": "body", + "in": "body", + "description": "The body of the request.", + "required": true, + "schema": { + "$ref": "#/definitions/CreateQuerySpecification" + } + } + ], + "responses": { + "202": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/CreateQueryResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + }, + "parameters": [] + }, + "/dataKiosk/2023-11-15/queries/{queryId}": { + "delete": { + "tags": [ + "queries" + ], + "operationId": "cancelQuery", + "description": "Cancels the query specified by the `queryId` parameter. Only queries with a non-terminal `processingStatus` (`IN_QUEUE`, `IN_PROGRESS`) can be cancelled. Cancelling a query that already has a `processingStatus` of `CANCELLED` will no-op. Cancelled queries are returned in subsequent calls to the `getQuery` and `getQueries` operations.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0222 | 10 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "parameters": [ + { + "name": "queryId", + "in": "path", + "description": "The identifier for the query. This identifier is unique only in combination with a selling partner account ID.", + "required": true, + "type": "string" + } + ], + "responses": { + "204": { + "description": "Success.", + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + }, + "get": { + "tags": [ + "queries" + ], + "operationId": "getQuery", + "description": "Returns query details for the query specified by the `queryId` parameter. See the `createQuery` operation for details about query retention.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 2.0 | 15 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "parameters": [ + { + "name": "queryId", + "in": "path", + "required": true, + "description": "The query identifier.", + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/Query" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + }, + "parameters": [] + }, + "/dataKiosk/2023-11-15/documents/{documentId}": { + "get": { + "tags": [ + "queries" + ], + "description": "Returns the information required for retrieving a Data Kiosk document's contents. See the `createQuery` operation for details about document retention.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0167 | 15 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "getDocument", + "parameters": [ + { + "name": "documentId", + "in": "path", + "description": "The identifier for the Data Kiosk document.", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/GetDocumentResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + } + } + }, + "definitions": { + "ErrorList": { + "type": "object", + "description": "A list of error responses returned when a request is unsuccessful.", + "required": [ + "errors" + ], + "properties": { + "errors": { + "description": "Error response returned when the request is unsuccessful.", + "type": "array", + "items": { + "$ref": "#/definitions/Error" + } + } + } + }, + "Error": { + "type": "object", + "description": "Error response returned when the request is unsuccessful.", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "An error code that identifies the type of error that occurred." + }, + "message": { + "type": "string", + "description": "A message that describes the error condition." + }, + "details": { + "type": "string", + "description": "Additional details that can help the caller understand or fix the issue." + } + } + }, + "Query": { + "type": "object", + "description": "Detailed information about the query.", + "required": [ + "processingStatus", + "queryId", + "query", + "createdTime" + ], + "properties": { + "queryId": { + "description": "The query identifier. This identifier is unique only in combination with a selling partner account ID.", + "type": "string" + }, + "query": { + "description": "The submitted query.", + "type": "string" + }, + "createdTime": { + "description": "The date and time when the query was created, in ISO 8601 date time format.", + "type": "string", + "format": "date-time" + }, + "processingStatus": { + "description": "The processing status of the query.", + "type": "string", + "enum": [ + "CANCELLED", + "DONE", + "FATAL", + "IN_PROGRESS", + "IN_QUEUE" + ], + "x-docgen-enum-table-extension": [ + { + "value": "CANCELLED", + "description": "The query was cancelled before it began processing." + }, + { + "value": "DONE", + "description": "The query has completed processing." + }, + { + "value": "FATAL", + "description": "The query was aborted due to a fatal error." + }, + { + "value": "IN_PROGRESS", + "description": "The query is being processed." + }, + { + "value": "IN_QUEUE", + "description": "The query has not yet started processing. It may be waiting for another `IN_PROGRESS` query." + } + ] + }, + "processingStartTime": { + "description": "The date and time when the query processing started, in ISO 8601 date time format.", + "type": "string", + "format": "date-time" + }, + "processingEndTime": { + "description": "The date and time when the query processing completed, in ISO 8601 date time format.", + "type": "string", + "format": "date-time" + }, + "dataDocumentId": { + "description": "The data document identifier. This identifier is only present when there is data available as a result of the query. This identifier is unique only in combination with a selling partner account ID. Pass this identifier into the `getDocument` operation to get the information required to retrieve the data document's contents.", + "type": "string" + }, + "errorDocumentId": { + "description": "The error document identifier. This identifier is only present when an error occurs during query processing. This identifier is unique only in combination with a selling partner account ID. Pass this identifier into the `getDocument` operation to get the information required to retrieve the error document's contents.", + "type": "string" + }, + "pagination": { + "type": "object", + "description": "When a query produces results that are not included in the data document, pagination occurs. This means the results are divided into pages. To retrieve the next page, you must pass a `CreateQuerySpecification` object with `paginationToken` set to this object's `nextToken` and with `query` set to this object's `query` in the subsequent `createQuery` request. When there are no more pages to fetch, the `nextToken` field will be absent.", + "properties": { + "nextToken": { + "type": "string", + "description": "A token that can be used to fetch the next page of results." + } + } + } + } + }, + "QueryList": { + "type": "array", + "description": "A list of queries.", + "items": { + "$ref": "#/definitions/Query" + } + }, + "CreateQuerySpecification": { + "type": "object", + "description": "Information required to create the query.", + "required": [ + "query" + ], + "properties": { + "query": { + "description": "The GraphQL query to submit. A query must be at most 8000 characters after unnecessary whitespace is removed.", + "type": "string" + }, + "paginationToken": { + "description": "A token to fetch a certain page of query results when there are multiple pages of query results available. The value of this token must be fetched from the `pagination.nextToken` field of the `Query` object, and the `query` field for this object must also be set to the `query` field of the same `Query` object. A `Query` object can be retrieved from either the `getQueries` or `getQuery` operation. In the absence of this token value, the first page of query results will be requested.", + "type": "string" + } + } + }, + "CreateQueryResponse": { + "type": "object", + "description": "The response for the `createQuery` operation.", + "required": [ + "queryId" + ], + "properties": { + "queryId": { + "description": "The identifier for the query. This identifier is unique only in combination with a selling partner account ID.", + "type": "string" + } + } + }, + "GetQueriesResponse": { + "type": "object", + "description": "The response for the `getQueries` operation.", + "required": [ + "queries" + ], + "properties": { + "queries": { + "description": "The Data Kiosk queries.", + "$ref": "#/definitions/QueryList" + }, + "pagination": { + "type": "object", + "description": "When a request has results that are not included in this response, pagination occurs. This means the results are divided into pages. To retrieve the next page, you must pass the `nextToken` as the `paginationToken` query parameter in the subsequent `getQueries` request. All other parameters must be provided with the same values that were provided with the request that generated this token, with the exception of `pageSize` which can be modified between calls to `getQueries`. When there are no more pages to fetch, the `nextToken` field will be absent.", + "properties": { + "nextToken": { + "type": "string", + "description": "A token that can be used to fetch the next page of results." + } + } + } + } + }, + "GetDocumentResponse": { + "type": "object", + "description": "The response for the `getDocument` operation.", + "required": [ + "documentId", + "documentUrl" + ], + "properties": { + "documentId": { + "description": "The identifier for the Data Kiosk document. This identifier is unique only in combination with a selling partner account ID.", + "type": "string" + }, + "documentUrl": { + "description": "A presigned URL that can be used to retrieve the Data Kiosk document. This URL expires after 5 minutes. If the Data Kiosk document is compressed, the `Content-Encoding` header will indicate the compression algorithm.\n\n**Note:** Most HTTP clients are capable of automatically decompressing downloaded files based on the `Content-Encoding` header.", + "type": "string" + } + } + } + }, + "basePath": "/" +} diff --git a/local-ai-sandbox/res/models/definitionsProductTypes_2020-09-01.json b/local-ai-sandbox/res/models/definitionsProductTypes_2020-09-01.json new file mode 100644 index 000000000..4197b3f21 --- /dev/null +++ b/local-ai-sandbox/res/models/definitionsProductTypes_2020-09-01.json @@ -0,0 +1,940 @@ +{ + "swagger": "2.0", + "info": { + "description": "The Selling Partner API for Product Type Definitions provides programmatic access to attribute and data requirements for product types in the Amazon catalog. Use this API to return the JSON Schema for a product type that you can then use with other Selling Partner APIs, such as the Selling Partner API for Listings Items, the Selling Partner API for Catalog Items, and the Selling Partner API for Feeds (for JSON-based listing feeds).\n\nFor more information, see the [Product Type Definitions API Use Case Guide](doc:product-type-api-use-case-guide).", + "version": "2020-09-01", + "title": "Selling Partner API for Product Type Definitions", + "contact": { + "name": "Selling Partner API Developer Support", + "url": "https://sellercentral.amazon.com/gp/mws/contactus.html" + }, + "license": { + "name": "Apache License 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0" + } + }, + "host": "sellingpartnerapi-na.amazon.com", + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/definitions/2020-09-01/productTypes": { + "get": { + "tags": [ + "definitions" + ], + "description": "Search for and return a list of Amazon product types that have definitions available.\n\n**Usage Plans:**\n\n| Plan type | Rate (requests per second) | Burst |\n| ---- | ---- | ---- |\n|Default| 5 | 10 |\n|Selling partner specific| Variable | Variable |\n\nThe x-amzn-RateLimit-Limit response header returns the usage plan rate limits that were applied to the requested operation. Rate limits for some selling partners will vary from the default rate and burst shown in the table above. For more information, see [Usage Plans and Rate Limits in the Selling Partner API](doc:usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "searchDefinitionsProductTypes", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "keywords", + "in": "query", + "description": "A comma-delimited list of keywords to search product types. **Note:** Cannot be used with `itemName`.", + "required": false, + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "x-example": "LUGGAGE" + }, + { + "name": "marketplaceIds", + "in": "query", + "description": "A comma-delimited list of Amazon marketplace identifiers for the request.", + "required": true, + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "x-example": "ATVPDKIKX0DER" + }, + { + "name": "itemName", + "description": "Title of ASIN to get product type recommendation. **Note:** Cannot be used with `keywords`.", + "in": "query", + "required": false, + "type": "string", + "x-example": "Running shoes" + }, + { + "name": "locale", + "description": "Locale for display names in response. Defaults to primary locale of the marketplace.", + "in": "query", + "required": false, + "type": "string", + "x-example": "en_US" + }, + { + "name": "searchLocale", + "description": "Language used for `keywords` or `itemName` parameters. Defaults to primary locale of the marketplace.", + "in": "query", + "required": false, + "type": "string", + "x-example": "en_US" + } + ], + "responses": { + "200": { + "description": "Successfully retrieved a list of Amazon product types that have definitions available.", + "schema": { + "$ref": "#/definitions/ProductTypeList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request exceeds the maximum size.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + }, + "parameters": [] + }, + "/definitions/2020-09-01/productTypes/{productType}": { + "get": { + "tags": [ + "definitions" + ], + "description": "Retrieve an Amazon product type definition.\n\n**Usage Plans:**\n\n| Plan type | Rate (requests per second) | Burst |\n| ---- | ---- | ---- |\n|Default| 5 | 10 |\n|Selling partner specific| Variable | Variable |\n\nThe x-amzn-RateLimit-Limit response header returns the usage plan rate limits that were applied to the requested operation. Rate limits for some selling partners will vary from the default rate and burst shown in the table above. For more information, see [Usage Plans and Rate Limits in the Selling Partner API](doc:usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "getDefinitionsProductType", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "productType", + "in": "path", + "description": "The Amazon product type name.", + "required": true, + "type": "string", + "x-example": "LUGGAGE" + }, + { + "name": "sellerId", + "in": "query", + "description": "A selling partner identifier. When provided, seller-specific requirements and values are populated within the product type definition schema, such as brand names associated with the selling partner.", + "required": false, + "type": "string" + }, + { + "name": "marketplaceIds", + "in": "query", + "description": "A comma-delimited list of Amazon marketplace identifiers for the request.\nNote: This parameter is limited to one marketplaceId at this time.", + "required": true, + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "x-example": "ATVPDKIKX0DER" + }, + { + "name": "productTypeVersion", + "in": "query", + "description": "The version of the Amazon product type to retrieve. Defaults to \"LATEST\". Prerelease versions of product type definitions may be retrieved with \"RELEASE_CANDIDATE\". If no prerelease version is currently available, the \"LATEST\" live version will be provided.", + "required": false, + "type": "string", + "default": "LATEST", + "x-example": "LATEST" + }, + { + "name": "requirements", + "in": "query", + "description": "The name of the requirements set to retrieve requirements for.", + "required": false, + "type": "string", + "default": "LISTING", + "enum": [ + "LISTING", + "LISTING_PRODUCT_ONLY", + "LISTING_OFFER_ONLY" + ], + "x-docgen-enum-table-extension": [ + { + "value": "LISTING", + "description": "Request schema containing product facts and sales terms." + }, + { + "value": "LISTING_PRODUCT_ONLY", + "description": "Request schema containing product facts only." + }, + { + "value": "LISTING_OFFER_ONLY", + "description": "Request schema containing sales terms only." + } + ], + "x-example": "LISTING" + }, + { + "name": "requirementsEnforced", + "in": "query", + "description": "Identifies if the required attributes for a requirements set are enforced by the product type definition schema. Non-enforced requirements enable structural validation of individual attributes without all the required attributes being present (such as for partial updates).", + "required": false, + "type": "string", + "default": "ENFORCED", + "enum": [ + "ENFORCED", + "NOT_ENFORCED" + ], + "x-docgen-enum-table-extension": [ + { + "value": "ENFORCED", + "description": "Request schema with required and conditionally required attributes enforced (used for full payload validation)." + }, + { + "value": "NOT_ENFORCED", + "description": "Request schema with required and conditionally required attributes not enforced (used for partial payload validation, such as for single attributes)." + } + ], + "x-example": "ENFORCED" + }, + { + "name": "locale", + "in": "query", + "description": "Locale for retrieving display labels and other presentation details. Defaults to the default language of the first marketplace in the request.", + "required": false, + "type": "string", + "default": "DEFAULT", + "enum": [ + "DEFAULT", + "ar", + "ar_AE", + "de", + "de_DE", + "en", + "en_AE", + "en_AU", + "en_CA", + "en_GB", + "en_IN", + "en_SG", + "en_US", + "es", + "es_ES", + "es_MX", + "es_US", + "fr", + "fr_CA", + "fr_FR", + "it", + "it_IT", + "ja", + "ja_JP", + "nl", + "nl_NL", + "pl", + "pl_PL", + "pt", + "pt_BR", + "pt_PT", + "sv", + "sv_SE", + "tr", + "tr_TR", + "zh", + "zh_CN", + "zh_TW" + ], + "x-docgen-enum-table-extension": [ + { + "value": "DEFAULT", + "description": "Default locale of the requested Amazon marketplace." + }, + { + "value": "ar", + "description": "Arabic" + }, + { + "value": "ar_AE", + "description": "Arabic (U.A.E.)" + }, + { + "value": "de", + "description": "German" + }, + { + "value": "de_DE", + "description": "German (Germany)" + }, + { + "value": "en", + "description": "English" + }, + { + "value": "en_AE", + "description": "English (U.A.E.)" + }, + { + "value": "en_AU", + "description": "English (Australia)" + }, + { + "value": "en_CA", + "description": "English (Canada)" + }, + { + "value": "en_GB", + "description": "English (United Kingdom)" + }, + { + "value": "en_IN", + "description": "English (India)" + }, + { + "value": "en_SG", + "description": "English (Singapore)" + }, + { + "value": "en_US", + "description": "English (United States)" + }, + { + "value": "es", + "description": "Spanish" + }, + { + "value": "es_ES", + "description": "Spanish (Spain)" + }, + { + "value": "es_MX", + "description": "Spanish (Mexico)" + }, + { + "value": "es_US", + "description": "Spanish (United States)" + }, + { + "value": "fr", + "description": "French" + }, + { + "value": "fr_CA", + "description": "French (Canada)" + }, + { + "value": "fr_FR", + "description": "French (France)" + }, + { + "value": "it", + "description": "Italian" + }, + { + "value": "it_IT", + "description": "Italian (Italy)" + }, + { + "value": "ja", + "description": "Japanese" + }, + { + "value": "ja_JP", + "description": "Japanese (Japan)" + }, + { + "value": "nl", + "description": "Dutch" + }, + { + "value": "nl_NL", + "description": "Dutch (Netherlands)" + }, + { + "value": "pl", + "description": "Polish" + }, + { + "value": "pl_PL", + "description": "Polish (Poland)" + }, + { + "value": "pt", + "description": "Portuguese" + }, + { + "value": "pt_BR", + "description": "Portuguese (Brazil)" + }, + { + "value": "pt_PT", + "description": "Portuguese (Portugal)" + }, + { + "value": "sv", + "description": "Swedish" + }, + { + "value": "sv_SE", + "description": "Swedish (Sweden)" + }, + { + "value": "tr", + "description": "Turkish" + }, + { + "value": "tr_TR", + "description": "Turkish (Turkey)" + }, + { + "value": "zh", + "description": "Chinese" + }, + { + "value": "zh_CN", + "description": "Chinese (Simplified)" + }, + { + "value": "zh_TW", + "description": "Chinese (Traditional)" + } + ], + "x-example": "DEFAULT" + }, + { + "name": "parentageLevel", + "in": "query", + "description": "The parentage level of the listing to retrieve a schema for. When provided, the schema is simplified by resolving all conditional logic related to the specified parentage level, resulting in a smaller schema with fewer conditions.", + "required": false, + "type": "string", + "enum": [ + "NONE", + "CHILD", + "PARENT" + ], + "x-docgen-enum-table-extension": [ + { + "value": "NONE", + "description": "Schema for standalone listings with no variation relationships. Eliminates all variation-related conditional logic." + }, + { + "value": "CHILD", + "description": "Schema for variation child listings. Eliminates conditional logic that does not apply to listings with a parent-child variation relationship." + }, + { + "value": "PARENT", + "description": "Schema for variation parent listings. Eliminates conditional logic that does not apply to variation group containers." + } + ], + "x-example": "CHILD" + } + ], + "responses": { + "200": { + "description": "Successfully retrieved an Amazon product type definition.", + "schema": { + "$ref": "#/definitions/ProductTypeDefinition" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request exceeds the maximum size.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + }, + "parameters": [] + } + }, + "definitions": { + "Error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "An error code that identifies the type of error that occurred." + }, + "message": { + "type": "string", + "description": "A message that describes the error condition." + }, + "details": { + "type": "string", + "description": "Additional details that can help the caller understand or fix the issue." + } + }, + "description": "Error response returned when the request is unsuccessful." + }, + "ErrorList": { + "type": "object", + "required": [ + "errors" + ], + "properties": { + "errors": { + "description": "A list of error responses.", + "type": "array", + "items": { + "$ref": "#/definitions/Error" + } + } + }, + "description": "A list of error responses returned when a request is unsuccessful." + }, + "SchemaLink": { + "description": "A link to metadata schema.", + "type": "object", + "required": [ + "checksum", + "link" + ], + "properties": { + "link": { + "type": "object", + "description": "Link to retrieve the schema.", + "properties": { + "resource": { + "type": "string", + "description": "URI resource for the link." + }, + "verb": { + "type": "string", + "description": "HTTP method for the link operation.", + "enum": [ + "GET" + ], + "x-docgen-enum-table-extension": [ + { + "value": "GET", + "description": "The provided resource is accessed with the HTTP GET method." + } + ] + } + }, + "required": [ + "resource", + "verb" + ] + }, + "checksum": { + "type": "string", + "description": "Checksum hash of the schema (Base64 MD5). Use this to verify schema contents, identify changes between schema versions, and for caching." + } + } + }, + "ProductTypeDefinition": { + "type": "object", + "required": [ + "locale", + "marketplaceIds", + "productType", + "displayName", + "productTypeVersion", + "propertyGroups", + "requirements", + "requirementsEnforced", + "schema" + ], + "properties": { + "metaSchema": { + "description": "Link to meta-schema describing the vocabulary used by the product type schema.", + "$ref": "#/definitions/SchemaLink" + }, + "schema": { + "description": "Link to schema describing the attributes and requirements for the product type.", + "$ref": "#/definitions/SchemaLink" + }, + "requirements": { + "type": "string", + "description": "Name of the requirements set represented in this product type definition.", + "enum": [ + "LISTING", + "LISTING_PRODUCT_ONLY", + "LISTING_OFFER_ONLY" + ], + "x-docgen-enum-table-extension": [ + { + "value": "LISTING", + "description": "Indicates the schema data contains product facts and sales terms." + }, + { + "value": "LISTING_PRODUCT_ONLY", + "description": "Indicates the schema data contains product facts only." + }, + { + "value": "LISTING_OFFER_ONLY", + "description": "Indicates the schema data contains sales terms only." + } + ] + }, + "requirementsEnforced": { + "type": "string", + "description": "Identifies if the required attributes for a requirements set are enforced by the product type definition schema. Non-enforced requirements enable structural validation of individual attributes without all of the required attributes being present (such as for partial updates).", + "enum": [ + "ENFORCED", + "NOT_ENFORCED" + ], + "x-docgen-enum-table-extension": [ + { + "value": "ENFORCED", + "description": "Schema enforces required and conditionally required attributes (used for full payload validation)." + }, + { + "value": "NOT_ENFORCED", + "description": "Schema does not enforce required and conditionally required attributes (used for partial payload validation, such as for single attributes)." + } + ] + }, + "propertyGroups": { + "type": "object", + "description": "Mapping of property group names to property groups. Property groups represent logical groupings of schema properties that can be used for display or informational purposes.", + "additionalProperties": { + "$ref": "#/definitions/PropertyGroup" + } + }, + "locale": { + "type": "string", + "description": "Locale of the display elements contained in the product type definition." + }, + "marketplaceIds": { + "type": "array", + "description": "Amazon marketplace identifiers for which the product type definition is applicable.", + "items": { + "type": "string" + } + }, + "productType": { + "type": "string", + "description": "The name of the Amazon product type that this product type definition applies to." + }, + "displayName": { + "type": "string", + "description": "Human-readable and localized description of the Amazon product type." + }, + "productTypeVersion": { + "description": "The version details for the Amazon product type.", + "$ref": "#/definitions/ProductTypeVersion" + } + }, + "description": "A product type definition represents the attributes and data requirements for a product type in the Amazon catalog. Product type definitions are used interchangeably between the Selling Partner API for Listings Items, Selling Partner API for Catalog Items, and JSON-based listings feeds in the Selling Partner API for Feeds." + }, + "PropertyGroup": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The display label of the property group." + }, + "description": { + "type": "string", + "description": "The description of the property group." + }, + "propertyNames": { + "type": "array", + "description": "The names of the schema properties for the property group.", + "items": { + "type": "string" + } + } + }, + "description": "A property group represents a logical grouping of schema properties that can be used for display or informational purposes." + }, + "ProductTypeVersion": { + "type": "object", + "required": [ + "latest", + "version" + ], + "properties": { + "version": { + "type": "string", + "description": "Version identifier." + }, + "latest": { + "type": "boolean", + "description": "When true, the version indicated by the version identifier is the latest available for the Amazon product type." + }, + "releaseCandidate": { + "type": "boolean", + "description": "When true, the version indicated by the version identifier is the prerelease (release candidate) for the Amazon product type." + } + }, + "description": "The version details for an Amazon product type." + }, + "ProductType": { + "type": "object", + "required": [ + "marketplaceIds", + "name", + "displayName" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the Amazon product type." + }, + "displayName": { + "type": "string", + "description": "Human-readable and localized description of the Amazon product type." + }, + "marketplaceIds": { + "type": "array", + "description": "The Amazon marketplace identifiers for which the product type definition is available.", + "items": { + "type": "string" + } + } + }, + "description": "An Amazon product type with a definition available." + }, + "ProductTypeList": { + "type": "object", + "required": [ + "productTypes", + "productTypeVersion" + ], + "properties": { + "productTypes": { + "description": "A list of product types.", + "type": "array", + "items": { + "$ref": "#/definitions/ProductType" + } + }, + "productTypeVersion": { + "description": "Amazon product type version identifier.", + "type": "string" + } + }, + "description": "A list of Amazon product types with definitions available." + } + } +} diff --git a/local-ai-sandbox/res/models/fbaInventory_v1.json b/local-ai-sandbox/res/models/fbaInventory.json similarity index 96% rename from local-ai-sandbox/res/models/fbaInventory_v1.json rename to local-ai-sandbox/res/models/fbaInventory.json index 563708be5..5c7c559d3 100644 --- a/local-ai-sandbox/res/models/fbaInventory_v1.json +++ b/local-ai-sandbox/res/models/fbaInventory.json @@ -14,19 +14,26 @@ } }, "host": "sellingpartnerapi-na.amazon.com", - "schemes": ["https"], - "consumes": ["application/json"], - "produces": ["application/json"], + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "paths": { "/fba/inventory/v1/summaries": { - "x-amzn-api-sandbox": { - "dynamic": {} - }, "get": { - "tags": ["fbaInventory"], + "tags": [ + "fbaInventory" + ], "description": "Returns a list of inventory summaries. The summaries returned depend on the presence or absence of the startDateTime, sellerSkus and sellerSku parameters:\n\n- All inventory summaries with available details are returned when the startDateTime, sellerSkus and sellerSku parameters are omitted.\n- When startDateTime is provided, the operation returns inventory summaries that have had changes after the date and time specified. The sellerSkus and sellerSku parameters are ignored. Important: To avoid errors, use both startDateTime and nextToken to get the next page of inventory summaries that have changed after the date and time specified.\n- When the sellerSkus parameter is provided, the operation returns inventory summaries for only the specified sellerSkus. The sellerSku parameter is ignored.\n- When the sellerSku parameter is provided, the operation returns inventory summaries for only the specified sellerSku.\n\nNote: The parameters associated with this operation may contain special characters that must be encoded to successfully call the API. To avoid errors with SKUs when encoding URLs, refer to [URL Encoding](https://developer-docs.amazon.com/sp-api/docs/url-encoding).\n\nUsage Plan:\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 2 | 2 |\n\nThe x-amzn-RateLimit-Limit response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, see [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits).", "operationId": "getInventorySummaries", - "produces": ["application/json"], + "produces": [ + "application/json" + ], "parameters": [ { "name": "details", @@ -42,7 +49,9 @@ "description": "The granularity type for the inventory aggregation level.", "required": true, "type": "string", - "enum": ["Marketplace"], + "enum": [ + "Marketplace" + ], "x-docgen-enum-table-extension": [ { "value": "Marketplace", @@ -203,15 +212,16 @@ } }, "/fba/inventory/v1/items": { - "x-amzn-api-sandbox": { - "dynamic": {} - }, "x-amzn-api-sandbox-only": true, "post": { - "tags": ["fbaInventory"], + "tags": [ + "fbaInventory" + ], "description": "Requests that Amazon create product-details in the Sandbox Inventory in the sandbox environment. This is a sandbox-only operation and must be directed to a sandbox endpoint. Refer to [Selling Partner API sandbox](https://developer-docs.amazon.com/sp-api/docs/the-selling-partner-api-sandbox) for more information.", "operationId": "createInventoryItem", - "produces": ["application/json"], + "produces": [ + "application/json" + ], "parameters": [ { "name": "createInventoryItemRequestBody", @@ -312,15 +322,16 @@ } }, "/fba/inventory/v1/items/{sellerSku}": { - "x-amzn-api-sandbox": { - "dynamic": {} - }, "x-amzn-api-sandbox-only": true, "delete": { - "tags": ["fbaInventory"], + "tags": [ + "fbaInventory" + ], "description": "Requests that Amazon Deletes an item from the Sandbox Inventory in the sandbox environment. This is a sandbox-only operation and must be directed to a sandbox endpoint. Refer to [Selling Partner API sandbox](https://developer-docs.amazon.com/sp-api/docs/the-selling-partner-api-sandbox) for more information.", "operationId": "deleteInventoryItem", - "produces": ["application/json"], + "produces": [ + "application/json" + ], "parameters": [ { "name": "sellerSku", @@ -426,15 +437,16 @@ } }, "/fba/inventory/v1/items/inventory": { - "x-amzn-api-sandbox": { - "dynamic": {} - }, "x-amzn-api-sandbox-only": true, "post": { - "tags": ["fbaInventory"], + "tags": [ + "fbaInventory" + ], "description": "Requests that Amazon add items to the Sandbox Inventory with desired amount of quantity in the sandbox environment. This is a sandbox-only operation and must be directed to a sandbox endpoint. Refer to [Selling Partner API sandbox](https://developer-docs.amazon.com/sp-api/docs/the-selling-partner-api-sandbox) for more information.", "operationId": "addInventory", - "produces": ["application/json"], + "produces": [ + "application/json" + ], "parameters": [ { "name": "x-amzn-idempotency-token", @@ -560,7 +572,11 @@ "type": "string" } }, - "required": ["sellerSku", "marketplaceId", "productName"] + "required": [ + "sellerSku", + "marketplaceId", + "productName" + ] }, "AddInventoryRequest": { "description": "The object with the list of Inventory to be added", @@ -595,7 +611,11 @@ "type": "integer" } }, - "required": ["sellerSku", "marketplaceId", "quantity"] + "required": [ + "sellerSku", + "marketplaceId", + "quantity" + ] }, "CreateInventoryItemResponse": { "type": "object", @@ -671,12 +691,19 @@ }, "ResearchingQuantityEntry": { "type": "object", - "required": ["name", "quantity"], + "required": [ + "name", + "quantity" + ], "properties": { "name": { "type": "string", "description": "The duration of the research.", - "enum": ["researchingQuantityInShortTerm", "researchingQuantityInMidTerm", "researchingQuantityInLongTerm"], + "enum": [ + "researchingQuantityInShortTerm", + "researchingQuantityInMidTerm", + "researchingQuantityInLongTerm" + ], "x-docgen-enum-table-extension": [ { "value": "researchingQuantityInShortTerm", @@ -845,7 +872,10 @@ }, "GetInventorySummariesResult": { "type": "object", - "required": ["granularity", "inventorySummaries"], + "required": [ + "granularity", + "inventorySummaries" + ], "properties": { "granularity": { "$ref": "#/definitions/Granularity" @@ -875,7 +905,9 @@ }, "Error": { "type": "object", - "required": ["code"], + "required": [ + "code" + ], "properties": { "code": { "type": "string", diff --git a/local-ai-sandbox/res/models/listingsItems_2021-08-01.json b/local-ai-sandbox/res/models/listingsItems_2021-08-01.json index b04bf97d3..cb73f45c7 100644 --- a/local-ai-sandbox/res/models/listingsItems_2021-08-01.json +++ b/local-ai-sandbox/res/models/listingsItems_2021-08-01.json @@ -14,17 +14,29 @@ } }, "host": "sellingpartnerapi-na.amazon.com", - "schemes": ["https"], - "consumes": ["application/json"], - "produces": ["application/json"], + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "paths": { "/listings/2021-08-01/items/{sellerId}/{sku}": { "delete": { - "tags": ["listings"], + "tags": [ + "listings" + ], "description": "Delete a listings item for a selling partner.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 5 | 5 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The preceding table indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput can receive higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api) in the Selling Partner API documentation.", "operationId": "deleteListingsItem", - "consumes": ["application/json"], - "produces": ["application/json"], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "parameters": [ { "name": "sellerId", @@ -170,11 +182,17 @@ } }, "get": { - "tags": ["listings"], + "tags": [ + "listings" + ], "description": "Returns details about a listings item for a selling partner.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 5 | 10 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The preceding table indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput can receive higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api) in the Selling Partner API documentation.", "operationId": "getListingsItem", - "consumes": ["application/json"], - "produces": ["application/json"], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "parameters": [ { "name": "sellerId", @@ -218,7 +236,16 @@ "required": false, "type": "array", "items": { - "enum": ["summaries", "attributes", "issues", "offers", "fulfillmentAvailability", "procurement", "relationships", "productTypes"], + "enum": [ + "summaries", + "attributes", + "issues", + "offers", + "fulfillmentAvailability", + "procurement", + "relationships", + "productTypes" + ], "x-docgen-enum-table-extension": [ { "value": "summaries", @@ -257,7 +284,9 @@ }, "collectionFormat": "csv", "x-example": "summaries", - "default": ["summaries"] + "default": [ + "summaries" + ] } ], "responses": { @@ -384,11 +413,17 @@ } }, "patch": { - "tags": ["listings"], + "tags": [ + "listings" + ], "description": "Partially update (patch) a listings item for a selling partner. Only top-level listings item attributes can be patched. Patching nested attributes is not supported.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 5 | 5 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The preceding table indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput can receive higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api) in the Selling Partner API documentation.", "operationId": "patchListingsItem", - "consumes": ["application/json"], - "produces": ["application/json"], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "parameters": [ { "name": "sellerId", @@ -410,7 +445,6 @@ "in": "query", "required": true, "type": "array", - "maxItems": 1, "items": { "type": "string" }, @@ -424,7 +458,10 @@ "required": false, "type": "array", "items": { - "enum": ["identifiers", "issues"], + "enum": [ + "identifiers", + "issues" + ], "x-docgen-enum-table-extension": [ { "value": "identifiers", @@ -439,13 +476,17 @@ }, "collectionFormat": "csv", "x-example": "issues", - "default": ["issues"] + "default": [ + "issues" + ] }, { "name": "mode", "description": "The mode of operation for the request.", "in": "query", - "enum": ["VALIDATION_PREVIEW"], + "enum": [ + "VALIDATION_PREVIEW" + ], "x-docgen-enum-table-extension": [ { "value": "VALIDATION_PREVIEW", @@ -582,11 +623,17 @@ } }, "put": { - "tags": ["listings"], + "tags": [ + "listings" + ], "description": "Creates a new or fully-updates an existing listings item for a selling partner.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 5 | 10 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The preceding table indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput can receive higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api) in the Selling Partner API documentation.", "operationId": "putListingsItem", - "consumes": ["application/json"], - "produces": ["application/json"], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "parameters": [ { "name": "sellerId", @@ -622,7 +669,10 @@ "required": false, "type": "array", "items": { - "enum": ["identifiers", "issues"], + "enum": [ + "identifiers", + "issues" + ], "x-docgen-enum-table-extension": [ { "value": "identifiers", @@ -637,13 +687,17 @@ }, "collectionFormat": "csv", "x-example": "issues", - "default": ["issues"] + "default": [ + "issues" + ] }, { "name": "mode", "description": "The mode of operation for the request.", "in": "query", - "enum": ["VALIDATION_PREVIEW"], + "enum": [ + "VALIDATION_PREVIEW" + ], "x-docgen-enum-table-extension": [ { "value": "VALIDATION_PREVIEW", @@ -782,11 +836,17 @@ }, "/listings/2021-08-01/items/{sellerId}": { "get": { - "tags": ["listings"], + "tags": [ + "listings" + ], "description": "Search for and return a list of selling partner listings items and their respective details.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 5 | 5 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that are applied to the requested operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput might have higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", "operationId": "searchListingsItems", - "consumes": ["application/json"], - "produces": ["application/json"], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "parameters": [ { "name": "sellerId", @@ -823,7 +883,16 @@ "required": false, "type": "array", "items": { - "enum": ["summaries", "attributes", "issues", "offers", "fulfillmentAvailability", "procurement", "relationships", "productTypes"], + "enum": [ + "summaries", + "attributes", + "issues", + "offers", + "fulfillmentAvailability", + "procurement", + "relationships", + "productTypes" + ], "x-docgen-enum-table-extension": [ { "value": "summaries", @@ -862,7 +931,9 @@ }, "collectionFormat": "csv", "x-example": "summaries", - "default": ["summaries"] + "default": [ + "summaries" + ] }, { "name": "identifiers", @@ -883,7 +954,17 @@ "in": "query", "required": false, "type": "string", - "enum": ["ASIN", "EAN", "FNSKU", "GTIN", "ISBN", "JAN", "MINSAN", "SKU", "UPC"], + "enum": [ + "ASIN", + "EAN", + "FNSKU", + "GTIN", + "ISBN", + "JAN", + "MINSAN", + "SKU", + "UPC" + ], "x-docgen-enum-table-extension": [ { "value": "ASIN", @@ -983,7 +1064,10 @@ "required": false, "type": "array", "items": { - "enum": ["WARNING", "ERROR"], + "enum": [ + "WARNING", + "ERROR" + ], "type": "string", "x-docgen-enum-table-extension": [ { @@ -1006,7 +1090,10 @@ "required": false, "type": "array", "items": { - "enum": ["BUYABLE", "DISCOVERABLE"], + "enum": [ + "BUYABLE", + "DISCOVERABLE" + ], "type": "string", "x-docgen-enum-table-extension": [ { @@ -1029,7 +1116,10 @@ "required": false, "type": "array", "items": { - "enum": ["BUYABLE", "DISCOVERABLE"], + "enum": [ + "BUYABLE", + "DISCOVERABLE" + ], "type": "string", "x-docgen-enum-table-extension": [ { @@ -1051,7 +1141,11 @@ "in": "query", "required": false, "type": "string", - "enum": ["sku", "createdDate", "lastUpdatedDate"], + "enum": [ + "sku", + "createdDate", + "lastUpdatedDate" + ], "x-docgen-enum-table-extension": [ { "value": "sku", @@ -1075,7 +1169,10 @@ "in": "query", "required": false, "type": "string", - "enum": ["ASC", "DESC"], + "enum": [ + "ASC", + "DESC" + ], "x-docgen-enum-table-extension": [ { "value": "ASC", @@ -1250,7 +1347,10 @@ "type": "string" } }, - "required": ["code", "message"], + "required": [ + "code", + "message" + ], "type": "object" }, "ErrorList": { @@ -1264,7 +1364,9 @@ } } }, - "required": ["errors"] + "required": [ + "errors" + ] }, "ItemSearchResults": { "description": "Selling partner listings items and search related metadata.", @@ -1285,7 +1387,10 @@ } } }, - "required": ["numberOfResults", "items"], + "required": [ + "numberOfResults", + "items" + ], "type": "object" }, "Item": { @@ -1328,7 +1433,9 @@ "$ref": "#/definitions/ItemProductTypes" } }, - "required": ["sku"], + "required": [ + "sku" + ], "type": "object" }, "ItemSummaries": { @@ -1431,7 +1538,10 @@ "type": "array", "items": { "type": "string", - "enum": ["BUYABLE", "DISCOVERABLE"], + "enum": [ + "BUYABLE", + "DISCOVERABLE" + ], "x-docgen-enum-table-extension": [ { "value": "BUYABLE", @@ -1467,7 +1577,13 @@ "$ref": "#/definitions/ItemImage" } }, - "required": ["marketplaceId", "productType", "status", "createdDate", "lastUpdatedDate"], + "required": [ + "marketplaceId", + "productType", + "status", + "createdDate", + "lastUpdatedDate" + ], "type": "object" }, "ItemImage": { @@ -1486,7 +1602,11 @@ "type": "integer" } }, - "required": ["link", "height", "width"], + "required": [ + "link", + "height", + "width" + ], "type": "object" }, "ItemAttributes": { @@ -1514,7 +1634,11 @@ }, "severity": { "description": "The severity of the issue.", - "enum": ["ERROR", "WARNING", "INFO"], + "enum": [ + "ERROR", + "WARNING", + "INFO" + ], "x-docgen-enum-table-extension": [ { "value": "ERROR", @@ -1544,14 +1668,21 @@ "items": { "type": "string" }, - "example": ["INVALID_ATTRIBUTE"] + "example": [ + "INVALID_ATTRIBUTE" + ] }, "enforcements": { "description": "This field provides information about the enforcement actions taken by Amazon that affect the publishing or status of a listing. It also includes details about any associated exemptions.", "$ref": "#/definitions/IssueEnforcements" } }, - "required": ["code", "message", "severity", "categories"], + "required": [ + "code", + "message", + "severity", + "categories" + ], "type": "object" }, "IssueEnforcements": { @@ -1570,7 +1701,10 @@ "$ref": "#/definitions/IssueExemption" } }, - "required": ["actions", "exemption"] + "required": [ + "actions", + "exemption" + ] }, "IssueEnforcementAction": { "description": "The enforcement action taken by Amazon that affect the publishing or status of a listing", @@ -1582,7 +1716,9 @@ "example": "LISTING_SUPPRESSED" } }, - "required": ["action"] + "required": [ + "action" + ] }, "IssueExemption": { "description": "Conveying the status of the listed enforcement actions and, if applicable, provides information about the exemption's expiry date.", @@ -1590,7 +1726,11 @@ "properties": { "status": { "description": "This field indicates the current exemption status for the listed enforcement actions. It can take values such as `EXEMPT`, signifying permanent exemption, `EXEMPT_UNTIL_EXPIRY_DATE` indicating temporary exemption until a specified date, or `NOT_EXEMPT` signifying no exemptions, and enforcement actions were already applied.", - "enum": ["EXEMPT", "EXEMPT_UNTIL_EXPIRY_DATE", "NOT_EXEMPT"], + "enum": [ + "EXEMPT", + "EXEMPT_UNTIL_EXPIRY_DATE", + "NOT_EXEMPT" + ], "x-docgen-enum-table-extension": [ { "value": "EXEMPT", @@ -1614,7 +1754,9 @@ "example": "2023-10-28T00:36:48.914Z" } }, - "required": ["status"] + "required": [ + "status" + ] }, "ItemOffers": { "description": "Offer details for the listings item.", @@ -1632,7 +1774,10 @@ }, "offerType": { "description": "Type of offer for the listings item.", - "enum": ["B2C", "B2B"], + "enum": [ + "B2C", + "B2B" + ], "x-docgen-enum-table-extension": [ { "value": "B2C", @@ -1657,7 +1802,11 @@ "$ref": "#/definitions/Audience" } }, - "required": ["marketplaceId", "offerType", "price"], + "required": [ + "marketplaceId", + "offerType", + "price" + ], "type": "object" }, "ItemProcurement": { @@ -1668,7 +1817,9 @@ "$ref": "#/definitions/Money" } }, - "required": ["costPrice"], + "required": [ + "costPrice" + ], "type": "object" }, "ItemRelationships": { @@ -1693,7 +1844,10 @@ "type": "array" } }, - "required": ["marketplaceId", "relationships"], + "required": [ + "marketplaceId", + "relationships" + ], "type": "object" }, "ItemRelationship": { @@ -1720,7 +1874,10 @@ "type": { "description": "The type of relationship.", "example": "VARIATION", - "enum": ["VARIATION", "PACKAGE_HIERARCHY"], + "enum": [ + "VARIATION", + "PACKAGE_HIERARCHY" + ], "x-docgen-enum-table-extension": [ { "value": "VARIATION", @@ -1734,7 +1891,9 @@ "type": "string" } }, - "required": ["type"], + "required": [ + "type" + ], "type": "object" }, "ItemVariationTheme": { @@ -1753,7 +1912,10 @@ "type": "string" } }, - "required": ["attributes", "theme"], + "required": [ + "attributes", + "theme" + ], "type": "object" }, "ItemProductTypes": { @@ -1776,7 +1938,10 @@ "type": "string" } }, - "required": ["marketplaceId", "productType"], + "required": [ + "marketplaceId", + "productType" + ], "type": "object" }, "FulfillmentAvailability": { @@ -1792,7 +1957,9 @@ "minimum": 0 } }, - "required": ["fulfillmentChannelCode"], + "required": [ + "fulfillmentChannelCode" + ], "type": "object" }, "Money": { @@ -1807,7 +1974,10 @@ "$ref": "#/definitions/Decimal" } }, - "required": ["amount", "currencyCode"], + "required": [ + "amount", + "currencyCode" + ], "type": "object" }, "Decimal": { @@ -1822,7 +1992,9 @@ "type": "integer" } }, - "required": ["pointsNumber"] + "required": [ + "pointsNumber" + ] }, "Audience": { "description": "Buyer segment or program this offer is applicable to.", @@ -1844,7 +2016,12 @@ "properties": { "op": { "description": "Type of JSON Patch operation. Supported JSON Patch operations include `add`, `replace`, `merge` and `delete`. Refer to .", - "enum": ["add", "replace", "merge", "delete"], + "enum": [ + "add", + "replace", + "merge", + "delete" + ], "x-docgen-enum-table-extension": [ { "value": "add", @@ -1878,7 +2055,10 @@ } } }, - "required": ["op", "path"], + "required": [ + "op", + "path" + ], "type": "object" }, "ListingsItemPatchRequest": { @@ -1897,7 +2077,10 @@ "minItems": 1 } }, - "required": ["productType", "patches"], + "required": [ + "productType", + "patches" + ], "type": "object" }, "ListingsItemPutRequest": { @@ -1909,7 +2092,11 @@ }, "requirements": { "description": "The name of the requirements set for the provided data.", - "enum": ["LISTING", "LISTING_PRODUCT_ONLY", "LISTING_OFFER_ONLY"], + "enum": [ + "LISTING", + "LISTING_PRODUCT_ONLY", + "LISTING_OFFER_ONLY" + ], "x-docgen-enum-table-extension": [ { "value": "LISTING", @@ -1932,7 +2119,10 @@ "additionalProperties": true } }, - "required": ["productType", "attributes"], + "required": [ + "productType", + "attributes" + ], "type": "object" }, "ListingsItemSubmissionResponse": { @@ -1944,7 +2134,11 @@ }, "status": { "description": "The status of the listings item submission.", - "enum": ["ACCEPTED", "INVALID", "VALID"], + "enum": [ + "ACCEPTED", + "INVALID", + "VALID" + ], "x-docgen-enum-table-extension": [ { "value": "ACCEPTED", @@ -1977,7 +2171,11 @@ "$ref": "#/definitions/ItemIdentifiers" } }, - "required": ["sku", "status", "submissionId"], + "required": [ + "sku", + "status", + "submissionId" + ], "type": "object" }, "ItemIdentifiers": { diff --git a/local-ai-sandbox/res/models/listingsRestrictions_2021-08-01.json b/local-ai-sandbox/res/models/listingsRestrictions_2021-08-01.json new file mode 100644 index 000000000..e04b82fa7 --- /dev/null +++ b/local-ai-sandbox/res/models/listingsRestrictions_2021-08-01.json @@ -0,0 +1,506 @@ +{ + "swagger": "2.0", + "info": { + "title": "Selling Partner API for Listings Restrictions", + "description": "The Selling Partner API for Listings Restrictions provides programmatic access to restrictions on Amazon catalog listings.\n\nFor more information, see the [Listings Restrictions API Use Case Guide](doc:listings-restrictions-api-v2021-08-01-use-case-guide).", + "version": "2021-08-01", + "contact": { + "name": "Selling Partner API Developer Support", + "url": "https://sellercentral.amazon.com/gp/mws/contactus.html" + }, + "license": { + "name": "Apache License 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0" + } + }, + "host": "sellingpartnerapi-na.amazon.com", + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "definitions": { + "RestrictionList": { + "description": "A list of restrictions for the specified Amazon catalog item.", + "type": "object", + "properties": { + "restrictions": { + "description": "A list of restrictions.", + "items": { + "$ref": "#/definitions/Restriction" + }, + "type": "array" + } + }, + "required": [ + "restrictions" + ] + }, + "Restriction": { + "description": "A listing restriction, optionally qualified by a condition, with a list of reasons for the restriction.", + "type": "object", + "properties": { + "marketplaceId": { + "description": "A marketplace identifier. Identifies the Amazon marketplace where the restriction is enforced.", + "type": "string" + }, + "conditionType": { + "description": "The condition that applies to the restriction.", + "type": "string", + "enum": [ + "new_new", + "new_open_box", + "new_oem", + "refurbished_refurbished", + "used_like_new", + "used_very_good", + "used_good", + "used_acceptable", + "collectible_like_new", + "collectible_very_good", + "collectible_good", + "collectible_acceptable", + "club_club" + ], + "x-docgen-enum-table-extension": [ + { + "value": "new_new", + "description": "New" + }, + { + "value": "new_open_box", + "description": "New - Open Box." + }, + { + "value": "new_oem", + "description": "New - OEM." + }, + { + "value": "refurbished_refurbished", + "description": "Refurbished" + }, + { + "value": "used_like_new", + "description": "Used - Like New." + }, + { + "value": "used_very_good", + "description": "Used - Very Good." + }, + { + "value": "used_good", + "description": "Used - Good." + }, + { + "value": "used_acceptable", + "description": "Used - Acceptable." + }, + { + "value": "collectible_like_new", + "description": "Collectible - Like New." + }, + { + "value": "collectible_very_good", + "description": "Collectible - Very Good." + }, + { + "value": "collectible_good", + "description": "Collectible - Good." + }, + { + "value": "collectible_acceptable", + "description": "Collectible - Acceptable." + }, + { + "value": "club_club", + "description": "Club" + } + ] + }, + "reasons": { + "description": "A list of reasons for the restriction.", + "type": "array", + "items": { + "$ref": "#/definitions/Reason" + } + } + }, + "required": [ + "marketplaceId" + ] + }, + "Reason": { + "description": "A reason for the restriction, including path forward links that may allow Selling Partners to remove the restriction, if available.", + "type": "object", + "properties": { + "message": { + "description": "A message describing the reason for the restriction.", + "type": "string" + }, + "reasonCode": { + "description": "A code indicating why the listing is restricted.", + "type": "string", + "enum": [ + "APPROVAL_REQUIRED", + "ASIN_NOT_FOUND", + "NOT_ELIGIBLE" + ], + "x-docgen-enum-table-extension": [ + { + "value": "APPROVAL_REQUIRED", + "description": "Approval is required to create a listing for the specified ASIN. A path forward link will be provided that may allow Selling Partners to remove the restriction." + }, + { + "value": "ASIN_NOT_FOUND", + "description": "The specified ASIN does not exist in the requested marketplace." + }, + { + "value": "NOT_ELIGIBLE", + "description": "Not eligible to create a listing for the specified ASIN. No path forward link will be provided to remove the restriction." + } + ] + }, + "links": { + "description": "A list of path forward links that may allow Selling Partners to remove the restriction.", + "type": "array", + "items": { + "$ref": "#/definitions/Link" + } + } + }, + "required": [ + "message" + ] + }, + "Link": { + "description": "A link to resources related to a listing restriction.", + "type": "object", + "properties": { + "resource": { + "description": "The URI of the related resource.", + "type": "string", + "format": "uri" + }, + "verb": { + "description": "The HTTP verb used to interact with the related resource.", + "type": "string", + "enum": [ + "GET" + ], + "x-docgen-enum-table-extension": [ + { + "value": "GET", + "description": "The provided resource is accessed with the HTTP GET method." + } + ] + }, + "title": { + "description": "The title of the related resource.", + "type": "string" + }, + "type": { + "description": "The media type of the related resource.", + "type": "string" + } + }, + "required": [ + "resource", + "verb" + ] + }, + "ErrorList": { + "description": "A list of error responses returned when a request is unsuccessful.", + "type": "array", + "items": { + "$ref": "#/definitions/Error" + } + }, + "Error": { + "description": "Error response returned when the request is unsuccessful.", + "properties": { + "code": { + "description": "An error code that identifies the type of error that occurred.", + "type": "string" + }, + "message": { + "description": "A message that describes the error condition.", + "type": "string" + }, + "details": { + "description": "Additional details that can help the caller understand or fix the issue.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "paths": { + "/listings/2021-08-01/restrictions": { + "get": { + "tags": [ + "listings" + ], + "description": "Returns listing restrictions for an item in the Amazon Catalog. \n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 5 | 10 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values then those shown here. For more information, see [Usage Plans and Rate Limits in the Selling Partner API](doc:usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "getListingsRestrictions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "asin", + "in": "query", + "description": "The Amazon Standard Identification Number (ASIN) of the item.", + "required": true, + "type": "string", + "x-example": "B0000ASIN1" + }, + { + "name": "conditionType", + "in": "query", + "description": "The condition used to filter restrictions.", + "required": false, + "type": "string", + "enum": [ + "new_new", + "new_open_box", + "new_oem", + "refurbished_refurbished", + "used_like_new", + "used_very_good", + "used_good", + "used_acceptable", + "collectible_like_new", + "collectible_very_good", + "collectible_good", + "collectible_acceptable", + "club_club" + ], + "x-example": "used_very_good", + "x-docgen-enum-table-extension": [ + { + "value": "new_new", + "description": "New" + }, + { + "value": "new_open_box", + "description": "New - Open Box." + }, + { + "value": "new_oem", + "description": "New - OEM." + }, + { + "value": "refurbished_refurbished", + "description": "Refurbished" + }, + { + "value": "used_like_new", + "description": "Used - Like New." + }, + { + "value": "used_very_good", + "description": "Used - Very Good." + }, + { + "value": "used_good", + "description": "Used - Good." + }, + { + "value": "used_acceptable", + "description": "Used - Acceptable." + }, + { + "value": "collectible_like_new", + "description": "Collectible - Like New." + }, + { + "value": "collectible_very_good", + "description": "Collectible - Very Good." + }, + { + "value": "collectible_good", + "description": "Collectible - Good." + }, + { + "value": "collectible_acceptable", + "description": "Collectible - Acceptable." + }, + { + "value": "club_club", + "description": "Club" + } + ] + }, + { + "name": "sellerId", + "in": "query", + "description": "A selling partner identifier, such as a merchant account.", + "required": true, + "type": "string" + }, + { + "name": "marketplaceIds", + "in": "query", + "description": "A comma-delimited list of Amazon marketplace identifiers for the request.", + "required": true, + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "x-example": "ATVPDKIKX0DER" + }, + { + "name": "reasonLocale", + "in": "query", + "description": "A locale for reason text localization. When not provided, the default language code of the first marketplace is used. Examples: \"en_US\", \"fr_CA\", \"fr_FR\". Localized messages default to \"en_US\" when a localization is not available in the specified locale.", + "required": false, + "type": "string", + "x-example": "en_US" + }, + { + "name": "productType", + "in": "query", + "description": "The product type of the item. When provided with the brand name, the API evaluates GTIN exemption restrictions in addition to brand restrictions for the specified product type.", + "required": false, + "type": "string", + "x-example": "SHIRT" + } + ], + "responses": { + "200": { + "description": "Successfully retrieved the listings restrictions.", + "schema": { + "$ref": "#/definitions/RestrictionList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + }, + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/ErrorList" + }, + "headers": { + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "headers": { + "x-amzn-RequestId": { + "description": "Unique request reference identifier.", + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/ErrorList" + } + } + } + } + } + } +} diff --git a/local-ai-sandbox/res/models/notifications.json b/local-ai-sandbox/res/models/notifications.json new file mode 100644 index 000000000..dad3d668e --- /dev/null +++ b/local-ai-sandbox/res/models/notifications.json @@ -0,0 +1,2150 @@ +{ + "swagger": "2.0", + "info": { + "description": "The Selling Partner API for Notifications lets you subscribe to notifications that are relevant to a selling partner's business. Using this API you can create a destination to receive notifications, subscribe to notifications, delete notification subscriptions, and more.\n\nFor more information, refer to the [Notifications Use Case Guide](https://developer-docs.amazon.com/sp-api/docs/notifications-api-v1-use-case-guide).", + "version": "v1", + "title": "Selling Partner API for Notifications", + "contact": { + "name": "Selling Partner API Developer Support", + "url": "https://sellercentral.amazon.com/gp/mws/contactus.html" + }, + "license": { + "name": "Apache License 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0" + } + }, + "host": "sellingpartnerapi-na.amazon.com", + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/notifications/v1/subscriptions": { + "get": { + "tags": [ + "notifications" + ], + "description": "Returns information about subscriptions of the specified notification type. You can use this API to retrieve all subscriptions when multiple subscriptions exist for a notification type (for example, when using filter expressions).\n\nThe operation returns all subscriptions for the caller's party.\n\n`payloadVersion` is an optional parameter. When you do not provide `payloadVersion`, the operation returns subscriptions across all payload versions.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 1 | 5 |\n\nThe `x-amzn-RateLimit-Limit` response header contains the usage plan rate limits for the operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput might have higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "getSubscriptions", + "parameters": [ + { + "name": "notificationTypes", + "in": "query", + "description": "A list of notification types to retrieve subscriptions for. Currently limited to a single notification type per request.\n\n For more information about notification types, refer to the [Notifications API v1 Use Case Guide](https://developer-docs.amazon.com/sp-api/docs/notifications-api-v1-use-case-guide).", + "required": true, + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 1, + "minItems": 1 + }, + { + "name": "payloadVersion", + "in": "query", + "description": "The version of the payload object to be used in the notification.", + "required": false, + "type": "string" + }, + { + "name": "pageSize", + "in": "query", + "description": "The maximum number of subscriptions to return per page. Minimum value is 30. Maximum value is 100. Default is 30.", + "required": false, + "type": "integer", + "minimum": 30, + "maximum": 100, + "default": 30 + }, + { + "name": "nextToken", + "in": "query", + "description": "A token to retrieve the next page of results. If this field is not empty in a response, pass its value in the next request to retrieve the next page.", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/GetSubscriptionsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + }, + "x-amzn-RateLimit-Limit": { + "description": "Your rate limit (requests per second) for this operation.", + "type": "string" + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/GetSubscriptionsResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/GetSubscriptionsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/GetSubscriptionsResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/GetSubscriptionsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/GetSubscriptionsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/GetSubscriptionsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/GetSubscriptionsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/GetSubscriptionsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + } + }, + "/notifications/v1/subscriptions/{notificationType}": { + "get": { + "x-amzn-api-internal": "INTERNAL_AND_EXTERNAL", + "tags": [ + "notifications" + ], + "description": "Returns information about subscription of the specified notification type and payload version. `payloadVersion` is an optional parameter. When you do not provide `payloadVersion`, the operation returns the latest payload version subscription's information. You can use this API to get subscription information when you do not have a subscription identifier.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 1 | 5 |\n\nThe `x-amzn-RateLimit-Limit` response header contains the usage plan rate limits for the operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput might have higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "getSubscription", + "parameters": [ + { + "name": "notificationType", + "in": "path", + "description": "The type of notification.\n\n For more information about notification types, refer to the [Notifications API v1 Use Case Guide](https://developer-docs.amazon.com/sp-api/docs/notifications-api-v1-use-case-guide).", + "required": true, + "type": "string" + }, + { + "name": "payloadVersion", + "in": "query", + "description": "The version of the payload object to be used in the notification.", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/GetSubscriptionResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/GetSubscriptionResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/GetSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/GetSubscriptionResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/GetSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/GetSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/GetSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/GetSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/GetSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + }, + "post": { + "x-amzn-api-internal": "INTERNAL_AND_EXTERNAL", + "tags": [ + "notifications" + ], + "description": "Creates a subscription for the specified notification type to be delivered to the specified destination. Before you can subscribe, you must first create the destination by calling the `createDestination` operation. If the notification type that you specify supports multiple payload versions, you can use this operation to subscribe to a different payload version if you already have an existing subscription for a different payload version.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 1 | 5 |\n\nThe `x-amzn-RateLimit-Limit` response header contains the usage plan rate limits for the operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput might have higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "createSubscription", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateSubscriptionRequest" + }, + "description": "The request schema for the `createSubscription` operation." + }, + { + "name": "notificationType", + "in": "path", + "description": "The type of notification.\n\n For more information about notification types, refer to the [Notifications API v1 Use Case Guide](https://developer-docs.amazon.com/sp-api/docs/notifications-api-v1-use-case-guide).", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/CreateSubscriptionResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/CreateSubscriptionResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/CreateSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/CreateSubscriptionResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "409": { + "description": "The resource specified conflicts with the current state.", + "schema": { + "$ref": "#/definitions/CreateSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/CreateSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/CreateSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/CreateSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/CreateSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/CreateSubscriptionResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + } + }, + "/notifications/v1/subscriptions/{notificationType}/{subscriptionId}": { + "get": { + "tags": [ + "notifications" + ], + "description": "Returns information about a subscription for the specified notification type. The `getSubscriptionById` operation is grantless. For more information, refer to [Grantless Operations](https://developer-docs.amazon.com/sp-api/docs/grantless-operations) in the Selling Partner API Developer Guide.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 1 | 5 |\n\nThe `x-amzn-RateLimit-Limit` response header contains the usage plan rate limits for the operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput might have higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "getSubscriptionById", + "parameters": [ + { + "name": "subscriptionId", + "in": "path", + "description": "The identifier for the subscription that you want to get.", + "required": true, + "type": "string" + }, + { + "name": "notificationType", + "in": "path", + "description": "The type of notification.\n\n For more information about notification types, refer to the [Notifications API v1 Use Case Guide](https://developer-docs.amazon.com/sp-api/docs/notifications-api-v1-use-case-guide).", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/GetSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/GetSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/GetSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/GetSubscriptionResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "409": { + "description": "The resource specified conflicts with the current state.", + "schema": { + "$ref": "#/definitions/GetSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/GetSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/GetSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/GetSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/GetSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/GetSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + }, + "delete": { + "tags": [ + "notifications" + ], + "description": "Deletes the subscription indicated by the subscription identifier and notification type that you specify. The subscription identifier can be for any subscription associated with your application. After you successfully call this operation, notifications will stop being sent for the associated subscription. The `deleteSubscriptionById` operation is grantless. For more information, refer to [Grantless Operations](https://developer-docs.amazon.com/sp-api/docs/grantless-operations) in the Selling Partner API Developer Guide.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 1 | 5 |\n\nThe `x-amzn-RateLimit-Limit` response header contains the usage plan rate limits for the operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput might have higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "deleteSubscriptionById", + "parameters": [ + { + "name": "subscriptionId", + "in": "path", + "description": "The identifier for the subscription that you want to delete.", + "required": true, + "type": "string" + }, + { + "name": "notificationType", + "in": "path", + "description": "The type of notification.\n\n For more information about notification types, refer to the [Notifications API v1 Use Case Guide](https://developer-docs.amazon.com/sp-api/docs/notifications-api-v1-use-case-guide).", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/DeleteSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/DeleteSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/DeleteSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/DeleteSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "409": { + "description": "The resource specified conflicts with the current state.", + "schema": { + "$ref": "#/definitions/DeleteSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/DeleteSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/DeleteSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/DeleteSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/DeleteSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/DeleteSubscriptionByIdResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + } + }, + "/notifications/v1/subscriptions/{notificationType}/testNotification": { + "x-amzn-api-sandbox-only": true, + "post": { + "tags": [ + "notifications" + ], + "description": "Sends a mock notification of the specified type to your SQS. The `sendTestNotification` API is grantless. For more information, see \"Grantless operations\" in the Selling Partner API Developer Guide.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 1 | 5 |\n\nFor more information, see \"Usage Plans and Rate Limits\" in the Selling Partner API documentation. This is a sandbox-only operation and must be directed to a sandbox endpoint. Refer to [Selling Partner API sandbox](https://developer-docs.amazon.com/sp-api/docs/the-selling-partner-api-sandbox) for more information.", + "operationId": "sendTestNotification", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SendTestNotificationRequest" + }, + "description": "The request schema for the `sendTestNotification` operation." + }, + { + "name": "notificationType", + "in": "path", + "description": "The type of notification.\n\n For more information about notification types, refer to the [Notifications API v1 Use Case Guide](https://developer-docs.amazon.com/sp-api/docs/notifications-api-v1-use-case-guide).", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/SendTestNotificationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/SendTestNotificationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/SendTestNotificationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/SendTestNotificationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "409": { + "description": "The resource specified conflicts with the current state.", + "schema": { + "$ref": "#/definitions/SendTestNotificationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/SendTestNotificationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/SendTestNotificationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/SendTestNotificationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/SendTestNotificationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/SendTestNotificationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + } + }, + "/notifications/v1/destinations": { + "get": { + "x-amzn-api-internal": "INTERNAL_AND_EXTERNAL", + "tags": [ + "notifications" + ], + "description": "Returns information about all destinations. The `getDestinations` operation is grantless. For more information, refer to [Grantless Operations](https://developer-docs.amazon.com/sp-api/docs/grantless-operations) in the Selling Partner API Developer Guide.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 1 | 5 |\n\nThe `x-amzn-RateLimit-Limit` response header contains the usage plan rate limits for the operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput might have higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "getDestinations", + "parameters": [], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/GetDestinationsResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/GetDestinationsResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/GetDestinationsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/GetDestinationsResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "409": { + "description": "The resource specified conflicts with the current state.", + "schema": { + "$ref": "#/definitions/GetDestinationsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/GetDestinationsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/GetDestinationsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/GetDestinationsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/GetDestinationsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/GetDestinationsResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + }, + "post": { + "x-amzn-api-internal": "INTERNAL_AND_EXTERNAL", + "tags": [ + "notifications" + ], + "description": "Creates a destination resource to receive notifications. The `createDestination` operation is grantless. For more information, refer to [Grantless Operations](https://developer-docs.amazon.com/sp-api/docs/grantless-operations) in the Selling Partner API Developer Guide.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 1 | 5 |\n\nThe `x-amzn-RateLimit-Limit` response header contains the usage plan rate limits for the operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput might have higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "createDestination", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateDestinationRequest" + }, + "description": "The request schema for the `createDestination` operation." + } + ], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/CreateDestinationResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/CreateDestinationResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/CreateDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/CreateDestinationResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "409": { + "description": "The resource specified conflicts with the current state.", + "schema": { + "$ref": "#/definitions/CreateDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/CreateDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/CreateDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/CreateDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/CreateDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/CreateDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + } + }, + "/notifications/v1/destinations/{destinationId}": { + "get": { + "x-amzn-api-internal": "INTERNAL_AND_EXTERNAL", + "tags": [ + "notifications" + ], + "description": "Returns information about the destination that you specify. The `getDestination` operation is grantless. For more information, refer to [Grantless Operations](https://developer-docs.amazon.com/sp-api/docs/grantless-operations) in the Selling Partner API Developer Guide.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 1 | 5 |\n\nThe `x-amzn-RateLimit-Limit` response header contains the usage plan rate limits for the operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput might have higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "getDestination", + "parameters": [ + { + "name": "destinationId", + "in": "path", + "description": "The identifier generated when you created the destination.", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/GetDestinationResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/GetDestinationResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/GetDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/GetDestinationResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "409": { + "description": "The resource specified conflicts with the current state.", + "schema": { + "$ref": "#/definitions/GetDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/GetDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/GetDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/GetDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/GetDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/GetDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + }, + "delete": { + "x-amzn-api-internal": "INTERNAL_AND_EXTERNAL", + "tags": [ + "notifications" + ], + "description": "Deletes the destination that you specify. The `deleteDestination` operation is grantless. For more information, refer to [Grantless Operations](https://developer-docs.amazon.com/sp-api/docs/grantless-operations) in the Selling Partner API Developer Guide.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 1 | 5 |\n\nThe `x-amzn-RateLimit-Limit` response header contains the usage plan rate limits for the operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput might have higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", + "operationId": "deleteDestination", + "parameters": [ + { + "name": "destinationId", + "in": "path", + "description": "The identifier for the destination that you want to delete.", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Success.", + "schema": { + "$ref": "#/definitions/DeleteDestinationResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "400": { + "description": "Request has missing or invalid parameters and cannot be parsed.", + "schema": { + "$ref": "#/definitions/DeleteDestinationResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "403": { + "description": "Indicates that access to the resource is forbidden. Possible reasons include Access Denied, Unauthorized, Expired Token, or Invalid Signature.", + "schema": { + "$ref": "#/definitions/DeleteDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "404": { + "description": "The resource specified does not exist.", + "schema": { + "$ref": "#/definitions/DeleteDestinationResponse" + }, + "headers": { + "x-amzn-RateLimit-Limit": { + "type": "string", + "description": "Your rate limit (requests per second) for this operation." + }, + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "409": { + "description": "The resource specified conflicts with the current state.", + "schema": { + "$ref": "#/definitions/DeleteDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "413": { + "description": "The request size exceeded the maximum accepted size.", + "schema": { + "$ref": "#/definitions/DeleteDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "415": { + "description": "The request payload is in an unsupported format.", + "schema": { + "$ref": "#/definitions/DeleteDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "429": { + "description": "The frequency of requests was greater than allowed.", + "schema": { + "$ref": "#/definitions/DeleteDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "500": { + "description": "An unexpected condition occurred that prevented the server from fulfilling the request.", + "schema": { + "$ref": "#/definitions/DeleteDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + }, + "503": { + "description": "Temporary overloading or maintenance of the server.", + "schema": { + "$ref": "#/definitions/DeleteDestinationResponse" + }, + "headers": { + "x-amzn-RequestId": { + "type": "string", + "description": "Unique request reference identifier." + } + } + } + } + } + } + }, + "definitions": { + "ProcessingDirective": { + "description": "Additional information passed to the subscription to control the processing of notifications. For example, you can use an `eventFilter` to customize your subscription to send notifications for only the `marketplaceId`s that you specify, or select the aggregation time period at which to send notifications (for example, you can set a limit of one notification every five minutes for high frequency notifications). You can also use `filterExpression` to filter events based on notification payload. The specific features available can vary by the `notificationType`.", + "type": "object", + "properties": { + "eventFilter": { + "description": "A `notificationType` filter. Note: eventFilter and filterExpression are mutually exclusive, meaning if eventFilter is provided, filterExpression field cannot be used.", + "$ref": "#/definitions/EventFilter" + }, + "filterExpression": { + "description": "An expression for filtering events before delivery to destination based on the notification payload (example: FulfillmentOrderStatusNotification.FulfillmentOrderStatus == `SHIPPED` ). The `filterExpression` is a string that follows the CEL expression syntax (https://github.com/google/cel-spec) excluding arithmetic operators (+, -, *, /, %) and list/map indexing ([]). Refer to Notification Type Values to determine if filter Expression is supported for a Notification Type. Refer to CEL Operators (https://developer-docs.amazon.com/sp-api/docs/filter-notification-subscriptions) to see if a CEL operator is supported. \n Note: eventFilter and filterExpression are mutually exclusive. You can use filterExpression to replace existing eventFilter configurations.", + "type": "string", + "maxLength": 256, + "minLength": 1 + } + } + }, + "EventFilter": { + "description": "A `notificationType` filter. This object contains all of the available filters and properties that you can use to define a `notificationType` specific filter.", + "allOf": [ + { + "$ref": "#/definitions/AggregationFilter" + }, + { + "$ref": "#/definitions/MarketplaceFilter" + }, + { + "$ref": "#/definitions/OrderChangeTypeFilter" + }, + { + "$ref": "#/definitions/TrackingFilter" + }, + { + "type": "object", + "properties": { + "eventFilterType": { + "type": "string", + "enum": [ + "ANY_OFFER_CHANGED", + "ORDER_CHANGE", + "SHIPMENT_TRACKING_MILESTONE_CHANGED" + ], + "description": "An `eventFilterType` value that the `notificationType` supports. The subscription service uses the `eventFilterType` to determine the type of event filter. To determine if a specific `notificationType` supports an `eventFilterType`, refer to [Notification Type Values]( https://developer-docs.amazon.com/sp-api/docs/notification-type-values)." + } + }, + "required": [ + "eventFilterType" + ] + } + ] + }, + "TrackingFilter": { + "description": "An event filter you can use to customize your subscription to receive shipment tracking milestone notifications for a specific tracking identifier.", + "type": "object", + "properties": { + "trackingIdentifier": { + "$ref": "#/definitions/TrackingIdentifier" + } + } + }, + "TrackingIdentifier": { + "description": "Specifies the tracking identifier used to filter your subscription notifications. Provide exactly one identifier field. Providing multiple identifier fields in a single request is not supported.", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Amazon unique tracking identifier." + }, + "acsin": { + "type": "string", + "description": "Air Cargo Shipment Identification Number." + }, + "aftn": { + "type": "string", + "description": "Amazon Fulfillment Tracking Number." + }, + "containerNumber": { + "type": "string", + "description": "Container number provided by the Logistics Service Provider." + }, + "houseBillOfLadingNumber": { + "type": "string", + "description": "House Bill of Lading number." + }, + "carrierTracking": { + "type": "object", + "description": "Carrier-provided tracking identifier.", + "properties": { + "trackingNumber": { + "type": "string", + "description": "Carrier tracking number" + }, + "carrierCode": { + "type": "string", + "description": "Carrier code" + } + }, + "required": [ + "trackingNumber" + ] + } + } + }, + "MarketplaceFilter": { + "description": "An event filter you can use to customize your subscription to send notifications for specific `marketplaceId`s.", + "type": "object", + "properties": { + "marketplaceIds": { + "$ref": "#/definitions/MarketplaceIds" + } + } + }, + "MarketplaceIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of marketplace identifiers you can subscribe to (for example, `ATVPDKIKX0DER`). To receive notifications in every marketplace, do not provide this list." + }, + "AggregationFilter": { + "type": "object", + "properties": { + "aggregationSettings": { + "$ref": "#/definitions/AggregationSettings" + } + }, + "description": "A filter you can use to select the aggregation time period at which to send notifications (for example, limit to one notification every five minutes for high frequency notifications)." + }, + "AggregationSettings": { + "type": "object", + "description": "A container that holds all of the necessary properties to configure the aggregation of notifications.", + "properties": { + "aggregationTimePeriod": { + "$ref": "#/definitions/AggregationTimePeriod", + "description": "The supported time period to use to perform marketplace-ASIN level aggregation." + } + }, + "required": [ + "aggregationTimePeriod" + ] + }, + "AggregationTimePeriod": { + "description": "The supported aggregation time periods. For example, if FiveMinutes is the value chosen, and 50 price updates occur for an ASIN within 5 minutes, Amazon will send only two notifications; one for the first event, and then a subsequent notification 5 minutes later with the final end state of the data. The 48 interim events will be dropped.", + "type": "string", + "enum": [ + "FiveMinutes", + "TenMinutes" + ], + "x-docgen-enum-table-extension": [ + { + "value": "FiveMinutes", + "description": "An aggregated notification will be sent every five minutes." + }, + { + "value": "TenMinutes", + "description": "An aggregated notification will be sent every ten minutes." + } + ] + }, + "OrderChangeTypeFilter": { + "description": "An event filter you can use to customize your subscription to send notifications for a specific `orderChangeType`.", + "type": "object", + "properties": { + "orderChangeTypes": { + "$ref": "#/definitions/OrderChangeTypes" + } + } + }, + "OrderChangeTypes": { + "type": "array", + "items": { + "$ref": "#/definitions/OrderChangeTypeEnum" + }, + "description": "A list of order change types you can subscribe to (for example, `BuyerRequestedChange`). To receive notifications of all change types, do not provide this list." + }, + "OrderChangeTypeEnum": { + "type": "string", + "enum": [ + "BuyerRequestedChange", + "DeliveryTipChange", + "OrderStatusChange" + ], + "description": "The supported order change type of ORDER_CHANGE notification." + }, + "Subscription": { + "type": "object", + "required": [ + "destinationId", + "payloadVersion", + "subscriptionId" + ], + "properties": { + "subscriptionId": { + "type": "string", + "description": "The subscription identifier generated when the subscription is created." + }, + "payloadVersion": { + "type": "string", + "description": "The version of the payload object to be used in the notification." + }, + "destinationId": { + "type": "string", + "description": "The identifier for the destination where notifications will be delivered." + }, + "processingDirective": { + "$ref": "#/definitions/ProcessingDirective" + } + }, + "description": "Information about the subscription." + }, + "CreateSubscriptionResponse": { + "type": "object", + "properties": { + "payload": { + "description": "The payload for the `createSubscription` operation.", + "$ref": "#/definitions/Subscription" + }, + "errors": { + "description": "One or more unexpected errors occurred during the `createSubscription` operation.", + "$ref": "#/definitions/ErrorList" + } + }, + "description": "The response schema for the `createSubscription` operation." + }, + "CreateSubscriptionRequest": { + "type": "object", + "properties": { + "payloadVersion": { + "type": "string", + "description": "The version of the payload object to be used in the notification." + }, + "destinationId": { + "type": "string", + "description": "The identifier for the destination where notifications will be delivered." + }, + "processingDirective": { + "$ref": "#/definitions/ProcessingDirective" + } + }, + "required": [ + "destinationId", + "payloadVersion" + ], + "description": "The request schema for the `createSubscription` operation." + }, + "GetSubscriptionByIdResponse": { + "type": "object", + "properties": { + "payload": { + "description": "The payload for the `getSubscriptionById` operation.", + "$ref": "#/definitions/Subscription" + }, + "errors": { + "description": "An unexpected condition occurred during the `getSubscriptionById` operation.", + "$ref": "#/definitions/ErrorList" + } + }, + "description": "The response schema for the `getSubscriptionById` operation." + }, + "GetSubscriptionResponse": { + "type": "object", + "properties": { + "payload": { + "description": "The payload for the `getSubscription` operation.", + "$ref": "#/definitions/Subscription" + }, + "errors": { + "description": "One or more unexpected errors occurred during the `getSubscription` operation.", + "$ref": "#/definitions/ErrorList" + } + }, + "description": "The response schema for the `getSubscription` operation." + }, + "GetSubscriptionsResponse": { + "type": "object", + "properties": { + "payload": { + "description": "The payload for the `getSubscriptions` operation.", + "$ref": "#/definitions/GetSubscriptionsPayload" + }, + "errors": { + "description": "One or more unexpected errors occurred during the `getSubscriptions` operation.", + "$ref": "#/definitions/ErrorList" + } + }, + "description": "The response schema for the `getSubscriptions` operation." + }, + "GetSubscriptionsPayload": { + "type": "object", + "properties": { + "subscriptions": { + "description": "A list of subscriptions.", + "$ref": "#/definitions/Subscriptions" + }, + "nextToken": { + "type": "string", + "description": "A token that you can use to retrieve the next page of results. When this field is not empty, pass its value in the `nextToken` query parameter of the next request." + } + }, + "description": "The payload for the `getSubscriptions` operation." + }, + "Subscriptions": { + "type": "array", + "description": "A list of subscriptions.", + "items": { + "$ref": "#/definitions/Subscription" + } + }, + "DeleteSubscriptionByIdResponse": { + "type": "object", + "properties": { + "errors": { + "description": "An unexpected condition occurred during the `deleteSubscriptionById` operation.", + "$ref": "#/definitions/ErrorList" + } + }, + "description": "The response schema for the `deleteSubscriptionById` operation." + }, + "SendTestNotificationRequest": { + "type": "object", + "properties": { + "destinationId": { + "type": "string", + "description": "The identifier for the destination where notifications will be delivered." + }, + "testNotification": { + "$ref": "#/definitions/TestNotification" + } + }, + "description": "The request schema for the `sendTestNotification` operation." + }, + "SendTestNotificationResponse": { + "type": "object", + "properties": { + "payload": { + "description": "The payload for the `sendTestNotification` operation." + }, + "errors": { + "description": "One or more unexpected errors occurred during the `sendTestNotification` operation.", + "$ref": "#/definitions/ErrorList" + } + }, + "description": "The response schema for the `sendTestNotification` operation." + }, + "DestinationList": { + "type": "array", + "description": "A list of destinations.", + "items": { + "$ref": "#/definitions/Destination" + } + }, + "Destination": { + "type": "object", + "required": [ + "destinationId", + "name", + "resource" + ], + "properties": { + "name": { + "type": "string", + "description": "The developer-defined name for this destination.", + "maxLength": 256 + }, + "destinationId": { + "type": "string", + "description": "The destination identifier generated when you created the destination." + }, + "resource": { + "description": "The resource that will receive notifications associated with this destination.", + "$ref": "#/definitions/DestinationResource" + } + }, + "description": "Information about the destination you create when you call the `createDestination` operation." + }, + "DestinationResource": { + "type": "object", + "properties": { + "sqs": { + "description": "An Amazon Simple Queue Service (SQS) queue destination.", + "$ref": "#/definitions/SqsResource" + }, + "eventBridge": { + "description": "An Amazon EventBridge destination.", + "$ref": "#/definitions/EventBridgeResource" + } + }, + "description": "The destination resource types." + }, + "DestinationResourceSpecification": { + "type": "object", + "properties": { + "sqs": { + "description": "The information required to create an Amazon Simple Queue Service (SQS) queue destination.", + "$ref": "#/definitions/SqsResource" + }, + "eventBridge": { + "description": "The information required to create an Amazon EventBridge destination.", + "$ref": "#/definitions/EventBridgeResourceSpecification" + } + }, + "description": "The information required to create a destination resource. Applications should use one resource type (sqs or eventBridge) per destination." + }, + "SqsResource": { + "type": "object", + "required": [ + "arn" + ], + "properties": { + "arn": { + "type": "string", + "description": "The Amazon Resource Name (ARN) associated with the SQS queue.", + "maxLength": 1000, + "pattern": "^arn:aws:sqs:\\S+:\\S+:\\S+" + } + }, + "description": "The information required to create an Amazon Simple Queue Service (Amazon SQS) queue destination." + }, + "EventBridgeResourceSpecification": { + "type": "object", + "required": [ + "accountId", + "region" + ], + "properties": { + "region": { + "type": "string", + "description": "The AWS region in which you will be receiving the notifications." + }, + "accountId": { + "type": "string", + "description": "The identifier for the AWS account that is responsible for charges related to receiving notifications." + } + }, + "description": "The information required to create an Amazon EventBridge destination." + }, + "EventBridgeResource": { + "type": "object", + "required": [ + "accountId", + "name", + "region" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the partner event source associated with the destination.", + "maxLength": 256 + }, + "region": { + "type": "string", + "description": "The AWS region in which you receive the notifications. For AWS regions that Amazon EventBridge supports, refer to [Amazon EventBridge endpoints and quotas](https://docs.aws.amazon.com/general/latest/gr/ev.html)." + }, + "accountId": { + "type": "string", + "description": "The identifier for the AWS account that is responsible for charges related to receiving notifications." + } + }, + "description": "The Amazon EventBridge destination." + }, + "CreateDestinationRequest": { + "type": "object", + "required": [ + "name", + "resourceSpecification" + ], + "properties": { + "resourceSpecification": { + "description": "The information required to create a destination resource. Applications should use one resource type (sqs or eventBridge) per destination.", + "$ref": "#/definitions/DestinationResourceSpecification" + }, + "name": { + "type": "string", + "description": "A developer-defined name to help identify this destination." + } + }, + "description": "The request schema for the `createDestination` operation." + }, + "CreateDestinationResponse": { + "type": "object", + "properties": { + "payload": { + "description": "The payload for the `createDestination` operation.", + "$ref": "#/definitions/Destination" + }, + "errors": { + "description": "One or more unexpected errors occurred during the `createDestination` operation.", + "$ref": "#/definitions/ErrorList" + } + }, + "description": "The response schema for the createDestination operation." + }, + "GetDestinationResponse": { + "type": "object", + "properties": { + "payload": { + "description": "The payload for the `getDestination` operation.", + "$ref": "#/definitions/Destination" + }, + "errors": { + "description": "One or more unexpected errors occurred during the `getDestination` operation.", + "$ref": "#/definitions/ErrorList" + } + }, + "description": "The response schema for the `getDestination` operation." + }, + "GetDestinationsResponse": { + "type": "object", + "properties": { + "payload": { + "description": "The payload for the `getDestinations` operation.", + "$ref": "#/definitions/DestinationList" + }, + "errors": { + "description": "One or more unexpected errors occurred during the `getDestinations` operation.", + "$ref": "#/definitions/ErrorList" + } + }, + "description": "The response schema for the `getDestinations` operation." + }, + "DeleteDestinationResponse": { + "type": "object", + "properties": { + "errors": { + "description": "One or more unexpected errors occurred during the `deleteDestination` operation.", + "$ref": "#/definitions/ErrorList" + } + }, + "description": "The response schema for the `deleteDestination` operation." + }, + "ErrorList": { + "type": "array", + "description": "A list of error responses returned when a request is unsuccessful.", + "items": { + "$ref": "#/definitions/Error" + } + }, + "Error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "An error code that identifies the type of error that occurred." + }, + "message": { + "type": "string", + "description": "A message that describes the error condition." + }, + "details": { + "type": "string", + "description": "Additional details that can help the caller understand or fix the issue." + } + }, + "description": "An error response returned when the request is unsuccessful." + }, + "TestNotification": { + "type": "object", + "properties": { + "payloadVersion": { + "type": "string", + "description": "The version of the payload object to be used in the notification." + }, + "testScenario": { + "type": "string", + "description": "The scenario of the specified notification to be used in the notification payload. If testScenario is empty, a 400 response will be returned back to the developer. The scenarios supported for each notification type can be found in the Selling Partner API Developer Guide." + } + }, + "required": [ + "payloadVersion" + ], + "description": "The describer for the test notification that will be delivered." + } + } +} diff --git a/local-ai-sandbox/res/models/orders_2026-01-01.json b/local-ai-sandbox/res/models/orders_2026-01-01.json index 5e6ee45d3..8441833f3 100644 --- a/local-ai-sandbox/res/models/orders_2026-01-01.json +++ b/local-ai-sandbox/res/models/orders_2026-01-01.json @@ -14,14 +14,22 @@ } }, "host": "sellingpartnerapi-na.amazon.com", - "schemes": ["https"], - "consumes": ["application/json"], - "produces": ["application/json"], + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "paths": { "/orders/2026-01-01/orders": { "get": { - "tags": ["searchOrders"], - "description": "Returns orders that are created or updated during the time period that you specify. You can filter the response for specific types of orders.", + "tags": [ + "searchOrders" + ], + "description": "Returns orders created or updated during the time period that you specify. You can filter the response for specific types of orders.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0056 | 20 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that are applied to the requested operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may receive higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api) in the Selling Partner API documentation.", "operationId": "searchOrders", "parameters": [ { @@ -45,7 +53,7 @@ { "name": "lastUpdatedAfter", "in": "query", - "description": "The response includes orders updated at or after this time. An update is defined as any change made by Amazon or by the seller, including an update to the order status. The date must be in [ISO 8601](https://developer-docs.amazon.com/sp-api/docs/iso-8601) format.\n\n**Note**: You must provide exactly one of `createdAfter` and `lastUpdatedAfter`. If `lastUpdatedAfter` is provided, neither `createdAfter` nor `createdBefore` may be provided.", + "description": "The response includes orders updated at or after this time. An update is any change made by Amazon or the seller, including changes to order status. The date must be in [ISO 8601](https://developer-docs.amazon.com/sp-api/docs/iso-8601) format.\n\n**Note**: You must provide exactly one of `createdAfter` and `lastUpdatedAfter`. If `lastUpdatedAfter` is provided, neither `createdAfter` nor `createdBefore` may be provided.", "required": false, "type": "string", "format": "date-time", @@ -54,7 +62,7 @@ { "name": "lastUpdatedBefore", "in": "query", - "description": "The response includes orders updated at or before this time. An update is defined as any change made by Amazon or by the seller, including an update to the order status. The date must be in [ISO 8601](https://developer-docs.amazon.com/sp-api/docs/iso-8601) format.\n\n**Note**: If you include `lastUpdatedAfter` in the request, `lastUpdatedBefore` is optional, and if provided must be equal to or after the `lastUpdatedAfter` date and at least two minutes before the time of the request. If `lastUpdatedBefore` is provided, neither `createdAfter` nor `createdBefore` may be provided.", + "description": "The response includes orders updated at or before this time. An update is any change made by Amazon or the seller, including changes to order status. The date must be in [ISO 8601](https://developer-docs.amazon.com/sp-api/docs/iso-8601) format.\n\n**Note**: If you include `lastUpdatedAfter` in the request, `lastUpdatedBefore` is optional, and if provided must be equal to or after the `lastUpdatedAfter` date and at least two minutes before the time of the request. If `lastUpdatedBefore` is provided, neither `createdAfter` nor `createdBefore` may be provided.", "required": false, "type": "string", "format": "date-time", @@ -68,7 +76,15 @@ "type": "array", "items": { "type": "string", - "enum": ["PENDING_AVAILABILITY", "PENDING", "UNSHIPPED", "PARTIALLY_SHIPPED", "SHIPPED", "CANCELLED", "UNFULFILLABLE"], + "enum": [ + "PENDING_AVAILABILITY", + "PENDING", + "UNSHIPPED", + "PARTIALLY_SHIPPED", + "SHIPPED", + "CANCELLED", + "UNFULFILLABLE" + ], "x-docgen-enum-table-extension": [ { "value": "PENDING_AVAILABILITY", @@ -96,11 +112,14 @@ }, { "value": "UNFULFILLABLE", - "description": "The order cannot be fulfilled. This state only applies to Amazon-fulfilled orders that were not placed on Amazon's retail web site." + "description": "The order cannot be fulfilled. This state only applies to Amazon-fulfilled orders that were not placed on Amazon's retail website." } ] }, - "x-example": ["PENDING", "UNSHIPPED"] + "x-example": [ + "PENDING", + "UNSHIPPED" + ] }, { "name": "marketplaceIds", @@ -112,7 +131,10 @@ "type": "string" }, "maxItems": 50, - "x-example": ["ATVPDKIKX0DER", "A2EUQ1WTGCTBG2"] + "x-example": [ + "ATVPDKIKX0DER", + "A2EUQ1WTGCTBG2" + ] }, { "name": "fulfilledBy", @@ -122,7 +144,10 @@ "type": "array", "items": { "type": "string", - "enum": ["MERCHANT", "AMAZON"], + "enum": [ + "MERCHANT", + "AMAZON" + ], "x-docgen-enum-table-extension": [ { "value": "MERCHANT", @@ -134,7 +159,10 @@ } ] }, - "x-example": ["AMAZON", "MERCHANT"] + "x-example": [ + "AMAZON", + "MERCHANT" + ] }, { "name": "maxResultsPerPage", @@ -160,7 +188,19 @@ "type": "array", "items": { "type": "string", - "enum": ["BUYER", "RECIPIENT", "PROCEEDS", "EXPENSE", "PROMOTION", "CANCELLATION", "FULFILLMENT", "PACKAGES"], + "enum": [ + "BUYER", + "RECIPIENT", + "PROCEEDS", + "EXPENSE", + "PROMOTION", + "CANCELLATION", + "FULFILLMENT", + "PACKAGES", + "TAX", + "PAYMENT", + "FULFILLMENT_ORDERS" + ], "x-docgen-enum-table-extension": [ { "value": "BUYER", @@ -193,10 +233,25 @@ { "value": "PACKAGES", "description": "Information about shipping packages and tracking." + }, + { + "value": "TAX", + "description": "The tax information associated with the order." + }, + { + "value": "PAYMENT", + "description": "The payment information associated with the order." + }, + { + "value": "FULFILLMENT_ORDERS", + "description": "The fulfillment orders associated with this order." } ] }, - "x-example": ["BUYER", "PACKAGES"] + "x-example": [ + "BUYER", + "PACKAGES" + ] } ], "responses": { @@ -325,8 +380,10 @@ }, "/orders/2026-01-01/orders/{orderId}": { "get": { - "tags": ["getOrder"], - "description": "Returns the order that you specify.", + "tags": [ + "getOrder" + ], + "description": "Returns the order that you specify.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.5 | 30 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that are applied to the requested operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may receive higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api) in the Selling Partner API documentation.", "operationId": "getOrder", "parameters": [ { @@ -345,7 +402,19 @@ "type": "array", "items": { "type": "string", - "enum": ["BUYER", "RECIPIENT", "PROCEEDS", "EXPENSE", "PROMOTION", "CANCELLATION", "FULFILLMENT", "PACKAGES"], + "enum": [ + "BUYER", + "RECIPIENT", + "PROCEEDS", + "EXPENSE", + "PROMOTION", + "CANCELLATION", + "FULFILLMENT", + "PACKAGES", + "TAX", + "PAYMENT", + "FULFILLMENT_ORDERS" + ], "x-docgen-enum-table-extension": [ { "value": "BUYER", @@ -361,7 +430,7 @@ }, { "value": "EXPENSE", - "description": "The cost information applied to the order and order items." + "description": "The cost information about the order and order items." }, { "value": "PROMOTION", @@ -373,15 +442,30 @@ }, { "value": "FULFILLMENT", - "description": "Information about how this order and order items are processed and shipped." + "description": "Information about how the order and order items are processed and shipped." }, { "value": "PACKAGES", - "description": "Shipping packages and tracking information." + "description": "Information about shipping packages and tracking." + }, + { + "value": "TAX", + "description": "The tax information associated with the order." + }, + { + "value": "PAYMENT", + "description": "The payment information associated with the order." + }, + { + "value": "FULFILLMENT_ORDERS", + "description": "The fulfillment orders associated with this order." } ] }, - "x-example": ["BUYER", "PACKAGES"] + "x-example": [ + "BUYER", + "PACKAGES" + ] } ], "responses": { @@ -513,7 +597,9 @@ "SearchOrdersResponse": { "description": "A list of orders.", "type": "object", - "required": ["orders"], + "required": [ + "orders" + ], "properties": { "orders": { "description": "An array containing all orders that match the search criteria.", @@ -524,23 +610,23 @@ }, "pagination": { "$ref": "#/definitions/Pagination", - "description": "When a request has results that are not included in the response, pagination occurs. This means the results are divided into individual pages. To retrieve a different page, you must pass the token value as the `paginationToken` query parameter in the subsequent request. All other parameters must be provided with the same values that were provided with the request that generated this token, with the exception of `maxResultsPerPage` and `includedData`, which can be modified between calls. The token will expire after 24 hours. When there are no other pages to fetch, the `pagination` field will be absent from the response." + "description": "Pagination occurs when a request has results that exceed the response limit. This means the results are divided into individual pages. To retrieve a different page, you must pass the token value as the `paginationToken` query parameter in the subsequent request. All other parameters must be provided with the same values that were provided with the request that generated this token, with the exception of `maxResultsPerPage` and `includedData`, which can be modified between calls. The token will expire after 24 hours. When there are no other pages to fetch, the `pagination` field will be absent from the response." }, "lastUpdatedBefore": { "type": "string", "format": "date-time", - "description": "Only orders updated before the specified time are returned. The date must be in ISO 8601 format." + "description": "Only orders updated before the specified time are returned. The date must be in [ISO 8601](https://developer-docs.amazon.com/sp-api/docs/iso-8601) format." }, "createdBefore": { "type": "string", "format": "date-time", - "description": "Only orders placed before the specified time are returned. The date must be in ISO 8601 format." + "description": "Only orders placed before the specified time are returned. The date must be in [ISO 8601](https://developer-docs.amazon.com/sp-api/docs/iso-8601) format." } } }, "Pagination": { "type": "object", - "description": "When a request has results that are not included in the response, pagination occurs. This means the results are divided into individual pages. To retrieve a different page, you must pass the token value as the `paginationToken` query parameter in the subsequent request. All other parameters must be provided with the same values that were provided with the request that generated this token, with the exception of `maxResultsPerPage` and `includedData`, which can be modified between calls. The token will expire after 24 hours. When there are no other pages to fetch, the `pagination` field will be absent from the response.", + "description": "Pagination occurs when a request has results that exceed the response limit. This means the results are divided into individual pages. To retrieve a different page, you must pass the token value as the `paginationToken` query parameter in the subsequent request. All other parameters must be provided with the same values that were provided with the request that generated this token, with the exception of `maxResultsPerPage` and `includedData`, which can be modified between calls. The token will expire after 24 hours. When there are no other pages to fetch, the `pagination` field will be absent from the response.", "properties": { "nextToken": { "description": "A token that can be used to fetch the next page of results.", @@ -551,7 +637,9 @@ "GetOrderResponse": { "description": "Order details.", "type": "object", - "required": ["order"], + "required": [ + "order" + ], "properties": { "order": { "description": "All available information about the requested order.", @@ -562,7 +650,13 @@ "Order": { "description": "Comprehensive information about a customer order.", "type": "object", - "required": ["orderId", "createdTime", "lastUpdatedTime", "salesChannel", "orderItems"], + "required": [ + "orderId", + "createdTime", + "lastUpdatedTime", + "salesChannel", + "orderItems" + ], "properties": { "orderId": { "description": "An Amazon-defined order identifier, in 3-7-7 format.", @@ -588,7 +682,7 @@ }, "programs": { "type": "array", - "description": "Special programs associated with this order that may affect fulfillment or customer experience. \n\n**Possible values**: `AMAZON_BAZAAR`, `AMAZON_BUSINESS`, `AMAZON_EASY_SHIP`, `AMAZON_HAUL`, `DELIVERY_BY_AMAZON`, `FBM_SHIP_PLUS`, `IN_STORE_PICK_UP`, `PREMIUM`, `PREORDER`, `PRIME`", + "description": "Special programs associated with this order that may affect fulfillment or customer experience. \n\n**Possible values**: `AMAZON_BAZAAR`, `AMAZON_BUSINESS`, `AMAZON_EASY_SHIP`, `AMAZON_HAUL`, `DELIVERY_BY_AMAZON`, `FBM_SHIP_PLUS`, `INVOICE_BY_AMAZON`, `IN_STORE_PICK_UP`, `PREMIUM`, `PREORDER`, `PRIME`", "items": { "type": "string" } @@ -616,6 +710,14 @@ "description": "Financial information about this order.", "$ref": "#/definitions/OrderProceeds" }, + "payment": { + "description": "Payment information for the order.", + "$ref": "#/definitions/OrderPayment" + }, + "tax": { + "description": "Tax-related information for the order.", + "$ref": "#/definitions/OrderTax" + }, "fulfillment": { "description": "Information about how this order is being processed and shipped.", "$ref": "#/definitions/OrderFulfillment" @@ -633,13 +735,23 @@ "items": { "$ref": "#/definitions/OrderPackage" } + }, + "fulfillmentOrders": { + "description": "The list of fulfillment orders associated with this customer order. Each entry corresponds to one fulfillment unit created by Amazon for this order. **Note:** Only available for EasyShip orders at present.", + "type": "array", + "items": { + "$ref": "#/definitions/FulfillmentOrder" + } } } }, "Alias": { "description": "An alternative identifier that provides a different way to reference the same order.", "type": "object", - "required": ["aliasId", "aliasType"], + "required": [ + "aliasId", + "aliasType" + ], "properties": { "aliasId": { "description": "The alternative identifier value that can be used to reference this order.", @@ -668,7 +780,9 @@ "SalesChannel": { "description": "Information about where the customer placed this order.", "type": "object", - "required": ["channelName"], + "required": [ + "channelName" + ], "properties": { "channelName": { "description": "The name of the sales platform or channel where the customer placed this order.\n\n**Possible values**: `AMAZON`, `NON_AMAZON`", @@ -727,13 +841,44 @@ "grandTotal": { "description": "The total amount that the seller receives from the sale of the order.", "$ref": "#/definitions/Money" + }, + "breakdowns": { + "description": "Categorized proceeds for the order. Proceed categories are either aggregated across all order items (such as `ITEM`, `SHIPPING`, and `TAX`) or applied at the order level (such as `DELIVERY_TIP`).", + "type": "array", + "items": { + "$ref": "#/definitions/OrderProceedsBreakdown" + } + } + } + }, + "OrderProceedsBreakdown": { + "description": "An entry detailing proceeds information.", + "type": "object", + "required": [ + "type", + "subtotal" + ], + "properties": { + "type": { + "description": "The proceeds category. \n\n**Possible values**: `ITEM`, `SHIPPING`, `GIFT_WRAP`, `COD_FEE`, `TAX`, `DISCOUNT`, `DELIVERY_TIP`, `OTHER`. **Note:** `DELIVERY_TIP` is charged separately and not attributed to a specific item. The remaining categories are aggregated across all order items.", + "type": "string" + }, + "status": { + "description": "The processing status of the charge. Only present for categories processed separately after checkout, such as `DELIVERY_TIP`.\n\n**Possible values**: `PENDING`, `FINALIZED`.", + "type": "string" + }, + "subtotal": { + "description": "The monetary amount for the proceeds category.", + "$ref": "#/definitions/Money" } } }, "OrderFulfillment": { "description": "Information about how the order is being processed, packed, and shipped to the customer.", "type": "object", - "required": ["fulfillmentStatus"], + "required": [ + "fulfillmentStatus" + ], "properties": { "fulfillmentStatus": { "description": "The current status of the order in the fulfillment process, from pending to handover to carrier.", @@ -760,7 +905,11 @@ "OrderItem": { "description": "Information about a single product within an order.", "type": "object", - "required": ["orderItemId", "quantityOrdered", "product"], + "required": [ + "orderItemId", + "quantityOrdered", + "product" + ], "properties": { "orderItemId": { "description": "A unique identifier for this specific item within the order, in 3-7-7 format.", @@ -775,6 +924,13 @@ "description": "The unit of measure and value for items sold by weight, volume, or other measurements rather than simple count.", "$ref": "#/definitions/Measurement" }, + "associatedOrderItems": { + "description": "A list of order items associated with this item. For example, a value-add service purchased with the product.", + "type": "array", + "items": { + "$ref": "#/definitions/AssociatedOrderItem" + } + }, "programs": { "type": "array", "description": "Special programs that apply specifically to this item within the order.\n\n**Possible values**: `TRANSPARENCY`, `SUBSCRIBE_AND_SAVE`", @@ -805,13 +961,19 @@ "fulfillment": { "description": "Information about how the order item should be processed, packed, and shipped to the customer.", "$ref": "#/definitions/ItemFulfillment" + }, + "tax": { + "description": "Tax-related information for this order item.", + "$ref": "#/definitions/ItemTax" } } }, "OrderPackage": { "description": "Information about a physical shipping package, including tracking details. **Note:** Only available for merchant-fulfilled (FBM) orders.", "type": "object", - "required": ["packageReferenceId"], + "required": [ + "packageReferenceId" + ], "properties": { "packageReferenceId": { "description": "A unique identifier for this package within the context of the order.", @@ -1014,7 +1176,15 @@ "FulfillmentStatus": { "description": "The current fulfillment status of an order, indicating where the order is in the fulfillment process from placement to handover to carrier.", "type": "string", - "enum": ["PENDING_AVAILABILITY", "PENDING", "UNSHIPPED", "PARTIALLY_SHIPPED", "SHIPPED", "CANCELLED", "UNFULFILLABLE"], + "enum": [ + "PENDING_AVAILABILITY", + "PENDING", + "UNSHIPPED", + "PARTIALLY_SHIPPED", + "SHIPPED", + "CANCELLED", + "UNFULFILLABLE" + ], "x-docgen-enum-table-extension": [ { "value": "PENDING_AVAILABILITY", @@ -1022,7 +1192,7 @@ }, { "value": "PENDING", - "description": "The order has been placed but is not ready for shipment. Note that for standard orders, the initial order status is `PENDING`. For pre-orders, the initial order status is `PENDING_AVAILABILITY`, and the order passes into the `PENDING` status when payment authorization begins." + "description": "The order has been placed but is not ready for shipment. For standard orders, the initial order status is `PENDING`. For pre-orders, the initial order status is `PENDING_AVAILABILITY`, and the order passes into the `PENDING` status when payment authorization begins." }, { "value": "UNSHIPPED", @@ -1030,7 +1200,7 @@ }, { "value": "PARTIALLY_SHIPPED", - "description": "One or more (but not all) items in the order have been shipped." + "description": "At least one, but not all, items in the order have been shipped." }, { "value": "SHIPPED", @@ -1042,14 +1212,17 @@ }, { "value": "UNFULFILLABLE", - "description": "The order cannot be fulfilled. This state applies only to Amazon-fulfilled orders that were not placed on Amazon's retail web site." + "description": "The order cannot be fulfilled. This state only applies to Amazon-fulfilled orders that were not placed on Amazon's retail website." } ] }, "Measurement": { "type": "object", "description": "Specifies the unit of measure and quantity for items that are sold by weight, volume, length, or other measurements rather than simple count.", - "required": ["unit", "value"], + "required": [ + "unit", + "value" + ], "properties": { "unit": { "type": "string", @@ -1325,7 +1498,15 @@ "dayOfWeek": { "type": "string", "description": "Specific day of the week for which operating hours are being defined.", - "enum": ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"], + "enum": [ + "SUN", + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT" + ], "x-docgen-enum-table-extension": [ { "value": "SUN", @@ -1378,7 +1559,10 @@ "exceptionDateType": { "description": "Operational status of the business on the specified exception date.", "type": "string", - "enum": ["CLOSED", "OPEN"], + "enum": [ + "CLOSED", + "OPEN" + ], "x-docgen-enum-table-extension": [ { "value": "CLOSED", @@ -1428,7 +1612,7 @@ } }, "PreferredDeliveryCapability": { - "description": "Special delivery capabilities available at the shipping address that may affect delivery options and methods. \n\n**Possible values:**\n- `HAS_ACCESS_POINT` (Delivery location includes designated pickup or drop-off access points)\n- `PALLET_ENABLED` (Address is equipped to receive large pallet deliveries)\n- `PALLET_DISABLED` (Address cannot accommodate pallet delivery methods)", + "description": "Special delivery capabilities available at the shipping address that may affect delivery options and methods. \n\n**Possible values**:\n- `HAS_ACCESS_POINT` (Delivery location includes designated pickup or drop-off access points)\n- `PALLET_ENABLED` (Address is equipped to receive large pallet deliveries)\n- `PALLET_DISABLED` (Address cannot accommodate pallet delivery methods)", "type": "string" }, "ItemCondition": { @@ -1476,6 +1660,10 @@ "ItemProceedsBreakdown": { "description": "Detailed proceeds breakdown for a specific order item.", "type": "object", + "required": [ + "type", + "subtotal" + ], "properties": { "type": { "description": "Category classification of the proceeds breakdown. \n\n**Possible values**: `ITEM`, `SHIPPING`, `GIFT_WRAP`, `COD_FEE`, `OTHER`, `TAX`, `DISCOUNT`", @@ -1495,7 +1683,7 @@ } }, "ItemProceedsDetailedBreakdown": { - "description": "Further granular breakdown of the subtotal of the proceeds breakdown, only available for TAX and DISCOUNT proceeds type.", + "description": "Further granular breakdown of the subtotal of the proceeds breakdown, only available for TAX and DISCOUNT proceeds types.", "type": "object", "properties": { "subtype": { @@ -1561,7 +1749,7 @@ "type": "object", "properties": { "substitutionPreference": { - "description": "Substitution preference for an order item when it becomes unavailable during fulfillment", + "description": "Substitution preference for an order item when it becomes unavailable during fulfillment.", "$ref": "#/definitions/ItemSubstitutionPreference" } } @@ -1573,6 +1761,19 @@ "giftOption": { "description": "Gift wrapping and messaging specified for this item.", "$ref": "#/definitions/GiftOption" + }, + "serialNumberRequirement": { + "description": "Whether serial numbers must be provided for this line item.", + "$ref": "#/definitions/SerialNumberRequirement" + } + }, + "example": { + "giftOption": { + "giftMessage": "Happy Holidays! Enjoy your new smart speakers.", + "giftWrapLevel": "PREMIUM" + }, + "serialNumberRequirement": { + "requirementType": "REQUIRED" } } }, @@ -1608,15 +1809,31 @@ } } }, + "SerialNumberRequirement": { + "description": "Whether serial numbers must be provided for this line item.", + "type": "object", + "properties": { + "requirementType": { + "description": "The requirement type for this request. \n\n**Possible values**: `REQUIRED`", + "type": "string" + } + } + }, "ItemSubstitutionPreference": { "type": "object", "description": "Substitution preference for an order item when it becomes unavailable during fulfillment.", - "required": ["substitutionType"], + "required": [ + "substitutionType" + ], "properties": { "substitutionType": { "type": "string", "description": "Source and nature of the substitution preferences for this item.", - "enum": ["CUSTOMER_PREFERENCE", "AMAZON_RECOMMENDED", "DO_NOT_SUBSTITUTE"], + "enum": [ + "CUSTOMER_PREFERENCE", + "AMAZON_RECOMMENDED", + "DO_NOT_SUBSTITUTE" + ], "x-docgen-enum-table-extension": [ { "value": "CUSTOMER_PREFERENCE", @@ -1696,7 +1913,9 @@ "ConstraintType": { "type": "string", "description": "Classification of the enforcement level required for shipping and delivery constraints.", - "enum": ["MANDATORY"], + "enum": [ + "MANDATORY" + ], "x-docgen-enum-table-extension": [ { "value": "MANDATORY", @@ -1717,12 +1936,21 @@ "PackageStatus": { "description": "Current status and detailed tracking information for a shipping package throughout the delivery process.", "type": "object", - "required": ["status"], + "required": [ + "status" + ], "properties": { "status": { "description": "Primary status classification of the package in the shipping workflow.", "type": "string", - "enum": ["PENDING", "IN_TRANSIT", "SHIPPED", "DELIVERED", "CANCELLED", "UNDELIVERABLE"], + "enum": [ + "PENDING", + "IN_TRANSIT", + "SHIPPED", + "DELIVERED", + "CANCELLED", + "UNDELIVERABLE" + ], "x-docgen-enum-table-extension": [ { "value": "PENDING", @@ -1751,7 +1979,7 @@ ] }, "detailedStatus": { - "description": "Granular status information providing specific details about the package's current location and handling stage. \n\n**Possible values:**\n- `PENDING_SCHEDULE` (Package awaiting pickup scheduling)\n- `PENDING_PICK_UP` (Package ready for carrier collection from seller)\n- `PENDING_DROP_OFF` (Package awaiting seller delivery to carrier)\n- `LABEL_CANCELLED` (Shipping label canceled by seller)\n- `PICKED_UP` (Package collected by carrier from seller location)\n- `DROPPED_OFF` (Package delivered to carrier by seller)\n- `AT_ORIGIN_FC` (Package at originating fulfillment center)\n- `AT_DESTINATION_FC` (Package at destination fulfillment center)\n- `DELIVERED` (Package successfully delivered to recipient)\n- `REJECTED_BY_BUYER` (Package refused by intended recipient)\n- `UNDELIVERABLE` (Package cannot be delivered due to address or access issues)\n- `RETURNING_TO_SELLER` (Package in transit back to seller)\n- `RETURNED_TO_SELLER` (Package successfully returned to seller)\n- `LOST` (Package location unknown or confirmed lost)\n- `OUT_FOR_DELIVERY` (Package on delivery vehicle for final delivery)\n- `DAMAGED` (Package damaged during transit)\n", + "description": "Granular status information providing specific details about the package's current location and handling stage. \n\n**Possible values**:\n- `PENDING_SCHEDULE` (Package awaiting pickup scheduling)\n- `PENDING_PICK_UP` (Package ready for carrier collection from seller)\n- `PENDING_DROP_OFF` (Package awaiting seller delivery to carrier)\n- `LABEL_CANCELLED` (Shipping label canceled by seller)\n- `PICKED_UP` (Package collected by carrier from seller location)\n- `DROPPED_OFF` (Package delivered to carrier by seller)\n- `AT_ORIGIN_FC` (Package at originating fulfillment center)\n- `AT_DESTINATION_FC` (Package at destination fulfillment center)\n- `DELIVERED` (Package successfully delivered to recipient)\n- `REJECTED_BY_BUYER` (Package refused by intended recipient)\n- `UNDELIVERABLE` (Package cannot be delivered due to address or access issues)\n- `RETURNING_TO_SELLER` (Package in transit back to seller)\n- `RETURNED_TO_SELLER` (Package successfully returned to seller)\n- `LOST` (Package location unknown or confirmed lost)\n- `OUT_FOR_DELIVERY` (Package on delivery vehicle for final delivery)\n- `DAMAGED` (Package damaged during transit)\n", "type": "string" } } @@ -1759,11 +1987,13 @@ "PackageItem": { "description": "Individual order item contained within a shipping package.", "type": "object", - "required": ["orderItemId", "quantity"], + "required": [ + "orderItemId", + "quantity" + ], "properties": { "orderItemId": { - "description": "Unique identifier of the order item included in this package, in 3-7-7 format.", - "pattern": "^\\d{3}-\\d{7}-\\d{7}$", + "description": "Unique identifier of the order item included in this package.", "type": "string" }, "quantity": { @@ -1779,6 +2009,190 @@ } } }, + "OrderPayment": { + "description": "Payment information about the order.", + "type": "object", + "properties": { + "paymentExecutions": { + "description": "A list of payment executions for the order.", + "type": "array", + "items": { + "$ref": "#/definitions/PaymentExecution" + } + } + } + }, + "PaymentExecution": { + "description": "Payment execution details for an order.", + "type": "object", + "properties": { + "paymentMethod": { + "description": "The payment method used for this payment execution (for example, CashOnDelivery, ConvenienceStore, CreditCard, Invoice, Pix, and so on).", + "type": "string" + }, + "paymentAmount": { + "description": "The monetary value of the payment execution.", + "$ref": "#/definitions/Money" + }, + "acquirerId": { + "description": "The unique identifier of the payment processor or acquiring bank that authorizes the payment. \n\n**Note**: This attribute is only available for orders in the Brazil (BR) marketplace when the `paymentMethod` is `CreditCard` or `Pix`.", + "type": "string" + }, + "cardBrand": { + "description": "The card network or brand used in the payment transaction (for example, Visa or Mastercard).\n\n**Note**: This attribute is only available for orders in the Brazil (BR) marketplace when the `paymentMethod` is `CreditCard`.", + "type": "string" + }, + "authorizationCode": { + "description": "The unique code that confirms the payment authorization.\n\n**Note**: This attribute is only available for orders in the Brazil (BR) marketplace when the `paymentMethod` is `CreditCard` or `Pix`.", + "type": "string" + } + } + }, + "OrderTax": { + "description": "Tax information about the order.", + "type": "object", + "properties": { + "taxRegistrations": { + "description": "A list of tax registrations associated with the order.", + "type": "array", + "items": { + "$ref": "#/definitions/OrderTaxRegistration" + } + }, + "taxInvoicing": { + "description": "Tax invoicing information for the order.", + "$ref": "#/definitions/OrderTaxInvoicing" + } + } + }, + "OrderTaxRegistration": { + "description": "Tax registration information for an entity associated with the order.", + "type": "object", + "properties": { + "entityType": { + "description": "The type of entity that the tax registration belongs to.\n\n**Possible values**:\n- `BUYER` (Indicates that this is the buyer's tax registration information)\n- `MERCHANT` (Indicates that this is the merchant's tax registration information)\n- `MARKETPLACE` (Indicates that this is the marketplace's tax registration information)", + "type": "string" + }, + "legalName": { + "description": "The legal name associated with the tax registration.", + "type": "string" + }, + "taxRegistrationType": { + "description": "The type of the tax registration number.\n\n**Possible values**: `BUSINESS`, `VAT`, `CST`, `CPF`, `CNPJ`", + "type": "string" + }, + "taxRegistrationNumber": { + "description": "The tax registration number that identifies the entity for tax purposes.", + "type": "string" + }, + "taxRegistrationAddress": { + "description": "The address associated with the tax registration.", + "$ref": "#/definitions/CustomerAddress" + }, + "taxRegistrationAttributes": { + "description": "Additional attributes related to the tax registration.", + "type": "array", + "items": { + "$ref": "#/definitions/TaxRegistrationAttribute" + } + } + } + }, + "TaxRegistrationAttribute": { + "description": "An additional attribute associated with a tax registration.", + "type": "object", + "properties": { + "key": { + "description": "The name of the tax registration attribute.\n\n**Possible values**: `TAX_OFFICE`", + "type": "string" + }, + "value": { + "description": "The value of the tax registration attribute.", + "type": "string" + } + } + }, + "OrderTaxInvoicing": { + "description": "Tax invoicing information for the order.", + "type": "object", + "properties": { + "buyerInvoicePreference": { + "description": "The buyer's invoicing preference, which indicates whether the seller should issue an individual or a business invoice to the buyer. \n\n **Note**: This attribute is only available in the Turkey marketplace. \n\n**Possible values**:\n- `INDIVIDUAL` (Issues an individual invoice to the buyer)\n- `BUSINESS` (Issues a business invoice to the buyer)", + "type": "string" + }, + "invoiceStatus": { + "description": "The status of the invoice. Only available for Easy Ship orders and orders in the Brazil marketplace.\n\n**Possible values**:\n- `NOT_REQUIRED` (The order does not require an electronic invoice to be uploaded)\n- `NOT_FOUND` (The order requires an electronic invoice but it is not uploaded)\n- `PROCESSING` (The required electronic invoice was uploaded and is processing)\n- `ERRORED` (The uploaded electronic invoice was not accepted)\n- `ACCEPTED` (The uploaded electronic invoice was accepted)", + "type": "string" + } + } + }, + "AssociatedOrderItem": { + "description": "An associated order item that a customer has purchased with the product. For example, a tire installation service purchased with tires.", + "type": "object", + "properties": { + "orderId": { + "description": "The order identifier of the associated order item.", + "type": "string" + }, + "orderItemId": { + "description": "The order item identifier of the associated order item.", + "type": "string" + }, + "associationType": { + "description": "The type of association between the order items.\n\n**Possible values**:\n- `VALUE_ADD_SERVICE` (The associated item is a service order)", + "type": "string" + } + }, + "example": { + "orderId": "123-4567890-7654321", + "orderItemId": "12345678904321", + "associationType": "VALUE_ADD_SERVICE" + } + }, + "ItemTax": { + "description": "Tax information for an order item.", + "type": "object", + "properties": { + "taxCalculationBreakdowns": { + "description": "A list of tax calculation breakdowns for the order item.", + "type": "array", + "items": { + "$ref": "#/definitions/ItemTaxCalculationBreakdown" + } + }, + "taxCollections": { + "description": "A list of tax collections for the order item.", + "type": "array", + "items": { + "$ref": "#/definitions/ItemTaxCollection" + } + } + } + }, + "ItemTaxCalculationBreakdown": { + "description": "Tax calculation breakdowns for an order item.", + "type": "object", + "properties": { + "reportingScheme": { + "description": "The tax reporting scheme applied to this order item.\n\n**Possible values**:\n- `UOSS` (Union one stop shop. The item being purchased is held in the EU for shipment)\n- `IOSS` (Import one stop shop. The item being purchased is not held in the EU for shipment)", + "type": "string" + } + } + }, + "ItemTaxCollection": { + "description": "Tax collection information for an order item.", + "type": "object", + "properties": { + "model": { + "description": "The tax collection model applied to the item.\n\n**Possible values**:\n- `MARKETPLACE_FACILITATOR` (Tax is withheld and remitted to the taxing authority by Amazon on behalf of the seller)", + "type": "string" + }, + "responsibleParty": { + "description": "The party responsible for withholding the taxes and remitting them to the taxing authority.", + "type": "string" + } + } + }, "Asin": { "description": "The Amazon Standard Identification Number (ASIN), which uniquely identifies a product (catalog item).", "type": "string" @@ -1798,7 +2212,10 @@ "type": "string" } }, - "required": ["amount", "currencyCode"], + "required": [ + "amount", + "currencyCode" + ], "type": "object" }, "Decimal": { @@ -1823,7 +2240,10 @@ }, "Error": { "description": "Error response returned when the request is unsuccessful.", - "required": ["code", "message"], + "required": [ + "code", + "message" + ], "type": "object", "properties": { "code": { @@ -1843,7 +2263,9 @@ "ErrorList": { "type": "object", "description": "A list of error responses returned when a request is unsuccessful.", - "required": ["errors"], + "required": [ + "errors" + ], "properties": { "errors": { "description": "A list of errors.", @@ -1853,6 +2275,19 @@ } } } + }, + "FulfillmentOrder": { + "description": "Information about a fulfillment order associated with a customer order. A fulfillment order represents a unit of fulfillment created by Amazon for the order. **Note:** Only available for EasyShip orders at present.", + "type": "object", + "required": [ + "fulfillmentOrderId" + ], + "properties": { + "fulfillmentOrderId": { + "description": "The Fulfillment Order ID assigned by Amazon after fulfillment planning. This identifier is identical to the Shipment ID required by External Fulfillment APIs.", + "type": "string" + } + } } } } diff --git a/local-ai-sandbox/res/models/productPricing_2022-05-01.json b/local-ai-sandbox/res/models/productPricing_2022-05-01.json index 49b2812ff..a1fd5958f 100644 --- a/local-ai-sandbox/res/models/productPricing_2022-05-01.json +++ b/local-ai-sandbox/res/models/productPricing_2022-05-01.json @@ -14,13 +14,21 @@ } }, "host": "sellingpartnerapi-na.amazon.com", - "schemes": ["https"], - "consumes": ["application/json"], - "produces": ["application/json"], + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "paths": { "/batches/products/pricing/2022-05-01/offer/featuredOfferExpectedPrice": { "post": { - "tags": ["productPricing"], + "tags": [ + "productPricing" + ], "description": "Returns the set of responses that correspond to the batched list of up to 40 requests defined in the request body. The response for each successful (HTTP status code 200) request in the set includes the computed listing price at or below which a seller can expect to become the featured offer (before applicable promotions). This is called the featured offer expected price (FOEP). Featured offer is not guaranteed because competing offers might change. Other offers might be featured based on factors such as fulfillment capabilities to a specific customer. The response to an unsuccessful request includes the available error text.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.033 | 1 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that are applied to the requested operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may receive higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api) in the Selling Partner API.", "operationId": "getFeaturedOfferExpectedPriceBatch", "parameters": [ @@ -148,7 +156,9 @@ }, "/batches/products/pricing/2022-05-01/items/competitiveSummary": { "post": { - "tags": ["productPricing"], + "tags": [ + "productPricing" + ], "description": "Returns the competitive summary response, including featured buying options for the ASIN and `marketplaceId` combination.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.033 | 1 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that are applied to the requested operation, when available. The preceding table contains the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may receive higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api) in the Selling Partner API.", "operationId": "getCompetitiveSummary", "parameters": [ @@ -295,7 +305,10 @@ "FeaturedOfferExpectedPriceRequestParams": { "description": "The parameters for an individual request.", "type": "object", - "required": ["marketplaceId", "sku"], + "required": [ + "marketplaceId", + "sku" + ], "properties": { "marketplaceId": { "$ref": "#/definitions/MarketplaceId" @@ -332,7 +345,9 @@ }, { "type": "object", - "required": ["request"], + "required": [ + "request" + ], "properties": { "request": { "$ref": "#/definitions/FeaturedOfferExpectedPriceRequestParams", @@ -349,7 +364,9 @@ "CompetitiveSummaryBatchRequest": { "description": "The `competitiveSummary` batch request data.", "type": "object", - "required": ["requests"], + "required": [ + "requests" + ], "properties": { "requests": { "description": "A batched list of `competitiveSummary` requests.", @@ -369,7 +386,13 @@ "CompetitiveSummaryRequest": { "description": "An individual `competitiveSummary` request for an ASIN and `marketplaceId`.", "type": "object", - "required": ["asin", "marketplaceId", "includedData", "method", "uri"], + "required": [ + "asin", + "marketplaceId", + "includedData", + "method", + "uri" + ], "properties": { "asin": { "description": "The Amazon Standard Identification Number for the item.", @@ -409,7 +432,12 @@ "CompetitiveSummaryIncludedData": { "type": "string", "description": "The supported data types in the `getCompetitiveSummary` API.", - "enum": ["featuredBuyingOptions", "referencePrices", "lowestPricedOffers", "similarItems"], + "enum": [ + "featuredBuyingOptions", + "referencePrices", + "lowestPricedOffers", + "similarItems" + ], "x-docgen-enum-table-extension": [ { "value": "featuredBuyingOptions", @@ -432,7 +460,10 @@ "LowestPricedOffersInput": { "description": "The input required for building `LowestPricedOffers` data in the response.", "type": "object", - "required": ["itemCondition", "offerType"], + "required": [ + "itemCondition", + "offerType" + ], "properties": { "itemCondition": { "type": "string", @@ -442,7 +473,9 @@ "offerType": { "type": "string", "description": "The input parameter specifies the type of offers requested for `LowestPricedOffers`. This applies to `Consumer` and `Business` offers. `Consumer` is the default `offerType`.", - "enum": ["Consumer"], + "enum": [ + "Consumer" + ], "x-docgen-enum-table-extension": [ { "value": "Consumer", @@ -455,7 +488,9 @@ "CompetitiveSummaryBatchResponse": { "description": "The response schema for the `competitiveSummaryBatch` operation.", "type": "object", - "required": ["responses"], + "required": [ + "responses" + ], "properties": { "responses": { "description": "The response list for the `competitiveSummaryBatch` operation.", @@ -476,7 +511,10 @@ "CompetitiveSummaryResponse": { "description": "The response for the individual `competitiveSummary` request in the batch operation.", "type": "object", - "required": ["status", "body"], + "required": [ + "status", + "body" + ], "properties": { "status": { "description": "The HTTP status line associated with the response. For more information, refer to [RFC 2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec6.html).", @@ -491,7 +529,10 @@ "CompetitiveSummaryResponseBody": { "description": "The `competitiveSummaryResponse` body for a requested ASIN and `marketplaceId`.", "type": "object", - "required": ["asin", "marketplaceId"], + "required": [ + "asin", + "marketplaceId" + ], "properties": { "asin": { "description": "The Amazon identifier for the item.", @@ -538,7 +579,10 @@ "ReferencePrice": { "description": "The reference price for the specified ASIN `marketplaceId` combination.", "type": "object", - "required": ["name", "price"], + "required": [ + "name", + "price" + ], "properties": { "name": { "description": "Reference price type (e.g., `CompetitivePriceThreshold`, `WasPrice`, `CompetitivePrice`). For definitions, see the [Product Pricing API Use Case Guide](https://developer-docs.amazon.com/sp-api/docs/product-pricing-api-v2022-05-01-use-case-guide).", @@ -566,7 +610,9 @@ "Item": { "description": "A similar item for the specified ASIN `marketplaceId` combination.", "type": "object", - "required": ["asin"], + "required": [ + "asin" + ], "properties": { "asin": { "description": "The Amazon identifier for the item.", @@ -577,12 +623,17 @@ "FeaturedBuyingOption": { "description": "Describes a featured buying option, which includes a list of segmented featured offers for a particular item condition.", "type": "object", - "required": ["buyingOptionType", "segmentedFeaturedOffers"], + "required": [ + "buyingOptionType", + "segmentedFeaturedOffers" + ], "properties": { "buyingOptionType": { "description": "The buying option type for the featured offer. `buyingOptionType` represents the buying options that a customer receives on the detail page, such as `B2B`, `Fresh`, and `Subscribe n Save`. `buyingOptionType` currently supports `NEW` as a value.", "type": "string", - "enum": ["New"], + "enum": [ + "New" + ], "x-docgen-enum-table-extension": [ { "value": "New", @@ -609,7 +660,9 @@ { "type": "object", "description": "The list of segment information in which the offer is featured.", - "required": ["featuredOfferSegments"], + "required": [ + "featuredOfferSegments" + ], "properties": { "featuredOfferSegments": { "description": "The list of segment information in which the offer is featured.", @@ -625,7 +678,10 @@ "LowestPricedOffer": { "description": "Describes the lowest priced offers for the specified item condition and offer type.", "type": "object", - "required": ["lowestPricedOffersInput", "offers"], + "required": [ + "lowestPricedOffersInput", + "offers" + ], "properties": { "lowestPricedOffersInput": { "description": "The filtering criteria that are used to retrieve the lowest priced offers that correspond to the `lowestPricedOffersInputs` request.", @@ -646,7 +702,12 @@ "Offer": { "description": "The offer data of a product.", "type": "object", - "required": ["sellerId", "condition", "fulfillmentType", "listingPrice"], + "required": [ + "sellerId", + "condition", + "fulfillmentType", + "listingPrice" + ], "properties": { "sellerId": { "type": "string", @@ -757,12 +818,18 @@ "PrimeDetails": { "description": "Amazon Prime details.", "type": "object", - "required": ["eligibility"], + "required": [ + "eligibility" + ], "properties": { "eligibility": { "description": "Indicates whether the offer is an Amazon Prime offer.", "type": "string", - "enum": ["NATIONAL", "REGIONAL", "NONE"], + "enum": [ + "NATIONAL", + "REGIONAL", + "NONE" + ], "x-docgen-enum-table-extension": [ { "value": "NATIONAL", @@ -783,11 +850,16 @@ "ShippingOption": { "description": "The shipping option available for the offer.", "type": "object", - "required": ["shippingOptionType", "price"], + "required": [ + "shippingOptionType", + "price" + ], "properties": { "shippingOptionType": { "description": "The type of shipping option.", - "enum": ["DEFAULT"], + "enum": [ + "DEFAULT" + ], "x-docgen-enum-table-extension": [ { "value": "DEFAULT", @@ -804,12 +876,19 @@ "FeaturedOfferSegment": { "description": "Describes the segment in which the offer is featured.", "type": "object", - "required": ["customerMembership", "segmentDetails"], + "required": [ + "customerMembership", + "segmentDetails" + ], "properties": { "customerMembership": { "description": "The customer membership type that makes up this segment", "type": "string", - "enum": ["PRIME", "NON_PRIME", "DEFAULT"], + "enum": [ + "PRIME", + "NON_PRIME", + "DEFAULT" + ], "x-docgen-enum-table-extension": [ { "value": "PRIME", @@ -871,7 +950,9 @@ "Errors": { "type": "object", "description": "A list of error responses returned when a request is unsuccessful.", - "required": ["errors"], + "required": [ + "errors" + ], "properties": { "errors": { "description": "One or more unexpected errors occurred during the operation.", @@ -907,7 +988,9 @@ "FeaturedOfferExpectedPriceResult": { "description": "The FOEP result data for the requested offer.", "type": "object", - "required": ["resultStatus"], + "required": [ + "resultStatus" + ], "properties": { "featuredOfferExpectedPrice": { "$ref": "#/definitions/FeaturedOfferExpectedPrice" @@ -929,7 +1012,9 @@ "FeaturedOfferExpectedPrice": { "description": "The item price at or below which the target offer may be featured.", "type": "object", - "required": ["listingPrice"], + "required": [ + "listingPrice" + ], "properties": { "listingPrice": { "description": "A computed listing price at or below which a seller can expect to become the featured offer (before applicable promotions).", @@ -943,7 +1028,9 @@ }, "FeaturedOffer": { "type": "object", - "required": ["offerIdentifier"], + "required": [ + "offerIdentifier" + ], "properties": { "offerIdentifier": { "description": "An offer identifier used to identify the merchant of the featured offer. Since this may not belong to the requester, the SKU field is omitted.", @@ -985,7 +1072,7 @@ }, "HttpBody": { "description": "Additional HTTP body information that is associated with an individual request within a batch.", - "type": "object" + "type": "string" }, "HttpUri": { "description": "The URI associated with the individual APIs that are called as part of the batch request.", @@ -996,7 +1083,13 @@ "HttpMethod": { "description": "The HTTP method associated with an individual request within a batch.", "type": "string", - "enum": ["GET", "PUT", "PATCH", "DELETE", "POST"], + "enum": [ + "GET", + "PUT", + "PATCH", + "DELETE", + "POST" + ], "x-docgen-enum-table-extension": [ { "value": "GET", @@ -1023,7 +1116,10 @@ "BatchRequest": { "description": "The common properties for individual requests within a batch.", "type": "object", - "required": ["uri", "method"], + "required": [ + "uri", + "method" + ], "properties": { "uri": { "description": "The URI associated with an individual request within a batch. For `FeaturedOfferExpectedPrice`, this is `/products/pricing/2022-05-01/offer/featuredOfferExpectedPrice`.", @@ -1043,7 +1139,10 @@ "BatchResponse": { "description": "The common properties for responses to individual requests within a batch.", "type": "object", - "required": ["status", "headers"], + "required": [ + "status", + "headers" + ], "properties": { "headers": { "$ref": "#/definitions/HttpHeaders" @@ -1056,7 +1155,10 @@ "OfferIdentifier": { "description": "Identifies an offer from a particular seller for a specified ASIN.", "type": "object", - "required": ["marketplaceId", "asin"], + "required": [ + "marketplaceId", + "asin" + ], "properties": { "marketplaceId": { "$ref": "#/definitions/MarketplaceId", @@ -1096,7 +1198,9 @@ }, "Price": { "type": "object", - "required": ["listingPrice"], + "required": [ + "listingPrice" + ], "properties": { "listingPrice": { "description": "The listing price for the item, excluding any promotions.", @@ -1131,7 +1235,10 @@ "FulfillmentType": { "type": "string", "description": "Indicates whether the item is fulfilled by Amazon or by the seller (merchant).", - "enum": ["AFN", "MFN"], + "enum": [ + "AFN", + "MFN" + ], "x-docgen-enum-table-extension": [ { "value": "AFN", @@ -1164,7 +1271,13 @@ "Condition": { "description": "The condition of the item.", "type": "string", - "enum": ["New", "Used", "Collectible", "Refurbished", "Club"], + "enum": [ + "New", + "Used", + "Collectible", + "Refurbished", + "Club" + ], "x-docgen-enum-table-extension": [ { "value": "New", @@ -1201,7 +1314,10 @@ }, "Error": { "type": "object", - "required": ["code", "message"], + "required": [ + "code", + "message" + ], "properties": { "code": { "type": "string", diff --git a/local-ai-sandbox/res/models/reports_2021-06-30.json b/local-ai-sandbox/res/models/reports_2021-06-30.json index 3c991ea48..b5c721409 100644 --- a/local-ai-sandbox/res/models/reports_2021-06-30.json +++ b/local-ai-sandbox/res/models/reports_2021-06-30.json @@ -14,13 +14,21 @@ } }, "host": "sellingpartnerapi-na.amazon.com", - "schemes": ["https"], - "consumes": ["application/json"], - "produces": ["application/json"], + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "paths": { "/reports/2021-06-30/reports": { "get": { - "tags": ["reports"], + "tags": [ + "reports" + ], "operationId": "getReports", "description": "Returns report details for the reports that match the filters that you specify.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0222 | 10 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", "parameters": [ @@ -45,7 +53,13 @@ "minItems": 1, "items": { "type": "string", - "enum": ["CANCELLED", "DONE", "FATAL", "IN_PROGRESS", "IN_QUEUE"], + "enum": [ + "CANCELLED", + "DONE", + "FATAL", + "IN_PROGRESS", + "IN_QUEUE" + ], "x-docgen-enum-table-extension": [ { "value": "CANCELLED", @@ -131,37 +145,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "reportTypes": { - "value": ["FEE_DISCOUNTS_REPORT", "GET_AFN_INVENTORY_DATA"] - }, - "processingStatuses": { - "value": ["IN_QUEUE", "IN_PROGRESS"] - } - } - }, - "response": { - "nextToken": "VGhpcyB0b2tlbiBpcyBvcGFxdWUgYW5kIGludGVudGlvbmFsbHkgb2JmdXNjYXRlZA==", - "reports": [ - { - "reportId": "ReportId1", - "reportType": "FEE_DISCOUNTS_REPORT", - "dataStartTime": "2024-03-11T13:47:20.677Z", - "dataEndTime": "2024-03-12T13:47:20.677Z", - "createdTime": "2024-03-10T13:47:20.677Z", - "processingStatus": "IN_PROGRESS", - "processingStartTime": "2024-03-10T13:47:20.677Z", - "processingEndTime": "2024-03-12T13:47:20.677Z" - } - ] - } - } - ] } }, "400": { @@ -178,31 +161,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "reportTypes": { - "value": ["FEE_DISCOUNTS_REPORT", "GET_AFN_INVENTORY_DATA"] - }, - "processingStatuses": { - "value": ["BAD_VALUE", "IN_PROGRESS"] - } - } - }, - "response": { - "errors": [ - { - "code": "400", - "message": "Invalid input", - "details": "Invalid input in processing status" - } - ] - } - } - ] } }, "401": { @@ -296,7 +254,9 @@ } }, "post": { - "tags": ["reports"], + "tags": [ + "reports" + ], "operationId": "createReport", "description": "Creates a report.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0167 | 15 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", "parameters": [ @@ -325,26 +285,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "body": { - "value": { - "reportType": "GET_MERCHANT_LISTINGS_ALL_DATA", - "dataStartTime": "2024-03-10T20:11:24.000Z", - "marketplaceIds": ["A1PA6795UKMFR9", "ATVPDKIKX0DER"] - } - } - } - }, - "response": { - "reportId": "ID323" - } - } - ] } }, "400": { @@ -361,32 +301,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "body": { - "value": { - "reportType": "BAD_FEE_DISCOUNTS_REPORT", - "dataStartTime": "2024-03-10T20:11:24.000Z", - "marketplaceIds": ["A1PA6795UKMFR9", "ATVPDKIKX0DER"] - } - } - } - }, - "response": { - "errors": [ - { - "code": "400", - "message": "Invalid input", - "details": "Invalid input" - } - ] - } - } - ] } }, "401": { @@ -483,7 +397,9 @@ }, "/reports/2021-06-30/reports/{reportId}": { "delete": { - "tags": ["reports"], + "tags": [ + "reports" + ], "operationId": "cancelReport", "description": "Cancels the report that you specify. Only reports with `processingStatus=IN_QUEUE` can be cancelled. Cancelled reports are returned in subsequent calls to the `getReport` and `getReports` operations.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0222 | 10 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", "parameters": [ @@ -507,19 +423,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "reportId": { - "value": "ID" - } - } - } - } - ] } }, "400": { @@ -536,24 +439,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": {} - }, - "response": { - "errors": [ - { - "code": "400", - "message": "Invalid input", - "details": "Invalid input" - } - ] - } - } - ] } }, "401": { @@ -647,7 +532,9 @@ } }, "get": { - "tags": ["reports"], + "tags": [ + "reports" + ], "operationId": "getReport", "description": "Returns report details (including the `reportDocumentId`, if available) for the report that you specify.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 2 | 15 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", "parameters": [ @@ -674,29 +561,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "reportId": { - "value": "ID323" - } - } - }, - "response": { - "reportId": "ReportId1", - "reportType": "FEE_DISCOUNTS_REPORT", - "dataStartTime": "2024-03-11T13:47:20.677Z", - "dataEndTime": "2024-03-12T13:47:20.677Z", - "createdTime": "2024-03-10T13:47:20.677Z", - "processingStatus": "IN_PROGRESS", - "processingStartTime": "2024-03-10T13:47:20.677Z", - "processingEndTime": "2024-03-12T13:47:20.677Z" - } - } - ] } }, "400": { @@ -713,28 +577,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "reportId": { - "value": "badReportId1" - } - } - }, - "response": { - "errors": [ - { - "code": "400", - "message": "Invalid input", - "details": "Invalid input" - } - ] - } - } - ] } }, "401": { @@ -831,7 +673,9 @@ }, "/reports/2021-06-30/schedules": { "get": { - "tags": ["reports"], + "tags": [ + "reports" + ], "operationId": "getReportSchedules", "description": "Returns report schedule details that match the filters that you specify.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0222 | 10 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", "parameters": [ @@ -863,36 +707,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "reportTypes": { - "value": ["FEE_DISCOUNTS_REPORT", "GET_FBA_FULFILLMENT_CUSTOMER_TAXES_DATA"] - } - } - }, - "response": { - "reportSchedules": [ - { - "reportType": "FEE_DISCOUNTS_REPORT", - "marketplaceIds": ["ATVPDKIKX0DER"], - "reportScheduleId": "ID1", - "period": "PT5M", - "nextReportCreationTime": "2024-03-11T15:03:44.973Z" - }, - { - "reportType": "GET_FBA_FULFILLMENT_CUSTOMER_TAXES_DATA", - "reportScheduleId": "ID2", - "period": "PT5M", - "nextReportCreationTime": "2024-03-11T15:03:44.973Z" - } - ] - } - } - ] } }, "400": { @@ -909,28 +723,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "reportTypes": { - "value": ["BAD_FEE_DISCOUNTS_REPORT", "BAD_GET_FBA_FULFILLMENT_CUSTOMER_TAXES_DATA"] - } - } - }, - "response": { - "errors": [ - { - "code": "400", - "message": "Invalid input", - "details": "Invalid input" - } - ] - } - } - ] } }, "401": { @@ -1024,7 +816,9 @@ } }, "post": { - "tags": ["reports"], + "tags": [ + "reports" + ], "operationId": "createReportSchedule", "description": "Creates a report schedule. If a report schedule with the same report type and marketplace IDs already exists, it will be cancelled and replaced with this one.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0222 | 10 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", "parameters": [ @@ -1053,27 +847,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "body": { - "value": { - "reportType": "FEE_DISCOUNTS_REPORT", - "period": "PT5M", - "nextReportCreationTime": "2024-03-10T20:11:24.000Z", - "marketplaceIds": ["A1PA6795UKMFR9", "ATVPDKIKX0DER"] - } - } - } - }, - "response": { - "reportScheduleId": "ID323" - } - } - ] } }, "400": { @@ -1090,32 +863,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "body": { - "value": { - "reportType": "BAD_FEE_DISCOUNTS_REPORT", - "period": "PT5M", - "nextReportCreationTime": "2024-03-10T20:11:24.000Z" - } - } - } - }, - "response": { - "errors": [ - { - "code": "400", - "message": "Invalid input", - "details": "Invalid input" - } - ] - } - } - ] } }, "401": { @@ -1212,7 +959,9 @@ }, "/reports/2021-06-30/schedules/{reportScheduleId}": { "delete": { - "tags": ["reports"], + "tags": [ + "reports" + ], "operationId": "cancelReportSchedule", "description": "Cancels the report schedule that you specify.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0222 | 10 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", "parameters": [ @@ -1236,19 +985,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "reportScheduleId": { - "value": "ID" - } - } - } - } - ] } }, "400": { @@ -1265,24 +1001,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": {} - }, - "response": { - "errors": [ - { - "code": "400", - "message": "Invalid input", - "details": "Invalid input" - } - ] - } - } - ] } }, "401": { @@ -1376,7 +1094,9 @@ } }, "get": { - "tags": ["reports"], + "tags": [ + "reports" + ], "operationId": "getReportSchedule", "description": "Returns report schedule details for the report schedule that you specify.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0222 | 10 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", "parameters": [ @@ -1403,25 +1123,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "reportScheduleId": { - "value": "ID323" - } - } - }, - "response": { - "reportScheduleId": "ReportScheduleId1", - "reportType": "FEE_DISCOUNTS_REPORT", - "period": "PT5M", - "nextReportCreationTime": "2024-03-12T13:47:20.677Z" - } - } - ] } }, "400": { @@ -1438,28 +1139,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "reportScheduleId": { - "value": "badReportId1" - } - } - }, - "response": { - "errors": [ - { - "code": "400", - "message": "Invalid input", - "details": "Invalid input" - } - ] - } - } - ] } }, "401": { @@ -1556,7 +1235,9 @@ }, "/reports/2021-06-30/documents/{reportDocumentId}": { "get": { - "tags": ["reports"], + "tags": [ + "reports" + ], "description": "Returns the information required for retrieving a report document's contents.\n\n**Usage Plan:**\n\n| Rate (requests per second) | Burst |\n| ---- | ---- |\n| 0.0167 | 15 |\n\nThe `x-amzn-RateLimit-Limit` response header returns the usage plan rate limits that were applied to the requested operation, when available. The table above indicates the default rate and burst values for this operation. Selling partners whose business demands require higher throughput may see higher rate and burst values than those shown here. For more information, refer to [Usage Plans and Rate Limits in the Selling Partner API](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits-in-the-sp-api).", "operationId": "getReportDocument", "parameters": [ @@ -1590,23 +1271,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "reportDocumentId": { - "value": "0356cf79-b8b0-4226-b4b9-0ee058ea5760" - } - } - }, - "response": { - "reportDocumentId": "0356cf79-b8b0-4226-b4b9-0ee058ea5760", - "url": "https://d34o8swod1owfl.cloudfront.net/Report_47700__GET_MERCHANT_LISTINGS_ALL_DATA_.txt" - } - } - ] } }, "400": { @@ -1623,28 +1287,6 @@ "type": "string", "description": "Unique request reference identifier." } - }, - "x-amzn-api-sandbox": { - "static": [ - { - "request": { - "parameters": { - "reportDocumentId": { - "value": "badDocumentId1" - } - } - }, - "response": { - "errors": [ - { - "code": "400", - "message": "Invalid input", - "details": "Invalid input" - } - ] - } - } - ] } }, "401": { @@ -1743,7 +1385,9 @@ "ErrorList": { "type": "object", "description": "A list of error responses returned when a request is unsuccessful.", - "required": ["errors"], + "required": [ + "errors" + ], "properties": { "errors": { "type": "array", @@ -1756,7 +1400,10 @@ }, "Error": { "type": "object", - "required": ["code", "message"], + "required": [ + "code", + "message" + ], "properties": { "code": { "type": "string", @@ -1776,7 +1423,12 @@ "Report": { "type": "object", "description": "Detailed information about the report.", - "required": ["processingStatus", "reportId", "reportType", "createdTime"], + "required": [ + "processingStatus", + "reportId", + "reportType", + "createdTime" + ], "properties": { "marketplaceIds": { "description": "A list of marketplace identifiers for the report.", @@ -1815,7 +1467,13 @@ "processingStatus": { "description": "The processing status of the report.", "type": "string", - "enum": ["CANCELLED", "DONE", "FATAL", "IN_PROGRESS", "IN_QUEUE"], + "enum": [ + "CANCELLED", + "DONE", + "FATAL", + "IN_PROGRESS", + "IN_QUEUE" + ], "x-docgen-enum-table-extension": [ { "value": "CANCELLED", @@ -1865,7 +1523,11 @@ "CreateReportScheduleSpecification": { "type": "object", "description": "Information required to create the report schedule.", - "required": ["marketplaceIds", "period", "reportType"], + "required": [ + "marketplaceIds", + "period", + "reportType" + ], "properties": { "reportType": { "description": "The report type. Refer to [Report Type Values](https://developer-docs.amazon.com/sp-api/docs/report-type-values) for more information.", @@ -1991,7 +1653,10 @@ "CreateReportSpecification": { "type": "object", "description": "Information required to create the report.", - "required": ["marketplaceIds", "reportType"], + "required": [ + "marketplaceIds", + "reportType" + ], "properties": { "reportOptions": { "$ref": "#/definitions/ReportOptions" @@ -2031,7 +1696,11 @@ "ReportSchedule": { "description": "Detailed information about a report schedule.", "type": "object", - "required": ["period", "reportScheduleId", "reportType"], + "required": [ + "period", + "reportScheduleId", + "reportType" + ], "properties": { "reportScheduleId": { "description": "The identifier for the report schedule. This identifier is unique only in combination with a seller ID.", @@ -2065,7 +1734,9 @@ "ReportScheduleList": { "type": "object", "description": "A list of report schedules.", - "required": ["reportSchedules"], + "required": [ + "reportSchedules" + ], "properties": { "reportSchedules": { "type": "array", @@ -2079,7 +1750,9 @@ "CreateReportResponse": { "type": "object", "description": "The response schema.", - "required": ["reportId"], + "required": [ + "reportId" + ], "properties": { "reportId": { "description": "The identifier for the report. This identifier is unique only in combination with a seller ID.", @@ -2089,7 +1762,9 @@ }, "GetReportsResponse": { "type": "object", - "required": ["reports"], + "required": [ + "reports" + ], "properties": { "reports": { "description": "The reports.", @@ -2105,7 +1780,9 @@ "CreateReportScheduleResponse": { "type": "object", "description": "Response schema.", - "required": ["reportScheduleId"], + "required": [ + "reportScheduleId" + ], "properties": { "reportScheduleId": { "description": "The identifier for the report schedule. This identifier is unique only in combination with a seller ID.", @@ -2116,7 +1793,10 @@ "ReportDocument": { "type": "object", "description": "Information required for the report document.", - "required": ["reportDocumentId", "url"], + "required": [ + "reportDocumentId", + "url" + ], "properties": { "reportDocumentId": { "description": "The identifier for the report document. This identifier is unique only in combination with a seller ID.", @@ -2129,7 +1809,9 @@ "compressionAlgorithm": { "description": "If the report document contents have been compressed, the compression algorithm used is returned in this property and you must decompress the report when you download. Otherwise, you can download the report directly. Refer to [Step 2. Download the report](https://developer-docs.amazon.com/sp-api/docs/reports-api-v2021-06-30-retrieve-a-report#step-2-download-the-report) in the use case guide, where sample code is provided.", "type": "string", - "enum": ["GZIP"], + "enum": [ + "GZIP" + ], "x-docgen-enum-table-extension": [ { "value": "GZIP", diff --git a/local-ai-sandbox/res/notification-schemas/OrderChangeNotification.json b/local-ai-sandbox/res/notification-schemas/OrderChangeNotification.json new file mode 100644 index 000000000..c36326567 --- /dev/null +++ b/local-ai-sandbox/res/notification-schemas/OrderChangeNotification.json @@ -0,0 +1,705 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "http://example.com/example.json", + "type": "object", + "title": "The root schema", + "description": "The notification response schema that comprises the entire JSON document for ORDER_CHANGE notification.", + "examples": [ + { + "NotificationVersion" : "1.0", + "NotificationType" : "ORDER_CHANGE", + "PayloadVersion" : "1.0", + "EventTime" : "2020-01-11T00:09:53.109Z", + "Payload" : { + "OrderChangeNotification": { + "NotificationLevel": "OrderLevel", + "SellerId": "A3TH9S8BH6GOGM", + "AmazonOrderId": "903-8868176-2219830", + "OrderChangeType": "BuyerRequestedChange", + "OrderChangeTrigger": { + "TimeOfOrderChange": "2022-11-29T19:42:04.284Z", + "ChangeReason": "Buyer Requested Cancel" + }, + "Summary": { + "MarketplaceId": "ATVPDKIKX0DER", + "OrderStatus": "Unshipped", + "PurchaseDate": "2022-07-13T19:42:04.284Z", + "DestinationPostalCode": "48110", + "FulfillmentType": "MFN", + "OrderType": "StandardOrder", + "NumberOfItemsShipped": 0, + "NumberOfItemsUnshipped": 10, + "EarliestDeliveryDate": "2022-11-07T19:42:04.284Z", + "LatestDeliveryDate": "2022-12-07T19:42:04.284Z", + "EarliestShipDate": "2022-11-07T19:42:04.284Z", + "LatestShipDate": "2022-12-07T19:42:04.284Z", + "CancelNotifyDate": "2022-12-07T19:42:04.284Z", + "OrderPrograms": ["Business"], + "ShippingPrograms": ["EasyShip"], + "EasyShipShipmentStatus": "Delivered", + "ElectronicInvoiceStatus": "NotFound", + "OrderItems": [ + { + "OrderItemId": "OIID34853450", + "SellerSKU": "SellerSKUID1", + "SupplySourceId": "d7679e14-031b-4ab3-a81b-ec4fc7a460b3", + "OrderItemStatus": "Unshipped", + "Quantity": 10, + "QuantityShipped": 0, + "IsBuyerRequestedCancel": true, + "ItemEarliestDeliveryDate": "2022-11-07T19:42:04.284Z", + "ItemLatestDeliveryDate": "2022-12-07T19:42:04.284Z" + } + ] + } + } + }, + "NotificationMetadata" : { + "ApplicationId": "app-id-d0e9e693-c3ad-4373-979f-ed4ec98dd746", + "SubscriptionId": "subscription-id-d0e9e693-c3ad-4373-979f-ed4ec98dd746", + "PublishTime": "2020-07-13T19:42:04.284Z", + "NotificationId": "d0e9e693-c3ad-4373-979f-ed4ec98dd746" + } + } + ], + "required": [ + "NotificationVersion", + "NotificationType", + "PayloadVersion", + "EventTime", + "Payload", + "NotificationMetadata" + ], + "properties": { + "NotificationVersion": { + "$id": "#/properties/NotificationVersion", + "type": "string", + "title": "The NotificationVersion schema", + "description": "The notification version.", + "examples": [ + "1.0" + ] + }, + "NotificationType": { + "$id": "#/properties/NotificationType", + "type": "string", + "title": "The NotificationType schema", + "description": "The type of this notification, used to differentiate different notifications. Combined with payload version controls the structure of payload object.", + "examples": [ + "ORDER_CHANGE" + ] + }, + "PayloadVersion": { + "$id": "#/properties/PayloadVersion", + "type": "string", + "title": "The PayloadVersion schema", + "description": "The payload version of the notification.", + "examples": [ + "1.0" + ] + }, + "EventTime": { + "$id": "#/properties/EventTime", + "type": "string", + "title": "The EventTime schema", + "description": "The time when this notification was published, presented in ISO-8601 date/time format.", + "examples": [ + "2020-01-11T00:09:53.109Z" + ] + }, + "Payload": { + "$id": "#/properties/Payload", + "type": "object", + "title": "The Payload schema", + "description": "The payload for this ORDER_CHANGE notification. It's unique for different event type and will provide more in-depth information about this notification.", + "examples": [ + { + "OrderChangeNotification": { + "NotificationLevel": "OrderLevel", + "SellerId": "A3TH9S8BH6GOGM", + "AmazonOrderId": "903-8868176-2219830", + "OrderChangeType": "BuyerRequestedChange", + "OrderChangeTrigger": { + "TimeOfOrderChange": "2022-11-29T19:42:04.284Z", + "ChangeReason": "Buyer Requested Cancel" + }, + "Summary": { + "MarketplaceId": "ATVPDKIKX0DER", + "OrderStatus": "Unshipped", + "PurchaseDate": "2022-07-13T19:42:04.284Z", + "DestinationPostalCode": "48110", + "FulfillmentType": "MFN", + "OrderType": "StandardOrder", + "NumberOfItemsShipped": 0, + "NumberOfItemsUnshipped": 10, + "EarliestDeliveryDate": "2022-11-07T19:42:04.284Z", + "LatestDeliveryDate": "2022-12-07T19:42:04.284Z", + "EarliestShipDate": "2022-11-07T19:42:04.284Z", + "LatestShipDate": "2022-12-07T19:42:04.284Z", + "CancelNotifyDate": "2022-12-07T19:42:04.284Z", + "OrderPrograms": ["Business"], + "ShippingPrograms": ["EasyShip"], + "EasyShipShipmentStatus": "Delivered", + "ElectronicInvoiceStatus": "NotFound", + "OrderItems": [ + { + "OrderItemId": "OIID34853450", + "SellerSKU": "SellerSKUID1", + "SupplySourceId": "d7679e14-031b-4ab3-a81b-ec4fc7a460b3", + "OrderItemStatus": "Unshipped", + "Quantity": 10, + "QuantityShipped": 0, + "IsBuyerRequestedCancel": true, + "ItemEarliestDeliveryDate": "2022-11-07T19:42:04.284Z", + "ItemLatestDeliveryDate": "2022-12-07T19:42:04.284Z" + } + ] + } + } + } + ], + "required": [ + "OrderChangeNotification" + ], + "properties": { + "OrderChangeNotification": { + "$id": "#/properties/Payload/properties/OrderChangeNotification", + "type": "object", + "title": "The OrderChangeNotification schema", + "description": "An explanation about the ORDER_CHANGE notification.", + "examples": [ + { + "NotificationLevel": "OrderLevel", + "SellerId": "A3TH9S8BH6GOGM", + "AmazonOrderId": "903-8868176-2219830", + "OrderChangeType": "BuyerRequestedChange", + "OrderChangeTrigger": { + "TimeOfOrderChange": "2022-11-29T19:42:04.284Z", + "ChangeReason": "Buyer Requested Cancel" + }, + "Summary": { + "MarketplaceId": "ATVPDKIKX0DER", + "OrderStatus": "Unshipped", + "PurchaseDate": "2022-07-13T19:42:04.284Z", + "DestinationPostalCode": "48110", + "FulfillmentType": "MFN", + "OrderType": "StandardOrder", + "NumberOfItemsShipped": 0, + "NumberOfItemsUnshipped": 10, + "EarliestDeliveryDate": "2022-11-07T19:42:04.284Z", + "LatestDeliveryDate": "2022-12-07T19:42:04.284Z", + "EarliestShipDate": "2022-11-07T19:42:04.284Z", + "LatestShipDate": "2022-12-07T19:42:04.284Z", + "CancelNotifyDate": "2022-12-07T19:42:04.284Z", + "OrderPrograms": ["Business"], + "ShippingPrograms": ["EasyShip"], + "EasyShipShipmentStatus": "Delivered", + "ElectronicInvoiceStatus": "NotFound", + "OrderItems": [ + { + "OrderItemId": "OIID34853450", + "SellerSKU": "SellerSKUID1", + "SupplySourceId": "d7679e14-031b-4ab3-a81b-ec4fc7a460b3", + "OrderItemStatus": "Unshipped", + "Quantity": 10, + "QuantityShipped": 0, + "IsBuyerRequestedCancel": true, + "ItemEarliestDeliveryDate": "2022-11-07T19:42:04.284Z", + "ItemLatestDeliveryDate": "2022-12-07T19:42:04.284Z" + } + ] + } + } + ], + "required": [ + "NotificationLevel", + "SellerId", + "AmazonOrderId", + "OrderChangeType", + "OrderChangeTrigger", + "Summary" + ], + "properties": { + "NotificationLevel": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/NotificationLevel", + "type": "string", + "enum": ["OrderItemLevel", "OrderLevel"], + "title": "The NotificationLevel schema", + "description": "The notification level of current notification.", + "examples": [ + "OrderLevel" + ] + }, + "SellerId": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/SellerId", + "type": "string", + "title": "The SellerId schema", + "description": "The selling partner identifier.", + "examples": [ + "AXXXXXXXXXXXXX" + ] + }, + "AmazonOrderId": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/AmazonOrderId", + "type": "string", + "title": "The AmazonOrderId schema", + "description": "The Amazon order identifier, in 3-7-7 format.", + "examples": [ + "903-8868176-2219830" + ] + }, + "OrderChangeType": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/OrderChangeType", + "type": "string", + "enum": ["BuyerRequestedChange", "DeliveryTipChange", "OrderStatusChange"], + "title": "The OrderChangeType schema", + "description": "Change type of this notification. The possible values include BuyerRequestedChange, DeliveryTipChange, OrderStatusChange.", + "examples": [ + "BuyerRequestedChange" + ] + }, + "OrderChangeTrigger": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/OrderChangeTrigger", + "type": "object", + "title": "The OrderChangeTrigger schema", + "description": "Details about what triggered this ORDER_CHANGE notification.", + "examples": [ + { + "TimeOfOrderChange": "2022-11-29T19:42:04.284Z", + "ChangeReason": "Buyer Requested Cancel" + } + ], + "required": [ + "TimeOfOrderChange", + "ChangeReason" + ], + "properties": { + "TimeOfOrderChange": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/OrderChangeTrigger/properties/TimeOfOrderChange", + "type": ["string", "null"], + "title": "The TimeOfOrderChange schema", + "description": "The timestamp for the change that caused this notification, presented in ISO-8601 date/time format. It will be null when there is no related timestamp.", + "examples": [ + "2022-11-29T19:42:04.284Z" + ] + }, + "ChangeReason": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/OrderChangeTrigger/properties/ChangeReason", + "type": "string", + "title": "The ChangeReason schema", + "description": "The reason for this ORDER_CHANGE notification.", + "examples": [ + "Buyer Requested Cancel" + ] + } + } + }, + "Summary": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary", + "type": "object", + "title": "The Summary schema", + "description": "Information about order and order items that had the change.", + "examples": [ + { + "MarketplaceId": "ATVPDKIKX0DER", + "OrderStatus": "Unshipped", + "PurchaseDate": "2022-07-13T19:42:04.284Z", + "DestinationPostalCode": "48110", + "FulfillmentType": "MFN", + "OrderType": "StandardOrder", + "NumberOfItemsShipped": 0, + "NumberOfItemsUnshipped": 10, + "EarliestDeliveryDate": "2022-11-07T19:42:04.284Z", + "LatestDeliveryDate": "2022-12-07T19:42:04.284Z", + "EarliestShipDate": "2022-11-07T19:42:04.284Z", + "LatestShipDate": "2022-12-07T19:42:04.284Z", + "CancelNotifyDate": "2022-12-07T19:42:04.284Z", + "OrderPrograms": ["Business"], + "ShippingPrograms": ["EasyShip"], + "EasyShipShipmentStatus": "Delivered", + "ElectronicInvoiceStatus": "NotFound", + "OrderItems": [ + { + "OrderItemId": "OIID34853450", + "SellerSKU": "SellerSKUID1", + "OrderItemStatus": "Unshipped", + "Quantity": 10, + "QuantityShipped": 0, + "IsBuyerRequestedCancel": true, + "ItemEarliestDeliveryDate": "2022-11-07T19:42:04.284Z", + "ItemLatestDeliveryDate": "2022-12-07T19:42:04.284Z" + } + ] + } + ], + "required": [ + "MarketplaceId", + "OrderStatus", + "PurchaseDate", + "DestinationPostalCode", + "FulfillmentType", + "OrderType", + "OrderItems" + ], + "properties": { + "MarketplaceId": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/MarketplaceId", + "type": "string", + "title": "The MarketplaceId schema", + "description": "The Amazon marketplace identifier of the order.", + "examples": [ + "ATVPDKIKX0DER" + ] + }, + "OrderStatus": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderStatus", + "type": "string", + "enum": ["Pending", "Unshipped", "PartiallyShipped", "Shipped", "Canceled", "Unfulfillable", "InvoiceUnconfirmed", "PendingAvailability"], + "title": "The OrderStatus schema", + "description": "The current order status.", + "examples": [ + "Unshipped" + ] + }, + "PurchaseDate": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/PurchaseDate", + "type": ["string", "null"], + "title": "The PurchaseDate schema", + "description": "The purchase date of the order, presented in ISO-8601 date/time format. It will be null when there is no related information.", + "examples": [ + "2022-07-13T19:42:04.284Z" + ] + }, + "DestinationPostalCode": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/DestinationPostalCode", + "type": ["string", "null"], + "title": "The DestinationPostalCode schema", + "description": "The destination postal code. It will be null when there is no related information.", + "examples": [ + "48110" + ] + }, + "FulfillmentType": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/FulfillmentType", + "type": "string", + "enum": ["AFN", "MFN"], + "title": "The FulfillmentType schema", + "description": "Fulfillment type of the affected order, MFN or AFN.", + "examples": [ + "MFN" + ] + }, + "OrderType": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderType", + "type": "string", + "enum": ["StandardOrder", "LongLeadTimeOrder", "Preorder", "BackOrder", "SourcingOnDemandOrder"], + "title": "The OrderType schema", + "description": "The type of the order.", + "examples": [ + "StandardOrder" + ] + }, + "NumberOfItemsShipped": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/NumberOfItemsShipped", + "type": "integer", + "title": "The NumberOfItemsShipped schema", + "description": "The number of items shipped.", + "examples": [ + 0 + ] + }, + "NumberOfItemsUnshipped": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/NumberOfItemsUnshipped", + "type": "integer", + "title": "The NumberOfItemsUnshipped schema", + "description": "The number of items unshipped.", + "examples": [ + 10 + ] + }, + "EarliestDeliveryDate": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/EarliestDeliveryDate", + "type": "string", + "title": "The EarliestDeliveryDate schema", + "description": "The start of the time period within which you have committed to fulfill the order, presented in ISO-8601 date/time format. Returned only for seller-fulfilled orders.", + "examples": [ + "2022-11-07T19:42:04.284Z" + ] + }, + "LatestDeliveryDate": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/LatestDeliveryDate", + "type": "string", + "title": "The LatestDeliveryDate schema", + "description": "The end of the time period within which you have committed to fulfill the order, presented in ISO-8601 date/time format. Returned only for seller-fulfilled orders that do not have a PendingAvailability, Pending, or Canceled status.", + "examples": [ + "2022-12-07T19:42:04.284Z" + ] + }, + "EarliestShipDate": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/EarliestShipDate", + "type": "string", + "title": "The EarliestShipDate schema", + "description": "The start of the time period within which you have committed to ship the order, presented in ISO-8601 date/time format.", + "examples": [ + "2022-11-07T19:42:04.284Z" + ] + }, + "LatestShipDate": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/LatestShipDate", + "type": "string", + "title": "The LatestShipDate schema", + "description": "The end of the time period within which you have committed to ship the order, presented in ISO-8601 date/time format.", + "examples": [ + "2022-12-07T19:42:04.284Z" + ] + }, + "CancelNotifyDate": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/CancelNotifyDate", + "type": "string", + "title": "The CancelNotifyDate schema", + "description": "The end of the time period which you cancel notify for the order, presented in ISO-8601 date/time format.", + "examples": [ + "2022-12-07T19:42:04.284Z" + ] + }, + "OrderPrograms": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderPrograms", + "type": "array", + "title": "The OrderPrograms schema", + "description": "The order programs, if any, in which this order participates.", + "items": { + "type": "string", + "enum": ["Business", "Prime", "Premium", "IBA", "Replacement"] + }, + "examples": [ + ["Business"] + ] + }, + "ShippingPrograms": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/ShippingPrograms", + "type": "array", + "title": "The ShippingPrograms schema", + "description": "The shipping programs, if any, in which this order participates.", + "items": { + "type": "string", + "enum": ["ShipDateSet", "GlobalExpress", "ISPU", "AccessPoint", "TFM", "EasyShip"] + }, + "examples": [ + ["EasyShip"] + ] + }, + "EasyShipShipmentStatus": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/EasyShipShipmentStatus", + "type": "string", + "enum": ["PendingSchedule", "PendingPickUp", "PendingDropOff", "LabelCanceled", "PickedUp", "DroppedOff", "AtOriginFC", "AtDestinationFC", "Delivered", "RejectedByBuyer", "Undeliverable", "ReturningToSeller", "ReturnedToSeller", "Lost", "OutForDelivery", "Damaged"], + "title": "The EasyShipShipmentStatus schema", + "description": "The status of the Amazon Easy Ship order. This property is included only for Amazon Easy Ship orders.", + "examples": [ + "Delivered" + ] + }, + "ElectronicInvoiceStatus": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/ElectronicInvoiceStatus", + "type": "string", + "enum": ["NotRequired", "NotFound", "Processing", "Errored", "Accepted"], + "title": "The ElectronicInvoiceStatus schema", + "description": "The status of the electronic invoice.", + "examples": [ + "NotFound" + ] + }, + "OrderItems": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderItems", + "type": "array", + "title": "The OrderItems schema", + "description": "Information about order items included in this order. For OrderItemLevel notification, one payload include one item, while for OrderLevel notification, one payload include all items.", + "examples": [ + [ + { + "OrderItemId": "OIID34853450", + "SellerSKU": "SellerSKUID1", + "SupplySourceId": "d7679e14-031b-4ab3-a81b-ec4fc7a460b3", + "OrderItemStatus": "Unshipped", + "Quantity": 10, + "QuantityShipped": 0, + "IsBuyerRequestedCancel": true, + "ItemEarliestDeliveryDate": "2022-11-07T19:42:04.284Z", + "ItemLatestDeliveryDate": "2022-12-07T19:42:04.284Z" + } + ] + ], + "items": { + "type": "object", + "title": "The OrderItem schema", + "description": "Information about every order item.", + "examples": [ + { + "OrderItemId": "OIID34853450", + "SellerSKU": "SellerSKUID1", + "SupplySourceId": "d7679e14-031b-4ab3-a81b-ec4fc7a460b3", + "OrderItemStatus": "Unshipped", + "Quantity": 10, + "QuantityShipped": 0, + "IsBuyerRequestedCancel": true, + "ItemEarliestDeliveryDate": "2022-11-07T19:42:04.284Z", + "ItemLatestDeliveryDate": "2022-12-07T19:42:04.284Z" + } + ], + "required": [ + "OrderItemId", + "SellerSKU", + "SupplySourceId", + "Quantity" + ], + "properties": { + "OrderItemId": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderItems/OrderItemId", + "type": "string", + "title": "The OrderItemId schema", + "description": "The Amazon-defined order item identifier.", + "examples": [ + "OIID34853450" + ] + }, + "SellerSKU": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderItems/SellerSKU", + "type": "string", + "title": "The SellerSKU schema", + "description": "The seller-specific SKU identifier for an item.", + "examples": [ + "SellerSKUID1" + ] + }, + "SupplySourceId": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderItems/SupplySourceId", + "type": ["string", "null"], + "title": "The SupplySourceId schema", + "description": "The unique identifier of the supply source. It will be null when there is no related information.", + "examples": [ + "d7679e14-031b-4ab3-a81b-ec4fc7a460b3" + ] + }, + "OrderItemStatus": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderItems/OrderItemStatus", + "type": "string", + "enum": ["Unshipped", "Shipped"], + "title": "The OrderItemStatus schema", + "description": "The current status of the order item. Will display it when items' status are different.", + "examples": [ + "Unshipped" + ] + }, + "Quantity": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderItems/Quantity", + "type": "integer", + "title": "The Quantity schema", + "description": "The number of items in the order.", + "examples": [ + 10 + ] + }, + "QuantityShipped": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderItems/QuantityShipped", + "type": "integer", + "title": "The QuantityShipped schema", + "description": "The number of items shipped.", + "examples": [ + 0 + ] + }, + "IsBuyerRequestedCancel": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderItems/IsBuyerRequestedCancel", + "type": "boolean", + "title": "The IsBuyerRequestedCancel schema", + "description": "Information about whether or not a buyer requested cancellation. When true, the buyer has requested cancellation.", + "examples": [ + true + ] + }, + "ItemEarliestDeliveryDate": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderItems/ItemEarliestDeliveryDate", + "type": "string", + "title": "The ItemEarliestDeliveryDate schema", + "description": "The start of the time period within which you have committed to fulfill the order item.", + "examples": [ + "2022-11-07T19:42:04.284Z" + ] + }, + "ItemLatestDeliveryDate": { + "$id": "#/properties/Payload/properties/OrderChangeNotification/properties/Summary/properties/OrderItems/ItemLatestDeliveryDate", + "type": "string", + "title": "The ItemLatestDeliveryDate schema", + "description": "The end of the time period within which you have committed to fulfill the order item.", + "examples": [ + "2022-12-07T19:42:04.284Z" + ] + } + } + } + } + } + } + } + } + } + }, + "NotificationMetadata": { + "$id": "#/properties/NotificationMetadata", + "type": "object", + "title": "The NotificationMetadata schema", + "description": "The notification metadata.", + "examples": [ + { + "ApplicationId": "app-id-d0e9e693-c3ad-4373-979f-ed4ec98dd746", + "SubscriptionId": "subscription-id-d0e9e693-c3ad-4373-979f-ed4ec98dd746", + "PublishTime": "2020-07-13T19:42:04.284Z", + "NotificationId": "d0e9e693-c3ad-4373-979f-ed4ec98dd746" + } + ], + "required": [ + "ApplicationId", + "SubscriptionId", + "PublishTime", + "NotificationId" + ], + "properties": { + "ApplicationId": { + "$id": "#/properties/NotificationMetadata/properties/ApplicationId", + "type": "string", + "title": "The ApplicationId schema", + "description": "The identifier for the application that uses the notifications.", + "examples": [ + "app-id-d0e9e693-c3ad-4373-979f-ed4ec98dd746" + ] + }, + "SubscriptionId": { + "$id": "#/properties/NotificationMetadata/properties/SubscriptionId", + "type": "string", + "title": "The SubscriptionId schema", + "description": "A unique identifier for the subscription which resulted in this notification.", + "examples": [ + "subscription-id-d0e9e693-c3ad-4373-979f-ed4ec98dd746" + ] + }, + "PublishTime": { + "$id": "#/properties/NotificationMetadata/properties/PublishTime", + "type": "string", + "title": "The PublishTime schema", + "description": "The date and time (in UTC) that the notification was sent, presented in ISO-8601 date/time format.", + "examples": [ + "2020-07-13T19:42:04.284Z" + ] + }, + "NotificationId": { + "$id": "#/properties/NotificationMetadata/properties/NotificationId", + "type": "string", + "title": "The NotificationId schema", + "description": "A unique identifier for this notification instance.", + "examples": [ + "d0e9e693-c3ad-4373-979f-ed4ec98dd746" + ] + } + } + } + } +} diff --git a/local-ai-sandbox/res/response/invoice.pdf b/local-ai-sandbox/res/response/invoice.pdf new file mode 100644 index 0000000000000000000000000000000000000000..72e78c7a126cb29480401dcb0cfc6f3a06ef9326 GIT binary patch literal 247007 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

VvMCLf-E7&X)aZEXuw`3Ju)%s>C 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

    <6EK9* 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�gYUP0{7%eKku`8Q5jmbT)uh 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 Ul1LGUyATBN3w3p^)>$w7FEBD0X8-^I literal 0 HcmV?d00001 diff --git a/local-ai-sandbox/res/response/label.png b/local-ai-sandbox/res/response/label.png new file mode 100644 index 0000000000000000000000000000000000000000..4d2a6ca9ace6b57e3513c8ccd17c063a3fed7326 GIT binary patch literal 149788 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�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 + When a new order is placed with status PENDING, reduce the + listing quantity for each order item's SKU by the quantity + ordered. This simulates real-world inventory deduction when + a customer purchases an item. + on: + api: orders + event: [INSERT] + condition: + path: "$.fulfillment.fulfillmentStatus" + equals: "PENDING" + handler: reduceInventoryOnOrderPlaced + + - name: Send ORDER_CHANGE notification on status change + description: > + When an order's fulfillment status is updated, sends an ORDER_CHANGE + notification to the SQS queue configured in the matching subscription. + Does nothing if no ORDER_CHANGE subscription exists. This simulates + the real SP-API notification system where sellers subscribe to order + change events. + on: + api: orders + event: [UPDATE] + condition: null + handler: sendOrderChangeNotification + + listings: + - name: Process listing submission + description: > + Catalog matching, the sandbox analogue of Amazon's asynchronous + processing after a submission. A full submission (LISTING / + LISTING_PRODUCT_ONLY) for an unknown product gets a catalog item + created for its ASIN. Offer-only matching an existing item needs + nothing; matching nothing is tagged 4005015 when a + merchant_suggested_asin was given, else 8560. Listing attributes are + never modified. Idempotent: re-running reconciles rather than appends, + so correcting the listing clears the issue. Fires on every listings + write. + on: + api: listings + event: [INSERT, UPDATE] + condition: null + handler: processListingSubmission + diff --git a/local-ai-sandbox/scripts/config/apiRegistrationConfig.ts b/local-ai-sandbox/scripts/config/apiRegistrationConfig.ts new file mode 100644 index 000000000..cff64f9b7 --- /dev/null +++ b/local-ai-sandbox/scripts/config/apiRegistrationConfig.ts @@ -0,0 +1,148 @@ +/** + * API registration policy — the single place that governs which models are imported, + * how their display names / database namespaces are derived, and what is excluded. + * + * Consumed by both scripts/fetchModels.ts (copy stage) and + * scripts/generateOperationRegistry.ts (extraction stage). + */ + +export interface ApiMetadataOverride { + /** Canonical display name, e.g. "Product Pricing". Must match the validation registry's apiName. */ + apiName?: string; + /** Database partition key; must correspond to a member of the `Api` enum. */ + dbNamespace?: string; + /** Overrides the model file path returned by the resource-retrieval tool (Listings → Product Type Definition). */ + resourcePath?: string; +} + +/** + * Per-model overrides, ONLY for models where the derived apiName/dbNamespace would be wrong. + * `apiVersion` always comes from the model's `info.version`. A model with correct derivation needs + * no entry (e.g. Orders and Reports derive correctly and are intentionally absent here). + * + * Two reasons an override is needed: + * - dbNamespace: the `Api` enum uses abbreviated partition keys (`catalog`, `inventory`, + * `extFulfillment*`) that the display-name slug cannot produce (`catalogItems`, `fbaInventory`, + * `externalFulfillment*`). + * - apiName: a couple of titles don't match the canonical name — "Pricing" -> "Product Pricing", + * "…Return Item" -> "External Fulfillment Returns". Listings also needs a resourcePath override. + */ +export const API_METADATA_OVERRIDES: Record = { + "listingsItems_2021-08-01.json": { apiName: "Listings", dbNamespace: "listings", resourcePath: "./res/pt-definitions/PRODUCT.json" }, + "catalogItems_2022-04-01.json": { apiName: "Catalog Items", dbNamespace: "catalog" }, + "productPricing_2022-05-01.json": { apiName: "Product Pricing", dbNamespace: "pricing" }, + "fbaInventory.json": { apiName: "FBA Inventory", dbNamespace: "inventory" }, + "externalFulfillmentInventory_2024-09-11.json": { apiName: "External Fulfillment Inventory", dbNamespace: "extFulfillmentInventory" }, + "externalFulfillmentReturns_2024-09-11.json": { apiName: "External Fulfillment Returns", dbNamespace: "extFulfillmentReturns" }, + "externalFulfillmentShipments_2024-09-11.json": { apiName: "External Fulfillment Shipments", dbNamespace: "extFulfillmentShipments" }, +}; + +export interface ExcludeListEntry { + apiName?: string; + apiVersion?: string; + modelFile?: string; + /** + * When present, only these operationIds are excluded (the rest of the model is still registered). + * When absent, the whole matching model/API is excluded. + */ + operationIds?: string[]; + reason: string; +} + +/** + * APIs (or specific operations) excluded from the registry. A rule matches on any combination of + * apiName/apiVersion/modelFile that is present. Deprecated operations (schema `deprecated: true`) + * are dropped automatically by the generator and do not need an entry here. + */ +export const EXCLUDE_LIST: ExcludeListEntry[] = [ + // NOTE: ordersV0.json is intentionally NOT excluded — it hosts the live confirmShipment + // operation (absent from Orders 2026-01-01). + { apiName: "Catalog Items", apiVersion: "v0", reason: "Superseded by Catalog Items 2022-04-01." }, + { apiName: "Catalog Items", apiVersion: "2020-12-01", reason: "Superseded by Catalog Items 2022-04-01." }, + { modelFile: "listingsItems_2020-09-01.json", reason: "Superseded by Listings Items 2021-08-01." }, + { apiName: "Fulfillment By Amazon (Small and Light)", reason: "Deprecated program." }, + { modelFile: "productPricingV0.json", reason: "Superseded by Product Pricing 2022-05-01." }, +]; + +/** + * Upstream model folders (in amzn/selling-partner-api-models `models/`) eligible for the fetch + * script to copy. This is a temporary guard so we don't import the entire SP-API surface before we + * have handlers for it. Leave it EMPTY to import everything (minus the exclude list) — the intended + * end state as coverage approaches full parity, at which point the exclude list is the only gate. + */ +export const ALLOWLIST: string[] = [ + "orders-api-model", + "listings-items-api-model", + "catalog-items-api-model", + "product-pricing-api-model", + "fba-inventory-api-model", + "external-fulfillment-inventory-api-model", + "external-fulfillment-returns-api-model", + "external-fulfillment-shipments-api-model", + "reports-api-model", + "product-type-definitions-api-model", + "listings-restrictions-api-model", + "notifications-api-model", + "data-kiosk-api-model", +]; + +/** Facts an exclude-list rule can match against. */ +export interface ApiIdentity { + apiName?: string; + apiVersion?: string; + modelFile?: string; +} + +/** Whether a rule's identity fields match the given API. Requires at least one identity field so a reason-only rule matches nothing. */ +function matchesIdentity(rule: ExcludeListEntry, identity: ApiIdentity): boolean { + const hasIdentityField = rule.modelFile !== undefined || rule.apiName !== undefined || rule.apiVersion !== undefined; + return ( + hasIdentityField && + (rule.modelFile === undefined || rule.modelFile === identity.modelFile) && + (rule.apiName === undefined || rule.apiName === identity.apiName) && + (rule.apiVersion === undefined || rule.apiVersion === identity.apiVersion) + ); +} + +/** True if the whole model/API is excluded (a matching rule that is not scoped to specific operations). */ +export function isExcluded(identity: ApiIdentity): boolean { + return EXCLUDE_LIST.some((rule) => matchesIdentity(rule, identity) && rule.operationIds === undefined); +} + +/** True if this specific operation is excluded (a matching rule scoped to operationIds that includes it). */ +export function isOperationExcluded(identity: ApiIdentity, operationId: string): boolean { + return EXCLUDE_LIST.some((rule) => matchesIdentity(rule, identity) && rule.operationIds?.includes(operationId) === true); +} + +/** Safe lookup for a model's overrides (Record index does not model the missing-key case without noUncheckedIndexedAccess). */ +export function overrideFor(modelFile: string): ApiMetadataOverride | undefined { + return Object.prototype.hasOwnProperty.call(API_METADATA_OVERRIDES, modelFile) ? API_METADATA_OVERRIDES[modelFile] : undefined; +} + +/** + * Fallback display name for a model with no override: strip common SP-API title boilerplate. + * e.g. "The Selling Partner API for Amazon External Fulfillment Shipments Processing" → "External Fulfillment Shipments". + */ +export function deriveApiName(title: string | undefined, modelFile: string): string { + const override = overrideFor(modelFile)?.apiName; + if (override) return override; + if (!title) return modelFile.replace(/\.json$/, ""); + return ( + title + .replace(/^The\s+/i, "") + .replace(/Selling Partner API for\s+/i, "") + .replace(/^Amazon\s+/i, "") + .replace(/\s+(Processing|Management)$/i, "") + .trim() || modelFile.replace(/\.json$/, "") + ); +} + +/** Fallback database namespace for a model with no override: lowerCamel slug of the display name. */ +export function deriveDbNamespace(apiName: string, modelFile: string): string { + const override = overrideFor(modelFile)?.dbNamespace; + if (override) return override; + const words = apiName.split(/\s+/).filter(Boolean); + return words + .map((w, i) => (i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())) + .join(""); +} diff --git a/local-ai-sandbox/scripts/config/notificationSchemasConfig.ts b/local-ai-sandbox/scripts/config/notificationSchemasConfig.ts new file mode 100644 index 000000000..96d0f67b8 --- /dev/null +++ b/local-ai-sandbox/scripts/config/notificationSchemasConfig.ts @@ -0,0 +1,9 @@ +/** + * Allowlist of notification type identifiers to download. + * Each entry corresponds to a filename (without `.json` extension) in the upstream + * `schemas/notifications/` directory of the `amzn/selling-partner-api-models` repository. + * + * Consumed by `scripts/fetchNotificationSchemas.ts` to filter which schemas are copied + * into `res/notification-schemas/`. + */ +export const NOTIFICATION_SCHEMA_ALLOWLIST: string[] = ["OrderChangeNotification"]; diff --git a/local-ai-sandbox/scripts/fetchModels.ts b/local-ai-sandbox/scripts/fetchModels.ts new file mode 100644 index 000000000..c5aa768a3 --- /dev/null +++ b/local-ai-sandbox/scripts/fetchModels.ts @@ -0,0 +1,108 @@ +/** + * Copies SP-API OpenAPI model files from the upstream GitHub repository into res/models/, + * filtered by the allowlist/exclude list and sanitized (sandbox-only content removed). + * + * Usage: + * tsx scripts/fetchModels.ts # fetch from the default ref (main) + * tsx scripts/fetchModels.ts --ref v1.2.3 # fetch from a pinned tag/commit for reproducibility + * + * After running, regenerate the registry: `npm run registry:generate` (or use `npm run models:sync`). + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; +import { ALLOWLIST, isExcluded, deriveApiName } from "./config/apiRegistrationConfig.js"; + +const UPSTREAM_REPO = "https://github.com/amzn/selling-partner-api-models.git"; +const UPSTREAM_MODELS_SUBDIR = "models"; +const DEST_DIR = "res/models"; + +/** The subset of a Swagger 2.0 model this script reads. */ +interface SwaggerModel { + info?: { version?: string; title?: string }; +} + +/** + * Recursively removes every `examples` and `x-amzn-api-sandbox` property at any depth. + * `example` (singular) is intentionally preserved (openapi-enforcer suppresses those separately). + * Pure and idempotent: returns a new structure, mutates nothing. + */ +export function sanitizeModel(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sanitizeModel); + } + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const [key, v] of Object.entries(value)) { + if (key === "examples" || key === "x-amzn-api-sandbox") continue; + out[key] = sanitizeModel(v); + } + return out; + } + return value; +} + +function argRef(): string { + const i = process.argv.indexOf("--ref"); + return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : "main"; +} + +/** Shallow, sparse clone of only the upstream models directory into a temp dir; returns its path. */ +function sparseCloneModels(ref: string): string { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sp-api-models-")); + const git = (args: string[]) => execFileSync("git", args, { cwd: tmp, stdio: "pipe" }); + execFileSync("git", ["clone", "--depth", "1", "--filter=blob:none", "--sparse", "--branch", ref, UPSTREAM_REPO, tmp], { stdio: "pipe" }); + git(["sparse-checkout", "set", UPSTREAM_MODELS_SUBDIR]); + return path.join(tmp, UPSTREAM_MODELS_SUBDIR); +} + +/** + * All *.json model files under the allowlisted upstream folders. An empty ALLOWLIST means "all + * folders" (exclude-list-only mode). + */ +function allowlistedModelFiles(modelsRoot: string): { folder: string; file: string; absPath: string }[] { + const results: { folder: string; file: string; absPath: string }[] = []; + const folders = + ALLOWLIST.length > 0 ? ALLOWLIST : fs.readdirSync(modelsRoot).filter((f) => fs.statSync(path.join(modelsRoot, f)).isDirectory()); + for (const folder of folders) { + const dir = path.join(modelsRoot, folder); + if (!fs.existsSync(dir)) { + console.warn(`[models:fetch] allowlisted folder not found upstream: ${folder}`); + continue; + } + for (const file of fs.readdirSync(dir)) { + if (file.endsWith(".json")) results.push({ folder, file, absPath: path.join(dir, file) }); + } + } + return results; +} + +function main(): void { + const ref = argRef(); + console.log(`[models:fetch] cloning ${UPSTREAM_REPO} @ ${ref} (sparse: ${UPSTREAM_MODELS_SUBDIR})`); + const modelsRoot = sparseCloneModels(ref); + fs.mkdirSync(DEST_DIR, { recursive: true }); + + let copied = 0; + let skipped = 0; + for (const { file, absPath } of allowlistedModelFiles(modelsRoot)) { + const raw = JSON.parse(fs.readFileSync(absPath, "utf8")) as SwaggerModel; + const apiName = deriveApiName(raw.info?.title, file); + const apiVersion: string = raw.info?.version ?? ""; + if (isExcluded({ apiName, apiVersion, modelFile: file })) { + skipped++; + continue; + } + const sanitized = sanitizeModel(raw); + fs.writeFileSync(path.join(DEST_DIR, file), JSON.stringify(sanitized, null, 2) + "\n", "utf8"); + copied++; + console.log(`[models:fetch] ${apiName} ${apiVersion} → ${file}`); + } + + console.log(`[models:fetch] done: ${String(copied)} copied, ${String(skipped)} skipped (excluded). Run "npm run registry:generate" next.`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/local-ai-sandbox/scripts/fetchNotificationSchemas.ts b/local-ai-sandbox/scripts/fetchNotificationSchemas.ts new file mode 100644 index 000000000..31b1ff8c4 --- /dev/null +++ b/local-ai-sandbox/scripts/fetchNotificationSchemas.ts @@ -0,0 +1,153 @@ +/** + * Downloads notification event/payload JSON schema files from the upstream + * `amzn/selling-partner-api-models` GitHub repository into `res/notification-schemas/`, + * filtered by the allowlist in `scripts/config/notificationSchemasConfig.ts`. + * + * Always fetches from the `main` branch of the upstream repository. + * + * Usage: + * tsx scripts/fetchNotificationSchemas.ts + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; +import { NOTIFICATION_SCHEMA_ALLOWLIST } from "./config/notificationSchemasConfig.js"; + +/** + * Git ref that notification schemas are always fetched from. + */ +export const NOTIFICATION_SCHEMAS_REF = "main"; + +/** + * Derives the notification type identifier from a schema filename. + * Strips the `.json` extension from the filename. + */ +export function deriveNotificationType(filename: string): string { + return filename.replace(/\.json$/, ""); +} + +/** + * Filters upstream schema files against the allowlist. + * Returns matched files (type → absolute path) and allowlist entries with no match. + */ +export function filterSchemas(schemasDir: string, allowlist: string[]): { toCopy: Map; missing: string[] } { + const files = fs.readdirSync(schemasDir).filter((f) => f.endsWith(".json")); + const availableTypes = new Map(); + for (const file of files) { + const type = deriveNotificationType(file); + availableTypes.set(type, path.join(schemasDir, file)); + } + + const toCopy = new Map(); + const missing: string[] = []; + + for (const entry of allowlist) { + const absPath = availableTypes.get(entry); + if (absPath) { + toCopy.set(entry, absPath); + } else { + missing.push(entry); + } + } + + return { toCopy, missing }; +} + +const UPSTREAM_REPO = "https://github.com/amzn/selling-partner-api-models.git"; +const UPSTREAM_SCHEMAS_SUBDIR = "schemas/notifications"; + +/** + * Performs a shallow sparse clone of the upstream repository, checking out only the + * `schemas/notifications/` subdirectory into an OS temp directory. + * + * Always clones the `main` branch (see `NOTIFICATION_SCHEMAS_REF`). + * + * The caller owns `tmpRoot` (typically created via `fs.mkdtempSync`) and is + * responsible for cleaning it up, so the directory is removed even when the + * clone fails partway through. + * + * @param tmpRoot The caller-owned temp directory to clone into. + * @returns The resolved path to the `schemas/notifications/` directory within `tmpRoot`. + */ +export function sparseCloneNotificationSchemas(tmpRoot: string): string { + const git = (args: string[]) => execFileSync("git", args, { cwd: tmpRoot, stdio: "pipe" }); + execFileSync( + "git", + ["clone", "--depth", "1", "--filter=blob:none", "--sparse", "--branch", NOTIFICATION_SCHEMAS_REF, UPSTREAM_REPO, tmpRoot], + { stdio: "pipe" }, + ); + git(["sparse-checkout", "set", UPSTREAM_SCHEMAS_SUBDIR]); + return path.join(tmpRoot, "schemas", "notifications"); +} + +const DEST_DIR = "res/notification-schemas"; + +/** + * Orchestrates the full notification schema fetch pipeline. + * Entry point when run as a script. + */ +export function main(): void { + console.log(`[notifications:fetch] cloning ${UPSTREAM_REPO} @ ${NOTIFICATION_SCHEMAS_REF} (sparse: ${UPSTREAM_SCHEMAS_SUBDIR})`); + + if (NOTIFICATION_SCHEMA_ALLOWLIST.length === 0) { + console.log("[notifications:fetch] No notification types configured — skipping."); + return; + } + + // Create the temp dir here so the finally block always owns the handle and can + // clean it up even when the clone throws partway through. + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sp-api-notification-schemas-")); + + // Defer process.exit until after cleanup: process.exit() terminates + // immediately and skips finally blocks, which would otherwise leak tmpRoot. + // A `return` inside the try would likewise bypass the post-finally exit, so + // the flow below uses a flag and plain branching instead of early returns. + let shouldExitWithError = false; + try { + let schemasDir: string | null = null; + try { + schemasDir = sparseCloneNotificationSchemas(tmpRoot); + } catch (err) { + console.error(`[notifications:fetch] clone failed: ${err instanceof Error ? err.message : String(err)}`); + shouldExitWithError = true; + } + + if (schemasDir !== null) { + const { toCopy, missing } = filterSchemas(schemasDir, NOTIFICATION_SCHEMA_ALLOWLIST); + + fs.mkdirSync(DEST_DIR, { recursive: true }); + + let copied = 0; + + for (const [type, srcPath] of toCopy) { + const destPath = path.join(DEST_DIR, type + ".json"); + try { + fs.copyFileSync(srcPath, destPath); + copied++; + console.log(`[notifications:fetch] copied ${type} → ${destPath}`); + } catch (err) { + shouldExitWithError = true; + console.error(`[notifications:fetch] failed to write ${destPath}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + for (const entry of missing) { + console.log(`[notifications:fetch] warning: no upstream schema found for "${entry}"`); + } + + console.log(`[notifications:fetch] done: ${String(copied)} copied, ${String(missing.length)} missing.`); + } + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + + // Cleanup has run; now it is safe to terminate with a non-zero exit code. + if (shouldExitWithError) { + process.exit(1); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/local-ai-sandbox/scripts/generateOperationRegistry.ts b/local-ai-sandbox/scripts/generateOperationRegistry.ts new file mode 100644 index 000000000..d99fdfb8c --- /dev/null +++ b/local-ai-sandbox/scripts/generateOperationRegistry.ts @@ -0,0 +1,122 @@ +/** + * Extracts operations from the local OpenAPI model files into a single Operation Registry + * (res/generated/operationRegistry.json), the source of truth every runtime call site reads. + * + * Usage: + * tsx scripts/generateOperationRegistry.ts # write the registry + * tsx scripts/generateOperationRegistry.ts --check # fail (exit 1) if the committed registry is stale + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { deriveApiName, deriveDbNamespace, isExcluded, isOperationExcluded, overrideFor } from "./config/apiRegistrationConfig.js"; +import type { OperationEntry, ModelIndexEntry, OperationRegistry } from "../src/registry/operationRegistry.js"; + +const MODELS_DIR = "res/models"; +const OUTPUT_FILE = "res/generated/operationRegistry.json"; +const HTTP_METHODS = new Set(["get", "post", "put", "delete", "patch", "head", "options"]); + +/** The subset of a Swagger 2.0 model this script reads. */ +interface SwaggerModel { + info?: { version?: string; title?: string }; + paths?: Record>; +} + +/** Longest common static prefix of the given paths, stopping before the first parameterized ("{...}") segment. */ +export function computePathPrefix(paths: string[]): string { + if (paths.length === 0) return ""; + const split = paths.map((p) => p.split("/")); + const first = split[0]; + const out: string[] = []; + for (let i = 0; i < first.length; i++) { + const seg = first[i]; + if (seg.includes("{")) break; + if (split.every((parts) => parts[i] === seg)) out.push(seg); + else break; + } + return out.join("/"); +} + +/** Build the registry from the model files found in MODELS_DIR. */ +export function buildRegistry(modelsDir = MODELS_DIR): OperationRegistry { + const operations: OperationEntry[] = []; + const models: ModelIndexEntry[] = []; + + const files = fs + .readdirSync(modelsDir) + .filter((f) => f.endsWith(".json")) + .sort(); + + for (const modelFile of files) { + const model = JSON.parse(fs.readFileSync(path.join(modelsDir, modelFile), "utf8")) as SwaggerModel; + const apiVersion: string = model.info?.version ?? ""; + const apiName = deriveApiName(model.info?.title, modelFile); + const dbNamespace = deriveDbNamespace(apiName, modelFile); + + if (isExcluded({ apiName, apiVersion, modelFile })) continue; + + const paths = model.paths ?? {}; + const pathPrefix = computePathPrefix(Object.keys(paths)); + + models.push({ + modelFile, + apiName, + apiVersion, + pathPrefix, + dbNamespace, + resourcePath: overrideFor(modelFile)?.resourcePath ?? null, + }); + + for (const p of Object.keys(paths)) { + const pathItem = paths[p] as Record; + // Skip sandbox-only paths (x-amzn-api-sandbox-only at the path-item level). + if (pathItem["x-amzn-api-sandbox-only"] === true) continue; + for (const method of Object.keys(paths[p])) { + if (!HTTP_METHODS.has(method.toLowerCase())) continue; + const op = paths[p][method]; + const operationId = op.operationId; + if (!operationId) continue; + if (op.deprecated === true) continue; // schema-deprecated operations are dropped automatically + if (isOperationExcluded({ apiName, apiVersion, modelFile }, operationId)) continue; + operations.push({ operationId, apiName, apiVersion, modelFile, path: p, method: method.toLowerCase(), dbNamespace, pathPrefix }); + } + } + } + + // Deterministic ordering so regeneration is byte-stable. + operations.sort( + (a, b) => + a.apiName.localeCompare(b.apiName) || a.apiVersion.localeCompare(b.apiVersion) || a.path.localeCompare(b.path) || a.method.localeCompare(b.method), + ); + models.sort((a, b) => a.apiName.localeCompare(b.apiName) || a.apiVersion.localeCompare(b.apiVersion)); + + return { operations, models }; +} + +function serialize(registry: OperationRegistry): string { + return JSON.stringify(registry, null, 2) + "\n"; +} + +function main(): void { + const checkMode = process.argv.includes("--check"); + const registry = buildRegistry(); + const output = serialize(registry); + + if (checkMode) { + const existing = fs.existsSync(OUTPUT_FILE) ? fs.readFileSync(OUTPUT_FILE, "utf8") : ""; + if (existing !== output) { + console.error(`[registry:check] ${OUTPUT_FILE} is stale. Run "npm run registry:generate" and commit the result.`); + process.exit(1); + } + console.log(`[registry:check] ${OUTPUT_FILE} is up to date (${String(registry.operations.length)} operations, ${String(registry.models.length)} models).`); + return; + } + + fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true }); + fs.writeFileSync(OUTPUT_FILE, output, "utf8"); + console.log(`[registry:generate] Wrote ${OUTPUT_FILE}: ${String(registry.operations.length)} operations across ${String(registry.models.length)} models.`); +} + +// Run only when invoked directly (not when imported by tests). +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/local-ai-sandbox/src/agent-definition/agentsDefinitionsRegistry.ts b/local-ai-sandbox/src/agent-definition/agentsDefinitionsRegistry.ts deleted file mode 100644 index 09efaadac..000000000 --- a/local-ai-sandbox/src/agent-definition/agentsDefinitionsRegistry.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { DeleteListingsItem, GetListingsItem, PatchListingsItem, PutListingsItem, SearchListingsItems } from "./listingsAgentsDefinitions.js"; -import { Request } from "express"; -import { ConfirmShipment, GetOrder, SearchOrders } from "./ordersAgentsDefinitions.js"; -import { BatchInventory } from "./extFulfillmentInventoryAgentsDefinitions.js"; -import { ListReturns, GetReturn } from "./extFulfillmentReturnsAgentsDefinitions.js"; -import { - GetShipments, - GetShipment, - ProcessShipment, - CreatePackages, - UpdatePackage, - UpdatePackageStatus, - RetrieveShippingOptions, - RetrieveInvoice, - GenerateInvoice, - GenerateShipLabels, -} from "./extFulfillmentShipmentsAgentsDefinitions.js"; -import { GetCatalogItem, SearchCatalogItems } from "./catalogItemsAgentsDefinitions.js"; -import { GetFeaturedOfferExpectedPriceBatch } from "./pricingAgentsDefinitions.js"; - -export const AGENTS_DEFINITIONS_REGISTRY = new Map([ - ["getListingsItem", new GetListingsItem()], - ["searchListingsItems", new SearchListingsItems()], - ["putListingsItem", new PutListingsItem()], - ["patchListingsItem", new PatchListingsItem()], - ["deleteListingsItem", new DeleteListingsItem()], - ["confirmShipment", new ConfirmShipment()], - ["searchOrders", new SearchOrders()], - ["getOrder", new GetOrder()], - ["batchInventory", new BatchInventory()], - ["listReturns", new ListReturns()], - ["getReturn", new GetReturn()], - ["getShipments", new GetShipments()], - ["getShipment", new GetShipment()], - ["processShipment", new ProcessShipment()], - ["createPackages", new CreatePackages()], - ["updatePackage", new UpdatePackage()], - ["updatePackageStatus", new UpdatePackageStatus()], - ["retrieveShippingOptions", new RetrieveShippingOptions()], - ["retrieveInvoice", new RetrieveInvoice()], - ["generateInvoice", new GenerateInvoice()], - ["generateShipLabels", new GenerateShipLabels()], - ["getCatalogItem", new GetCatalogItem()], - ["searchCatalogItems", new SearchCatalogItems()], - ["getFeaturedOfferExpectedPriceBatch", new GetFeaturedOfferExpectedPriceBatch()], -]); - -export abstract class AgentDefinition { - abstract tools: any[]; - abstract instructions: (request: Request, result: any) => string; -} diff --git a/local-ai-sandbox/src/agent-definition/catalogItemsAgentsDefinitions.ts b/local-ai-sandbox/src/agent-definition/catalogItemsAgentsDefinitions.ts deleted file mode 100644 index bc798d5ff..000000000 --- a/local-ai-sandbox/src/agent-definition/catalogItemsAgentsDefinitions.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { AgentDefinition } from "./agentsDefinitionsRegistry.js"; -import { Request } from "express"; -import { databaseLookupTool } from "../tool/databaseLookupTool.js"; -import { safeStringify } from "../util.js"; - -export class GetCatalogItem implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the getCatalogItem operation. - - EXECUTION STEPS: - - 1. DATABASE LOOKUP - Query database for ASIN: ${result.path.asin} - - 2. RESPONSE HANDLING - - SCENARIO A: Item Found in DB - - Return HTTP 200 with catalog item data filtered by includedData param - - Only include data categories requested in includedData: ${JSON.stringify(result.query.includedData)} - - SCENARIO B: Item Not Found in DB - - Retrieve the catalog model specification - - Generate a realistic catalog item for ASIN ${result.path.asin} with rich product data - - Include only the data categories requested in includedData: ${JSON.stringify(result.query.includedData)} - - Generate realistic: summaries (title, brand, manufacturer), attributes, dimensions, identifiers (UPC/EAN/GTIN), images with URLs, salesRanks, relationships, classifications - - Store generated item in database for future lookups - - Return HTTP 200 with the generated item - - CONTEXT: - - API Spec: ${safeStringify(result.operation)} - - ASIN: ${result.path.asin} - - Marketplace IDs: ${JSON.stringify(result.query.marketplaceIds)} - - Included Data: ${JSON.stringify(result.query.includedData)} - - Response must have top-level "asin" field plus arrays for each includedData category. - Handle errors at each step. - `; - } - - tools = [databaseLookupTool]; -} - -export class SearchCatalogItems implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the searchCatalogItems operation. - - EXECUTION STEPS: - - 1. REQUEST VALIDATION - Verify search criteria: at least one of keywords, identifiers, or identifiersType must be provided. - Keywords: ${JSON.stringify(result.query.keywords)} - Identifiers: ${JSON.stringify(result.query.identifiers)} - IdentifiersType: ${JSON.stringify(result.query.identifiersType)} - - 2. DATABASE QUERY - DONT use optional id tool parameter. Retrieve all catalog items. - - 3. SEARCH & FILTER - - If keywords provided: filter items whose title/brand/attributes match the keywords - - If identifiers provided: filter items matching the given identifiers (ASIN, UPC, EAN, etc.) - - Apply includedData filter: ${JSON.stringify(result.query.includedData)} - - 4. RESPONSE GENERATION - - If matching items found in DB: return them - - If no items found but valid search: generate 3-5 realistic catalog items matching the search criteria with rich product data, store them in database, return them - - Return HTTP 200 with items array, numberOfResults, and pagination per API spec - - CONTEXT: - - API Spec: ${safeStringify(result.operation)} - - Query Parameters: ${JSON.stringify(result.query)} - - Response must have "numberOfResults", "items" array, and "pagination" object. - Handle errors at each step. - `; - } - - tools = [databaseLookupTool]; -} diff --git a/local-ai-sandbox/src/agent-definition/extFulfillmentInventoryAgentsDefinitions.ts b/local-ai-sandbox/src/agent-definition/extFulfillmentInventoryAgentsDefinitions.ts deleted file mode 100644 index dc7b25279..000000000 --- a/local-ai-sandbox/src/agent-definition/extFulfillmentInventoryAgentsDefinitions.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { AgentDefinition } from "./agentsDefinitionsRegistry.js"; -import { Request } from "express"; -import { databaseLookupTool } from "../tool/databaseLookupTool.js"; -import { databaseInsertionTool } from "../tool/databaseInsertionTool.js"; -import { safeStringify } from "../util.js"; - -export class BatchInventory implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the batchInventory operation. - - EXECUTION STEPS: - - 1. PROCESS BATCH REQUESTS - The request body contains a list of inventory requests. For each request: - - Extract the inventoryRequestParams (sellerSku, locationId, quantity, marketplaceAttributes) - - Use the sellerSku as the database key - - Look up existing inventory, then upsert with the new quantity and location data - - Write to database using sellerSku as id - - 2. RESPONSE GENERATION - Return HTTP 207 (Multi-Status) with a BatchInventoryResponse containing: - - A response entry for each request in the batch - - Each successful entry should have status 200 and include the inventory count - - Each failed entry should have the appropriate error status and message - Format as JSON per API specification. - - CONTEXT: - - Request Body: ${JSON.stringify(request.body)} - - API Spec: ${safeStringify(result.operation)} - - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - - tools = [databaseLookupTool, databaseInsertionTool]; -} diff --git a/local-ai-sandbox/src/agent-definition/extFulfillmentReturnsAgentsDefinitions.ts b/local-ai-sandbox/src/agent-definition/extFulfillmentReturnsAgentsDefinitions.ts deleted file mode 100644 index 4e7ef3f15..000000000 --- a/local-ai-sandbox/src/agent-definition/extFulfillmentReturnsAgentsDefinitions.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { AgentDefinition } from "./agentsDefinitionsRegistry.js"; -import { Request } from "express"; -import { databaseLookupTool } from "../tool/databaseLookupTool.js"; -import { safeStringify } from "../util.js"; - -export class ListReturns implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the listReturns operation. - - EXECUTION STEPS: - 1. DATABASE QUERY - Retrieve all returns. DONT use optional id tool parameter. - 2. FILTERING - Apply query parameter filters on results: ${JSON.stringify(result.query)} - 3. RESPONSE GENERATION - Return HTTP 200 with matching returns formatted as JSON per API specification: ${safeStringify(result.operation)} - - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - tools = [databaseLookupTool]; -} - -export class GetReturn implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the getReturn operation. - - EXECUTION STEPS: - 1. DATABASE QUERY - Query database for return id: ${result.path.returnId} - 2. RESPONSE GENERATION - - Found: Return HTTP 200 with return data as JSON per API specification - - Not Found: Return HTTP 404 as JSON per API specification - - CONTEXT: - - API Spec: ${safeStringify(result.operation)} - - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - tools = [databaseLookupTool]; -} diff --git a/local-ai-sandbox/src/agent-definition/extFulfillmentShipmentsAgentsDefinitions.ts b/local-ai-sandbox/src/agent-definition/extFulfillmentShipmentsAgentsDefinitions.ts deleted file mode 100644 index 6b60e3be3..000000000 --- a/local-ai-sandbox/src/agent-definition/extFulfillmentShipmentsAgentsDefinitions.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { AgentDefinition } from "./agentsDefinitionsRegistry.js"; -import { Request } from "express"; -import { databaseLookupTool } from "../tool/databaseLookupTool.js"; -import { databaseInsertionTool } from "../tool/databaseInsertionTool.js"; -import { safeStringify } from "../util.js"; - -export class GetShipments implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the getShipments operation. - EXECUTION STEPS: - 1. DATABASE QUERY - Retrieve all shipments. DONT use optional id tool parameter. - 2. FILTERING - Apply query parameter filters: ${JSON.stringify(result.query)} - 3. RESPONSE GENERATION - Return HTTP 200 with matching shipments as JSON per API specification: ${safeStringify(result.operation)} - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - tools = [databaseLookupTool]; -} - -export class GetShipment implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the getShipment operation. - EXECUTION STEPS: - 1. DATABASE QUERY - Query database for shipment id: ${result.path.shipmentId} - 2. RESPONSE GENERATION - Found: Return HTTP 200 with shipment data. Not Found: Return HTTP 404. - CONTEXT: API Spec: ${safeStringify(result.operation)} - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - tools = [databaseLookupTool]; -} - -export class ProcessShipment implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the processShipment operation. - EXECUTION STEPS: - 1. DATABASE LOOKUP - Query database for shipment id: ${result.path.shipmentId} - 2. RESPONSE HANDLING - - Found: Process the shipment action from request body, update shipment status in database, return HTTP 200 - - Not Found: Return HTTP 404 - CONTEXT: Request Body: ${JSON.stringify(request.body)}, API Spec: ${safeStringify(result.operation)} - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - tools = [databaseLookupTool, databaseInsertionTool]; -} - -export class CreatePackages implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the createPackages operation. - EXECUTION STEPS: - 1. DATABASE LOOKUP - Query database for shipment id: ${result.path.shipmentId} - 2. RESPONSE HANDLING - - Found: Create packages from request body, add to shipment's packages array, write updated shipment to database, return HTTP 200 - - Not Found: Return HTTP 404 - CONTEXT: Request Body: ${JSON.stringify(request.body)}, API Spec: ${safeStringify(result.operation)} - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - tools = [databaseLookupTool, databaseInsertionTool]; -} - -export class UpdatePackage implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the updatePackage operation. - EXECUTION STEPS: - 1. DATABASE LOOKUP - Query database for shipment id: ${result.path.shipmentId} - 2. Find package with id: ${result.path.packageId} within the shipment - 3. RESPONSE HANDLING - - Found: Replace package data with request body, write updated shipment to database, return HTTP 200 - - Not Found: Return HTTP 404 - CONTEXT: Request Body: ${JSON.stringify(request.body)}, API Spec: ${safeStringify(result.operation)} - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - tools = [databaseLookupTool, databaseInsertionTool]; -} - -export class UpdatePackageStatus implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the updatePackageStatus operation. - EXECUTION STEPS: - 1. DATABASE LOOKUP - Query database for shipment id: ${result.path.shipmentId} - 2. Find package with id: ${result.path.packageId} within the shipment - 3. RESPONSE HANDLING - - Found: Apply status patch from request body, write updated shipment to database, return HTTP 200 - - Not Found: Return HTTP 404 - CONTEXT: Request Body: ${JSON.stringify(request.body)}, API Spec: ${safeStringify(result.operation)} - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - tools = [databaseLookupTool, databaseInsertionTool]; -} - -export class RetrieveShippingOptions implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the retrieveShippingOptions operation. - EXECUTION STEPS: - 1. DATABASE LOOKUP - Query database for shipment id: ${result.path.shipmentId} - 2. RESPONSE GENERATION - - Found: Return HTTP 200 with shipping options for the shipment as JSON per API specification - - Not Found: Return HTTP 404 - CONTEXT: API Spec: ${safeStringify(result.operation)} - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - tools = [databaseLookupTool]; -} - -export class RetrieveInvoice implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the retrieveInvoice operation. - EXECUTION STEPS: - 1. DATABASE LOOKUP - Query database for shipment id: ${result.path.shipmentId} - 2. RESPONSE GENERATION - - Found: Return HTTP 200 with invoice data as JSON per API specification - - Not Found: Return HTTP 404 - CONTEXT: API Spec: ${safeStringify(result.operation)} - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - tools = [databaseLookupTool]; -} - -export class GenerateInvoice implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the generateInvoice operation. - EXECUTION STEPS: - 1. DATABASE LOOKUP - Query database for shipment id: ${result.path.shipmentId} - 2. RESPONSE HANDLING - - Found: Generate invoice data from request body, store in shipment record, write to database, return HTTP 200 - - Not Found: Return HTTP 404 - CONTEXT: Request Body: ${JSON.stringify(request.body)}, API Spec: ${safeStringify(result.operation)} - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - tools = [databaseLookupTool, databaseInsertionTool]; -} - -export class GenerateShipLabels implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the generateShipLabels operation. - EXECUTION STEPS: - 1. DATABASE LOOKUP - Query database for shipment id: ${result.path.shipmentId} - 2. RESPONSE HANDLING - - Found: Generate shipping labels from request body, store in shipment record, write to database, return HTTP 200 - - Not Found: Return HTTP 404 - CONTEXT: Request Body: ${JSON.stringify(request.body)}, API Spec: ${safeStringify(result.operation)} - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - tools = [databaseLookupTool, databaseInsertionTool]; -} diff --git a/local-ai-sandbox/src/agent-definition/fbaInventoryAgentsDefinitions.ts b/local-ai-sandbox/src/agent-definition/fbaInventoryAgentsDefinitions.ts deleted file mode 100644 index 9378a96f4..000000000 --- a/local-ai-sandbox/src/agent-definition/fbaInventoryAgentsDefinitions.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { AgentDefinition } from "./agentsDefinitionsRegistry.js"; -import { Request } from "express"; -import { databaseLookupTool } from "../tool/databaseLookupTool.js"; -import { databaseInsertionTool } from "../tool/databaseInsertionTool.js"; -import { databaseRemovalTool } from "../tool/databaseRemovalTool.js"; -import { safeStringify } from "../util.js"; - -export class GetInventorySummaries implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the getInventorySummaries operation. - - EXECUTION STEPS: - - 1. DATABASE QUERY - Query database for inventory data. - If sellerSkus or sellerSku query params are provided, filter by those SKUs. - If startDateTime is provided, filter items with lastUpdatedTime after that date. - DONT use optional id tool parameter when retrieving all inventory. - - 2. RESPONSE GENERATION - Return HTTP 200 with inventory summaries formatted as JSON per API specification. - Include granularity object with granularityType and granularityId from query params. - Wrap results in payload.inventorySummaries array. - - CONTEXT: - - Query Parameters: ${JSON.stringify(result.query)} - - API Spec: ${safeStringify(result.operation)} - - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - - tools = [databaseLookupTool]; -} - -export class AddInventory implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the addInventory operation. - - EXECUTION STEPS: - - 1. PROCESS INVENTORY ITEMS - For each item in the request body inventoryItems array: - - Look up existing inventory by sellerSku - - If found: add the requested quantity to existing totalQuantity and fulfillableQuantity - - If not found: create new inventory entry with the requested quantity - - Write updated/new inventory to database using sellerSku as id - - 2. RESPONSE GENERATION - Return HTTP 200 with empty response body (no errors) per API specification. - - CONTEXT: - - Request Body: ${JSON.stringify(request.body)} - - API Spec: ${safeStringify(result.operation)} - - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - - tools = [databaseLookupTool, databaseInsertionTool]; -} - -export class CreateInventoryItem implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the createInventoryItem operation. - - EXECUTION STEPS: - - 1. DATABASE LOOKUP - Check if inventory item with sellerSku already exists. - - 2. RESPONSE HANDLING - - SCENARIO A: Item does NOT exist - - Create new inventory entry with sellerSku, marketplaceId, productName from request body - - Set initial quantities to 0 - - Write to database using sellerSku as id - - Return HTTP 200 with empty response body (no errors) - - SCENARIO B: Item already exists - - Return HTTP 400 with error indicating item already exists - - CONTEXT: - - Request Body: ${JSON.stringify(request.body)} - - API Spec: ${safeStringify(result.operation)} - - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - - tools = [databaseLookupTool, databaseInsertionTool]; -} - -export class DeleteInventoryItem implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the deleteInventoryItem operation. - - EXECUTION STEPS: - - 1. DATABASE LOOKUP - Query database for inventory item with sellerSku: ${result.path.sellerSku} - - 2. RESPONSE HANDLING - - SCENARIO A: Item Found - - Remove item from database - - Return HTTP 200 with empty response body (no errors) - - SCENARIO B: Item Not Found - - Return HTTP 404 with error per API specification - - CONTEXT: - - sellerSku: ${result.path.sellerSku} - - Query Parameters: ${JSON.stringify(result.query)} - - API Spec: ${safeStringify(result.operation)} - - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - - tools = [databaseLookupTool, databaseRemovalTool]; -} diff --git a/local-ai-sandbox/src/agent-definition/listingsAgentsDefinitions.ts b/local-ai-sandbox/src/agent-definition/listingsAgentsDefinitions.ts deleted file mode 100644 index 5f2837e8d..000000000 --- a/local-ai-sandbox/src/agent-definition/listingsAgentsDefinitions.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { databaseLookupTool } from "../tool/databaseLookupTool.js"; -import { AgentDefinition } from "./agentsDefinitionsRegistry.js"; -import { Request } from "express"; -import { PROD_BACKEND } from "../index.js"; -import { databaseInsertionTool } from "../tool/databaseInsertionTool.js"; -import { databaseRemovalTool } from "../tool/databaseRemovalTool.js"; -import { callSellingPartnerApiTool } from "../tool/callSellingPartnerApiTool.js"; -import { safeStringify } from "../util.js"; - -export class GetListingsItem implements AgentDefinition { - instructions = function (request: Request, result: any) { - return ` - You are responsible for generating a valid response for the getListingsItem operation. - - EXECUTION STEPS: - - 1. DATABASE LOOKUP - Query database for SKU: ${result.path.sku} - - 2. RESPONSE GENERATION - - Found: Return HTTP 200 with listing data as JSON per API specification - - Not Found: Return HTTP 404 as JSON per API specification - - CONTEXT: - - API Spec: ${safeStringify(result.operation)} - - Included data: ${JSON.stringify(result.query)} - - Handle errors at each step. Ensure responses validate against the API specification. - `; - }; - - tools = [databaseLookupTool]; -} - -export class SearchListingsItems implements AgentDefinition { - instructions = function (request: Request, result: any) { - return ` - You are responsible for generating a valid response for the searchListingsItems operation. - - EXECUTION STEPS: - - 1. DATABASE QUERY - DONT use optional id tool parameter - - 2. LISTINGS FILTERING - Apply filter parameters on result: ${JSON.stringify(result.query)} - - 3. RESPONSE GENERATION - Return HTTP 200 with matching database entries as JSON per API specification - - CONTEXT: - - API Spec: ${safeStringify(result.operation)} - - Handle errors at each step. Ensure responses validate against the API specification. - `; - }; - - tools = [databaseLookupTool]; -} - -export class PutListingsItem implements AgentDefinition { - instructions = function (request: Request, result: any) { - return ` - You are responsible for generating a valid response for the putListingsItem operation. - - EXECUTION STEPS: - - 1. VALIDATION REQUEST - Send HTTP PUT to: ${PROD_BACKEND}${request.path}?${JSON.stringify(request.query)}&mode=VALIDATION_PREVIEW - Headers: - - Content-Type: application/json - Body: ${JSON.stringify(request.body)} - - If LISTING_OFFER_ONLY, fetch catalog data in parallel: - GET ${PROD_BACKEND}/catalog/2022-04-01/items - Query params: - - identifiers: merchant_suggested_asin OR externally_assigned_product_identifier - - identifiersType: ASIN OR corresponding type - - marketplaceIds: ${result.query.marketplaceIds[0]} - - includedData: summaries,attributes,relationships,productTypes - - 2. RESPONSE HANDLING - - SCENARIO A: Complete Listing (200 + VALID + NOT LISTING_OFFER_ONLY) - - Write result to database - - Return HTTP 200 as JSON per API specification - - SCENARIO B: Offer-Only Listing (200 + VALID + LISTING_OFFER_ONLY) - - Merge catalog data (summaries, attributes, relationships, productTypes) into request body - - Write merged output to database - - Return HTTP 200 as JSON per API specification - - SCENARIO C: Invalid Listing (200 + NOT VALID) - - Return HTTP 200 with validation response as JSON per API specification - - CONTEXT: - - SKU: ${result.path.sku} - - API Spec: ${safeStringify(result.operation)} - - Handle errors at each step. Ensure all responses validate against the API specification. - `; - }; - - tools = [databaseInsertionTool, callSellingPartnerApiTool]; -} - -export class PatchListingsItem implements AgentDefinition { - instructions = function (request: Request, result: any) { - return ` - You are responsible for generating a valid response for the patchListingsItem operation. - - EXECUTION STEPS: - - 1. DATABASE LOOKUP - Query database for SKU: ${result.path.sku} - - 2. RESPONSE HANDLING - - SCENARIO A: Listing Found - - Apply patch operations (op, path, value) to listing item - - Write updated listing to database - - Return HTTP 200 as JSON per API specification - - SCENARIO B: Listing Not Found - - Return HTTP 400 as JSON per API specification - - CONTEXT: - - Patch Operations: ${JSON.stringify(request.body)} - - API Spec: ${safeStringify(result.operation)} - - Handle errors at each step. Ensure responses validate against the API specification. - `; - }; - - tools = [databaseLookupTool, databaseInsertionTool]; -} - -export class DeleteListingsItem implements AgentDefinition { - instructions = function (request: Request, result: any) { - return ` - You are responsible for generating a valid response for the deleteListingsItem operation. - - EXECUTION STEPS: - - 1. DATABASE LOOKUP - Query database for SKU: ${result.path.sku} - - 2. RESPONSE HANDLING - - SCENARIO A: Listing Found - - Remove listing from database - - Return HTTP 200 as JSON per API specification - - SCENARIO B: Listing Not Found - - Return HTTP 200 as JSON per API specification - - CONTEXT: - - API Spec: ${safeStringify(result.operation)} - - Handle errors at each step. Ensure responses validate against the API specification. - `; - }; - - tools = [databaseLookupTool, databaseRemovalTool]; -} diff --git a/local-ai-sandbox/src/agent-definition/ordersAgentsDefinitions.ts b/local-ai-sandbox/src/agent-definition/ordersAgentsDefinitions.ts deleted file mode 100644 index dc8d0a551..000000000 --- a/local-ai-sandbox/src/agent-definition/ordersAgentsDefinitions.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { AgentDefinition } from "./agentsDefinitionsRegistry.js"; -import { Request } from "express"; -import { databaseLookupTool } from "../tool/databaseLookupTool.js"; -import { databaseInsertionTool } from "../tool/databaseInsertionTool.js"; -import { safeStringify } from "../util.js"; - -export class ConfirmShipment implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the confirmShipment operation. - - EXECUTION STEPS: - - 1. DATABASE LOOKUP - Query database for order id: ${result.path.orderId} - - 2. RESPONSE HANDLING - - SCENARIO A: Order Found and order not fulfilled by Amazon - - Merge data from request body into existing order - - Write updated order to database - - Return HTTP 200 as JSON per API specification - - SCENARIO B: Order Found and order fulfilled by Amazon - - Return HTTP 400 as JSON per API specification (confirm shipment not allowed for FBA) - - SCENARIO C: Order Not Found - - Return HTTP 404 as JSON per API specification - - CONTEXT: - - Patch Operations: ${JSON.stringify(request.body)} - - API Spec: ${safeStringify(result.operation)} - - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - - tools = [databaseLookupTool, databaseInsertionTool]; -} - -export class SearchOrders implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the searchOrders operation. - - EXECUTION STEPS - - 1. REQUEST VALIDATION: - Query parameters: ${JSON.stringify(result.query)} - Verify the following mutually exclusive date filter rules: - - Exactly one of createdAfter OR lastUpdatedAfter must be provided - - If createdAfter is provided: - lastUpdatedAfter and lastUpdatedBefore must NOT be provided - createdBefore is optional; if provided, must be ≥ createdAfter and at least 2 minutes before request time - - If lastUpdatedAfter is provided: - createdAfter and createdBefore must NOT be provided - lastUpdatedBefore is optional; if provided, must be ≥ lastUpdatedAfter and at least 2 minutes before request time - - 2. DATABASE QUERY - DONT use optional id tool parameter - - 3. ORDER FILTERING - Apply filter parameters on result: ${JSON.stringify(result.query)} - Only return included data objects when available in the database - - 3. RESPONSE GENERATION - Return HTTP 200 with matching entries formatted as JSON per API specification: ${safeStringify(result.operation)} - - Validate and handle errors at each step. Ensure all responses conform to the API specification. - `; - } - - tools = [databaseLookupTool]; -} - -export class GetOrder implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the getOrder operation. - - EXECUTION STEPS: - - 1. DATABASE QUERY - Query database for order id: ${result.path.orderId} - - 2. RESPONSE GENERATION - - Found: Return HTTP 200 with order data as JSON per API specification - - Not Found: Return HTTP 404 as JSON per API specification - - CONTEXT: - - API Spec: ${safeStringify(result.operation)} - - Included data: ${JSON.stringify(result.query)} - Don't show corresponding object if no data available - - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - - tools = [databaseLookupTool]; -} diff --git a/local-ai-sandbox/src/agent-definition/pricingAgentsDefinitions.ts b/local-ai-sandbox/src/agent-definition/pricingAgentsDefinitions.ts deleted file mode 100644 index 082da7f92..000000000 --- a/local-ai-sandbox/src/agent-definition/pricingAgentsDefinitions.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { AgentDefinition } from "./agentsDefinitionsRegistry.js"; -import { Request } from "express"; -import { databaseLookupTool } from "../tool/databaseLookupTool.js"; -import { safeStringify } from "../util.js"; - -export class GetFeaturedOfferExpectedPriceBatch implements AgentDefinition { - instructions(request: Request, result: any): string { - return ` - You are responsible for generating a valid response for the getFeaturedOfferExpectedPriceBatch operation. - - EXECUTION STEPS: - - 1. PROCESS BATCH REQUESTS - The request body contains a batch of up to 40 FOEP requests. - Each request has: marketplaceId, sku, and optionally a segment with sampleLocation. - Request body: ${JSON.stringify(request.body)} - - 2. FOR EACH REQUEST IN THE BATCH: - a. Look up the SKU in the listings database to find the seller's offer (price, ASIN, fulfillment type, condition) - b. If SKU not found: return a response with resultStatus "OFFER_NOT_FOUND" and an error in the body - c. If SKU found: generate a realistic FOEP response based on the listing data - - 3. FOEP RESPONSE GENERATION (for found SKUs): - Use the listing's current price as context to generate realistic pricing data: - - - offerIdentifier: include asin, sku, marketplaceId, fulfillmentType, and a sellerId (generate a realistic one like "A" followed by alphanumeric chars) - - - featuredOfferExpectedPriceResults: array with one result containing: - - resultStatus: Use one of these based on realistic scenarios: - * "VALID_FOEP" (most common) - generate a featuredOfferExpectedPrice with listingPrice slightly below the current competing offer - * "NO_COMPETING_OFFER" - when the seller is the only offer - * "OFFER_NOT_ELIGIBLE" - occasionally, for variety - - - featuredOfferExpectedPrice (only when VALID_FOEP): - * listingPrice: a price at or slightly below the competing offer price (typically 2-8% lower than the competing offer) - * points: optional, include only for JP marketplace - - - competingFeaturedOffer: generate a realistic competing seller's offer with: - * offerIdentifier with a different sellerId than the requesting seller - * condition: "New" - * price with listingPrice (slightly above the FOEP) and shippingPrice - - - currentFeaturedOffer: - * If the seller's price is competitive (at or below FOEP): use the seller's own offer as currentFeaturedOffer - * If the seller's price is above FOEP: set equal to competingFeaturedOffer - - 4. RESPONSE FORMAT - Return HTTP 200 with a GetFeaturedOfferExpectedPriceBatchResponse. - The outer HTTP status is ALWAYS 200. Each item in the responses array has its own status. - - For a FOUND SKU: - { - "request": { marketplaceId, sku, segment (if provided) }, - "status": { "statusCode": 200, "reasonPhrase": "Success" }, - "headers": {}, - "body": { offerIdentifier, featuredOfferExpectedPriceResults } - } - - For a NOT FOUND or INVALID SKU: - { - "request": { marketplaceId, sku, segment (if provided) }, - "status": { "statusCode": 400, "reasonPhrase": "Client Error" }, - "headers": {}, - "body": { "errors": [{ "code": "INVALID_SKU", "message": "The requested SKU does not exist for the seller in the requested marketplace." }] } - } - - CONTEXT: - - API Spec: ${safeStringify(result.operation)} - - Use "listings" as the api parameter for database lookups to find SKU data - - Use the "ids" parameter with an array of all requested SKUs to batch lookup in a single call - - Use the "fields" parameter to request only: ["purchasable_offer", "externally_assigned_product_identifier", "fulfillment_availability"] - - IMPORTANT: - - Prices must use currencyCode "USD" for US marketplace (ATVPDKIKX0DER) - - All monetary amounts must be numbers, not strings - - Generate varied but realistic competing seller IDs and prices - - Ensure the FOEP listingPrice is always less than or equal to the competing offer's listingPrice - - Handle errors at each step. Ensure responses validate against the API specification. - `; - } - - tools = [databaseLookupTool]; -} diff --git a/local-ai-sandbox/src/controller/dataGeneratorController.ts b/local-ai-sandbox/src/controller/dataGeneratorController.ts index 8eb1af508..40660ab84 100644 --- a/local-ai-sandbox/src/controller/dataGeneratorController.ts +++ b/local-ai-sandbox/src/controller/dataGeneratorController.ts @@ -33,6 +33,9 @@ export const generateData = async (request: Request, response: Response) => { ADDITIONAL INFORMATION: - id passed to databaseInsertionTool must match entity id and pass schema validation, return error if not + - Listings: always include 'sellerId' and 'sku' in the entity. A SKU is + only unique per seller, so the tool keys the listing by both. Use the + seller ID from the prompt, or a realistic one like AMY6FKRUBY7XV. `; try { diff --git a/local-ai-sandbox/src/controller/notificationsManagementController.ts b/local-ai-sandbox/src/controller/notificationsManagementController.ts new file mode 100644 index 000000000..952914048 --- /dev/null +++ b/local-ai-sandbox/src/controller/notificationsManagementController.ts @@ -0,0 +1,156 @@ +import { Request, Response } from "express"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; +import { Api, Context } from "../database/Context.js"; + +const NOTIFICATION_SCHEMAS_DIR = path.resolve("res/notification-schemas"); + +/** + * Converts a PascalCase filename (without extension) to UPPER_SNAKE_CASE notification type. + * Strips trailing "Notification" suffix before conversion. + * Example: "OrderChangeNotification" → "ORDER_CHANGE" + */ +export function deriveNotificationType(filename: string): string { + // Strip file extension if present + const baseName = filename.replace(/\.[^.]+$/, ""); + + // Strip trailing "Notification" suffix + const stripped = baseName.replace(/Notification$/, ""); + + // Split PascalCase into words and convert to UPPER_SNAKE_CASE + const words = stripped.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2"); + + return words.toUpperCase(); +} + +/** + * GET /manage/notifications/schemas + * Returns an array of { notificationType, schema } objects for each JSON file + * in the res/notification-schemas/ directory. + */ +export const getNotificationSchemas = async (_req: Request, res: Response): Promise => { + try { + let files: string[]; + try { + files = await readdir(NOTIFICATION_SCHEMAS_DIR); + } catch { + // Directory not found — return empty array + res.status(200).json([]); + return; + } + + const jsonFiles = files.filter((f) => f.endsWith(".json")); + + if (jsonFiles.length === 0) { + res.status(200).json([]); + return; + } + + const schemas: { notificationType: string; schema: unknown }[] = []; + + for (const file of jsonFiles) { + try { + const filePath = path.join(NOTIFICATION_SCHEMAS_DIR, file); + const content = await readFile(filePath, "utf-8"); + const schema: unknown = JSON.parse(content); + const notificationType = deriveNotificationType(file); + schemas.push({ notificationType, schema }); + } catch (error) { + console.warn(`Skipping invalid JSON file '${file}':`, error); + } + } + + res.status(200).json(schemas); + } catch (error) { + console.error("Error in getNotificationSchemas:", error); + res.status(500).json({ error: "Internal server error" }); + } +}; + +/** Lazily instantiated SQS client — avoids startup cost when notifications are never used. */ +let sqsClient: SQSClient | null = null; + +function getSqsClient(): SQSClient { + if (!sqsClient) { + sqsClient = new SQSClient({}); + } + return sqsClient; +} + +/** + * Extracts the SQS queue URL from an ARN. + * ARN format: arn:aws:sqs::: + * Queue URL format: https://sqs..amazonaws.com// + */ +function queueUrlFromArn(arn: string): string { + const parts = arn.split(":"); + const region = parts[3]; + const accountId = parts[4]; + const queueName = parts[5]; + return `https://sqs.${region}.amazonaws.com/${accountId}/${queueName}`; +} + +/** + * POST /manage/notifications/send + * Sends a notification payload to the SQS queue configured for the notification type's subscription. + */ +export const sendNotification = async (req: Request, res: Response): Promise => { + try { + const body = req.body as Record; + const notificationType = body.NotificationType as string | undefined; + + if (!notificationType) { + res.status(400).json({ error: "NotificationType field is required in the payload" }); + return; + } + + // Look up a subscription matching this notification type + const subscriptions = Context.instance.engine.find(Api.NOTIFICATIONS, { + _type: "subscription", + notificationType, + }); + + if (subscriptions.length === 0) { + res.status(404).json({ error: `No subscription exists for notification type '${notificationType}'` }); + return; + } + + const subscription = subscriptions[0]; + const destinationId = subscription.destinationId as string; + + // Look up the destination by destinationId + const destinations = Context.instance.engine.find(Api.NOTIFICATIONS, { + _type: "destination", + destinationId, + }); + + if (destinations.length === 0) { + res.status(400).json({ error: "No valid SQS destination configured for this subscription" }); + return; + } + + const destination = destinations[0]; + const resource = destination.resource as { sqs?: { arn: string } } | undefined; + + if (!resource?.sqs) { + res.status(400).json({ error: "No valid SQS destination configured for this subscription" }); + return; + } + + const queueUrl = queueUrlFromArn(resource.sqs.arn); + const client = getSqsClient(); + + const command = new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: JSON.stringify(body), + }); + + const result = await client.send(command); + res.status(200).json({ messageId: result.MessageId }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error("Error in sendNotification:", error); + res.status(500).json({ error: `Failed to send message to SQS: ${message}` }); + } +}; diff --git a/local-ai-sandbox/src/controller/ordersManagementController.ts b/local-ai-sandbox/src/controller/ordersManagementController.ts new file mode 100644 index 000000000..7b0391ab2 --- /dev/null +++ b/local-ai-sandbox/src/controller/ordersManagementController.ts @@ -0,0 +1,86 @@ +import { Request, Response } from "express"; +import { Api, Context } from "../database/Context.js"; + +export const createOrder = async (req: Request, res: Response): Promise => { + try { + const body: unknown = req.body; + if (typeof body !== "object" || body === null || Array.isArray(body)) { + res.status(400).json({ error: "Request body must be a valid JSON object" }); + return; + } + + const order = body as Record; + const orderId = order.orderId as string | undefined; + + if (!orderId) { + res.status(400).json({ error: "Request body must contain an orderId field" }); + return; + } + + const existing = Context.instance.engine.get(Api.ORDERS, orderId); + if (existing) { + res.status(409).json({ error: `Order with orderId '${orderId}' already exists` }); + return; + } + + Context.instance.engine.put(Api.ORDERS, orderId, order); + res.status(201).json({ orderId }); + } catch (error) { + console.error("Error in createOrder:", error); + res.status(500).json({ error: "Internal server error" }); + } +}; + +export const updateOrder = async (req: Request, res: Response): Promise => { + try { + const body: unknown = req.body; + if (typeof body !== "object" || body === null || Array.isArray(body)) { + res.status(400).json({ error: "Request body must be a valid JSON object" }); + return; + } + + const order = body as Record; + const orderId = order.orderId as string | undefined; + + if (!orderId) { + res.status(400).json({ error: "Request body must contain an orderId field" }); + return; + } + + const existing = Context.instance.engine.get(Api.ORDERS, orderId); + if (!existing) { + res.status(404).json({ error: `Order with orderId '${orderId}' not found` }); + return; + } + + Context.instance.engine.put(Api.ORDERS, orderId, order); + const updated = Context.instance.engine.get(Api.ORDERS, orderId); + res.status(200).json({ order: updated }); + } catch (error) { + console.error("Error in updateOrder:", error); + res.status(500).json({ error: "Internal server error" }); + } +}; + +export const deleteOrder = async (req: Request, res: Response): Promise => { + try { + const orderId = req.params.orderId as string | undefined; + + if (!orderId) { + res.status(400).json({ error: "orderId path parameter is required" }); + return; + } + + const existing = Context.instance.engine.get(Api.ORDERS, orderId); + if (!existing) { + res.status(404).json({ error: `Order with orderId '${orderId}' not found` }); + return; + } + + await Context.instance.engine.remove(Api.ORDERS, orderId); + res.status(200).json({ message: `Order '${orderId}' deleted successfully` }); + } catch (error) { + console.error("Error in deleteOrder:", error); + res.status(500).json({ error: "Internal server error" }); + } +}; diff --git a/local-ai-sandbox/src/controller/reportsController.ts b/local-ai-sandbox/src/controller/reportsController.ts deleted file mode 100644 index 3d5b4f0bc..000000000 --- a/local-ai-sandbox/src/controller/reportsController.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Request, Response } from "express"; -import { randomUUID } from "node:crypto"; -import { Context } from "../database/Context.js"; -import { REPORT_GENERATORS } from "../service/reportGeneratorService.js"; -import { validateReport, REPORT_META } from "../service/reportValidationService.js"; - -const db = () => Context.instance.db; - -const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); - -export const createReport = async (req: Request, res: Response) => { - const { reportType, marketplaceIds, dataStartTime, dataEndTime, reportOptions } = req.body; - if (DANGEROUS_KEYS.has(reportType) || !Object.hasOwn(REPORT_GENERATORS, reportType)) { - res.status(400).json({ - errors: [{ code: "InvalidInput", message: `Unsupported reportType: ${reportType}. Supported: ${Object.keys(REPORT_GENERATORS).join(", ")}` }], - }); - return; - } - const generator = REPORT_GENERATORS[reportType]; - if (!generator) { - res.status(400).json({ - errors: [{ code: "InvalidInput", message: `Unsupported reportType: ${reportType}. Supported: ${Object.keys(REPORT_GENERATORS).join(", ")}` }], - }); - return; - } - const validationError = validateReport(reportType, marketplaceIds, reportOptions, dataStartTime, dataEndTime); - if (validationError) { - res.status(400).json({ errors: [{ code: "InvalidInput", message: validationError }] }); - return; - } - const reportId = `REP-${randomUUID().slice(0, 8).toUpperCase()}`; - const documentId = `DOC-${randomUUID().slice(0, 8).toUpperCase()}`; - const content = generator(reportOptions ?? {}); - - db().data.reports[documentId] = { content, contentType: "text/tab-separated-values" }; - db().data.reports[reportId] = { - reportId, - reportType, - marketplaceIds, - dataStartTime, - dataEndTime, - reportOptions, - processingStatus: "DONE", - reportDocumentId: documentId, - createdTime: new Date().toISOString(), - processingStartTime: new Date().toISOString(), - processingEndTime: new Date().toISOString(), - }; - await db().write(); - res.status(202).json({ reportId }); -}; - -export const getReport = async (req: Request, res: Response) => { - const report = db().data.reports[req.params.reportId as string]; - if (!report || report.content !== undefined) { - res.status(404).json({ errors: [{ code: "NotFound", message: `Report ${req.params.reportId} not found` }] }); - return; - } - const { content: _, ...metadata } = report; - res.status(200).json(metadata); -}; - -export const getReports = async (req: Request, res: Response) => { - const reports = Object.values(db().data.reports) - .filter((r: any) => r.reportId && !r.content) - .filter((r: any) => !req.query.reportTypes || (req.query.reportTypes as string).split(",").includes(r.reportType)); - res.status(200).json({ reports }); -}; - -export const cancelReport = async (req: Request, res: Response) => { - const reportId = req.params.reportId as string; - if (DANGEROUS_KEYS.has(reportId)) { - res.status(400).json({ errors: [{ code: "InvalidInput", message: "Invalid reportId" }] }); - return; - } - const report = db().data.reports[reportId]; - if (!report || report.content !== undefined) { - res.status(404).json({ errors: [{ code: "NotFound", message: `Report ${reportId} not found` }] }); - return; - } - report.processingStatus = "CANCELLED"; - await db().write(); - res.status(200).send(); -}; - -export const getReportDocument = async (req: Request, res: Response) => { - const doc = db().data.reports[req.params.reportDocumentId as string]; - if (doc?.content === undefined) { - res.status(404).json({ errors: [{ code: "NotFound", message: `Document ${req.params.reportDocumentId} not found` }] }); - return; - } - res.status(200).json({ - reportDocumentId: req.params.reportDocumentId, - url: `http://${req.host}/reports/download/${req.params.reportDocumentId}`, - }); -}; - -export const downloadReportDocument = async (req: Request, res: Response) => { - const doc = db().data.reports[req.params.documentId as string]; - if (doc?.content === undefined) { - res.status(404).json({ errors: [{ code: "NotFound", message: "Document not found" }] }); - return; - } - res.setHeader("Content-Type", doc.contentType ?? "text/plain"); - res.status(200).send(doc.content); -}; - -// Schedule operations — simple DB storage -export const createReportSchedule = async (req: Request, res: Response) => { - const { reportType, marketplaceIds } = req.body; - const meta = REPORT_META[reportType]; - if (meta && !meta.schedulable) { - res.status(400).json({ errors: [{ code: "InvalidInput", message: `reportType ${reportType} can only be requested, not scheduled` }] }); - return; - } - const scheduleId = `SCHED-${randomUUID().slice(0, 8).toUpperCase()}`; - db().data.reports[scheduleId] = { reportScheduleId: scheduleId, ...req.body, createdTime: new Date().toISOString() }; - await db().write(); - res.status(201).json({ reportScheduleId: scheduleId }); -}; - -export const getReportSchedule = async (req: Request, res: Response) => { - const schedule = db().data.reports[req.params.reportScheduleId as string]; - if (!schedule?.reportScheduleId) { - res.status(404).json({ errors: [{ code: "NotFound", message: `Schedule ${req.params.reportScheduleId} not found` }] }); - return; - } - res.status(200).json(schedule); -}; - -export const getReportSchedules = async (req: Request, res: Response) => { - const schedules = Object.values(db().data.reports).filter((r: any) => r.reportScheduleId); - res.status(200).json({ reportSchedules: schedules }); -}; - -export const cancelReportSchedule = async (req: Request, res: Response) => { - const schedule = db().data.reports[req.params.reportScheduleId as string]; - if (!schedule?.reportScheduleId) { - res.status(404).json({ errors: [{ code: "NotFound", message: `Schedule ${req.params.reportScheduleId} not found` }] }); - return; - } - delete db().data.reports[req.params.reportScheduleId as string]; - await db().write(); - res.status(200).send(); -}; diff --git a/local-ai-sandbox/src/controller/scenariosController.ts b/local-ai-sandbox/src/controller/scenariosController.ts new file mode 100644 index 000000000..2395afdb0 --- /dev/null +++ b/local-ai-sandbox/src/controller/scenariosController.ts @@ -0,0 +1,140 @@ +import { Request, Response } from "express"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { z } from "zod"; +import { Api, Context } from "../database/Context.js"; + +/** + * Guided scenarios — pre-seeded, runnable SP-API journeys (parity plan, Workstream C). + * + * Each scenario has a shared seed (the starting world state) and multiple branching + * tracks that explore different downstream paths from that seed. + * + * Seeding uses engine.put() directly and intentionally does NOT emit trigger events: + * fixtures describe a consistent snapshot, so firing INSERT triggers would double-apply. + */ + +const SCENARIOS_DIR = "./res/scenarios"; + +const SeedEntitySchema = z.object({ + api: z.enum(Api), + id: z.string().min(1), + entity: z.record(z.string(), z.unknown()), +}); + +const StepSchema = z.object({ + title: z.string(), + method: z.string(), + path: z.string(), + status: z.enum(["runnable", "pass-through", "planned"]), + note: z.string(), + body: z.record(z.string(), z.unknown()).optional(), +}); + +const TrackSchema = z.object({ + id: z.string().regex(/^[a-z0-9-]+$/), + title: z.string(), + description: z.string(), + steps: z.array(StepSchema).min(1), +}); + +const ScenarioSchema = z.object({ + id: z.string().regex(/^[a-z0-9-]+$/), + title: z.string(), + tagline: z.string(), + description: z.string(), + seed: z.array(SeedEntitySchema), + tracks: z.array(TrackSchema).min(1), +}); + +export type Scenario = z.infer; +export type Track = z.infer; + +let cache: Map | null = null; + +/** Load and validate all scenario fixtures (cached after first read). */ +export function loadScenarios(): Map { + if (cache) return cache; + + const scenarios = new Map(); + const files = fs + .readdirSync(SCENARIOS_DIR) + .filter((f) => f.endsWith(".json")) + .sort(); + + for (const file of files) { + const raw: unknown = JSON.parse(fs.readFileSync(path.join(SCENARIOS_DIR, file), "utf8")); + const scenario = ScenarioSchema.parse(raw); + if (scenarios.has(scenario.id)) { + throw new Error(`Duplicate scenario id '${scenario.id}' in ${file}`); + } + scenarios.set(scenario.id, scenario); + } + + cache = scenarios; + return scenarios; +} + +/** Test-only: drop the cache so the next call reloads from disk. */ +export function __resetScenarioCacheForTests(): void { + cache = null; +} + +/** GET /scenarios — list all guided scenarios with tracks. */ +export const listScenarios = (_request: Request, response: Response): void => { + try { + const scenarios = [...loadScenarios().values()].map((s) => ({ + id: s.id, + title: s.title, + tagline: s.tagline, + description: s.description, + seedCount: s.seed.length, + tracks: s.tracks.map((t) => ({ + id: t.id, + title: t.title, + description: t.description, + steps: t.steps, + runnableCount: t.steps.filter((st) => st.status === "runnable").length, + })), + })); + response.status(200).json({ scenarios }); + } catch (error) { + console.error("Failed to load scenarios:", error); + response.status(500).json({ errors: [{ code: "ScenarioLoadFailure", message: "Failed to load scenario definitions" }] }); + } +}; + +/** POST /scenarios/:scenarioId/seed — deterministically write the scenario's fixture data. */ +export const seedScenario = (request: Request, response: Response): void => { + const rawId = request.params.scenarioId; + const scenarioId = Array.isArray(rawId) ? rawId[0] : rawId; + + let scenario: Scenario | undefined; + try { + scenario = loadScenarios().get(scenarioId); + } catch (error) { + console.error("Failed to load scenarios:", error); + response.status(500).json({ errors: [{ code: "ScenarioLoadFailure", message: "Failed to load scenario definitions" }] }); + return; + } + + if (!scenario) { + response.status(404).json({ errors: [{ code: "NotFound", message: `Scenario '${scenarioId}' not found` }] }); + return; + } + + const engine = Context.instance.engine; + const seededByApi: Record = {}; + for (const { api, id, entity } of scenario.seed) { + engine.put(api, id, structuredClone(entity)); + (seededByApi[api] ??= []).push(id); + } + + response.status(200).json({ + scenarioId: scenario.id, + title: scenario.title, + seeded: seededByApi, + seedCount: scenario.seed.length, + message: `Seeded ${String(scenario.seed.length)} entities for '${scenario.title}'. Pick a track to explore.`, + }); +}; diff --git a/local-ai-sandbox/src/controller/spapiController.ts b/local-ai-sandbox/src/controller/spapiController.ts index 74e59d729..0ebf6d869 100644 --- a/local-ai-sandbox/src/controller/spapiController.ts +++ b/local-ai-sandbox/src/controller/spapiController.ts @@ -1,39 +1,72 @@ import { Request, Response } from "express"; -import { Agent } from "@strands-agents/sdk"; -import z from "zod"; -import { AgentDefinition, AGENTS_DEFINITIONS_REGISTRY } from "../agent-definition/agentsDefinitionsRegistry.js"; -import { ResponseSchema } from "../schema/schemas.js"; -import { validateRequest } from "../service/requestValidationService.js"; +import { validateRequest } from "../service/validationEngine.js"; import { asyncLocalStorage } from "../index.js"; -import { model } from "../modelProvider.js"; -import { printMetricsAndTraces } from "../util.js"; -import { handleAgentError } from "./errorHandler.js"; +import { buildKey, CURRENT_MODE, OPERATIONS_REGISTRY } from "../registry/operationRegistry.js"; +import { Api, Context } from "../database/Context.js"; export const createResponse = async (request: Request, response: Response) => { await asyncLocalStorage.run({ accessToken: request.header("x-amz-access-token") }, async () => { - const validation = await validateRequest(request); - - if (validation.valid) { - const agentDefinition: AgentDefinition = AGENTS_DEFINITIONS_REGISTRY.get(validation.operationId); - const responseGenerationAgent = new Agent({ - structuredOutputSchema: ResponseSchema, - tools: agentDefinition.tools, - model: model, - }); - - try { - const responseResult = await responseGenerationAgent.invoke(agentDefinition.instructions(request, validation.result)); - printMetricsAndTraces(responseResult); - - const sandboxResponse = responseResult.structuredOutput as z.infer; - if (sandboxResponse.body) { - response.status(sandboxResponse.statusCode).json(JSON.parse(sandboxResponse.body)).send(); - } else response.status(sandboxResponse.statusCode).send(); - } catch (error) { - handleAgentError(error, response); + const result = await validateRequest(request); + + if (result.pass) { + const compositeKey = buildKey(result.apiName, result.apiVersion, result.operationId); + + const operationHandler = OPERATIONS_REGISTRY.get(compositeKey); + + if (operationHandler) { + if (!OPERATIONS_REGISTRY.isAllowedInCurrentMode(compositeKey)) { + response + .status(403) + .json({ + errors: [{ code: "OperationNotAllowedForMode", message: `Operation ${result.operationId} is not available for ${CURRENT_MODE}.` }], + }) + .send(); + return; + } + + try { + const operationContext = await operationHandler(result, request); + + if (operationContext.data.headers) response.setHeaders(operationContext.data.headers as Headers); + if (operationContext.data.body) { + response.status(operationContext.statusCode).json(operationContext.data.body).send(); + } else { + response.status(operationContext.statusCode).send(); + } + } catch (error) { + console.error("Error in operation handler:", error); + response.status(500).json({ errors: [{ code: "OperationProcessingFailure", message: "Operation processing failed" }] }).send(); + } + } else { + response.status(501).send(); } } else { - response.status(validation.statusCode!).json(validation.errors).send(); + if (result.body) { + response.status(result.statusCode).json(result.body).send(); + } else { + response.status(result.statusCode).send(); + } } }); }; + +/** + * Builds a handler that serves a stored document's raw content from a database + * partition, keyed by the `:documentId` route param. Reports and Data Kiosk share + * the same shape; they differ only in the partition and the default content-type + * used when a record does not specify one. + */ +const makeDocumentDownloadHandler = + (api: Api, defaultContentType: string) => + async (req: Request, res: Response): Promise => { + const doc = Context.instance.engine.get(api, req.params.documentId as string); + if (doc?.content === undefined) { + res.status(404).json({ errors: [{ code: "NotFound", message: "Document not found" }] }); + return; + } + res.setHeader("Content-Type", (doc.contentType as string) ?? defaultContentType); + res.status(200).send(doc.content); + }; + +export const downloadReportDocument = makeDocumentDownloadHandler(Api.REPORTS, "text/plain"); +export const downloadDataKioskDocument = makeDocumentDownloadHandler(Api.DATA_KIOSK, "application/jsonl"); diff --git a/local-ai-sandbox/src/database/Context.ts b/local-ai-sandbox/src/database/Context.ts index c571b129e..a6362f8c8 100644 --- a/local-ai-sandbox/src/database/Context.ts +++ b/local-ai-sandbox/src/database/Context.ts @@ -1,4 +1,6 @@ -import { Low, Memory } from "lowdb"; +import { DatabaseEngine } from "./DatabaseEngine.js"; +import { type DatabaseEngineConfig } from "./types.js"; +import { dbNamespaces } from "../registry/operationRegistry.js"; export enum Api { LISTINGS = "listings", @@ -10,170 +12,74 @@ export enum Api { CATALOG = "catalog", PRICING = "pricing", REPORTS = "reports", + LISTINGS_RESTRICTIONS = "listingsRestrictions", + PRODUCT_TYPE_DEFINITIONS = "productTypeDefinitions", + NOTIFICATIONS = "notifications", + DATA_KIOSK = "dataKiosk", } -type Data = Record>; +/** + * The `Api` enum is the typed surface used across the app; the registry is the generated source of + * truth for which namespaces exist. Fail fast if they diverge so adding an API to the registry + * without updating the enum (or vice versa) is caught at construction rather than silently. + */ +function assertEnumMatchesRegistry(): void { + const enumValues = [...Object.values(Api)].sort(); + const registryValues = dbNamespaces(); + const missingFromEnum = registryValues.filter((ns) => !enumValues.includes(ns as Api)); + const missingFromRegistry = enumValues.filter((v) => !registryValues.includes(v)); + if (missingFromEnum.length > 0 || missingFromRegistry.length > 0) { + throw new Error( + `Api enum is out of sync with the operation registry. ` + + `In registry but missing from Api enum: [${missingFromEnum.join(", ")}]. ` + + `In Api enum but missing from registry: [${missingFromRegistry.join(", ")}]. ` + + `Update the Api enum in Context.ts to match res/generated/operationRegistry.json.`, + ); + } +} -export class Context { - static #instance: Context; - readonly db: Low; +/** + * Reads DatabaseEngineConfig from environment variables. + * - DB_MODE: "memory" | "persistent" (default: "memory") + * - DB_FILE_PATH: path to the persistence file (required when DB_MODE is "persistent") + */ +function loadConfigFromEnvironment(): DatabaseEngineConfig { + const mode = process.env.DB_MODE === "persistent" ? "persistent" : "memory"; - private constructor() { - this.db = new Low(new Memory(), { - [Api.LISTINGS]: {}, - [Api.ORDERS]: {}, - [Api.INVENTORY]: {}, - [Api.EXT_FULFILLMENT_INVENTORY]: {}, - [Api.EXT_FULFILLMENT_RETURNS]: {}, - [Api.EXT_FULFILLMENT_SHIPMENTS]: {}, - [Api.CATALOG]: {}, - [Api.PRICING]: {}, - [Api.REPORTS]: {}, - }); - this.addSeedData(); + if (mode === "persistent") { + const filePath = process.env.DB_FILE_PATH; + if (!filePath) throw new Error("DB_MODE is 'persistent' but DB_FILE_PATH is not set."); + return { mode: "persistent", filePath }; } - public async clear() { - await this.db.read(); - for (const key of Object.keys(this.db.data) as (keyof typeof this.db.data)[]) { - this.db.data[key] = {}; - } - this.addSeedData(); - await this.db.write(); - } + return { mode: "memory" }; +} - private addSeedData() { - // Catalog seed data - this.db.data.catalog.B0F4X2K9LM = { - asin: "B0F4X2K9LM", - summaries: [ - { - marketplaceId: "ATVPDKIKX0DER", - brandName: "GameTech", - itemName: "Next-Gen Gaming Console - Upcoming Release", - manufacturer: "GameTech Electronics", - itemClassification: "BASE_PRODUCT", - productType: "VIDEO_GAME_CONSOLE", - }, - ], - identifiers: [ - { - marketplaceId: "ATVPDKIKX0DER", - identifiers: [ - { identifierType: "UPC", identifier: "012345678901" }, - { identifierType: "EAN", identifier: "0012345678901" }, - ], - }, - ], - images: [ - { - marketplaceId: "ATVPDKIKX0DER", - images: [{ variant: "MAIN", link: "https://m.media-amazon.com/images/I/example-console.jpg", height: 1000, width: 1000 }], - }, - ], - salesRanks: [{ marketplaceId: "ATVPDKIKX0DER", classificationRanks: [{ classificationId: "videogames", title: "Video Games", rank: 42 }] }], - dimensions: [ - { - marketplaceId: "ATVPDKIKX0DER", - item: { - height: { value: 3.9, unit: "INCHES" }, - length: { value: 15.4, unit: "INCHES" }, - width: { value: 12.0, unit: "INCHES" }, - weight: { value: 9.8, unit: "POUNDS" }, - }, - }, - ], - relationships: [{ marketplaceId: "ATVPDKIKX0DER", relationships: [] }], - }; - this.db.data.catalog.B0A7M3N5QR = { - asin: "B0A7M3N5QR", - summaries: [ - { - marketplaceId: "ATVPDKIKX0DER", - brandName: "BrewMaster", - itemName: "Premium Coffee Maker with Timer", - manufacturer: "BrewMaster Home", - itemClassification: "BASE_PRODUCT", - productType: "COFFEE_MAKER", - }, - ], - identifiers: [ - { - marketplaceId: "ATVPDKIKX0DER", - identifiers: [ - { identifierType: "UPC", identifier: "023456789012" }, - { identifierType: "EAN", identifier: "0023456789012" }, - ], - }, - ], - images: [ - { - marketplaceId: "ATVPDKIKX0DER", - images: [ - { variant: "MAIN", link: "https://m.media-amazon.com/images/I/example-coffee.jpg", height: 1200, width: 1200 }, - { variant: "PT01", link: "https://m.media-amazon.com/images/I/example-coffee-side.jpg", height: 1200, width: 1200 }, - ], - }, - ], - salesRanks: [{ marketplaceId: "ATVPDKIKX0DER", classificationRanks: [{ classificationId: "kitchen", title: "Kitchen & Dining", rank: 156 }] }], - dimensions: [ - { - marketplaceId: "ATVPDKIKX0DER", - item: { - height: { value: 14.2, unit: "INCHES" }, - length: { value: 8.5, unit: "INCHES" }, - width: { value: 6.8, unit: "INCHES" }, - weight: { value: 5.2, unit: "POUNDS" }, - }, - }, - ], - relationships: [{ marketplaceId: "ATVPDKIKX0DER", relationships: [] }], - }; - this.db.data.catalog.B0B8K4L7ST = { - asin: "B0B8K4L7ST", - summaries: [ - { - marketplaceId: "ATVPDKIKX0DER", - brandName: "HydroElite", - itemName: "Stainless Steel Water Bottle Set", - manufacturer: "HydroElite Outdoors", - itemClassification: "VARIATION_PARENT", - productType: "WATER_BOTTLE", - }, - ], - identifiers: [{ marketplaceId: "ATVPDKIKX0DER", identifiers: [{ identifierType: "UPC", identifier: "034567890123" }] }], - images: [ - { - marketplaceId: "ATVPDKIKX0DER", - images: [{ variant: "MAIN", link: "https://m.media-amazon.com/images/I/example-bottle.jpg", height: 1500, width: 1500 }], - }, - ], - salesRanks: [{ marketplaceId: "ATVPDKIKX0DER", classificationRanks: [{ classificationId: "sports", title: "Sports & Outdoors", rank: 89 }] }], - dimensions: [ - { - marketplaceId: "ATVPDKIKX0DER", - item: { - height: { value: 10.5, unit: "INCHES" }, - length: { value: 3.2, unit: "INCHES" }, - width: { value: 3.2, unit: "INCHES" }, - weight: { value: 0.75, unit: "POUNDS" }, - }, - }, - ], - relationships: [ - { - marketplaceId: "ATVPDKIKX0DER", - relationships: [{ childAsins: ["B0B8K4L7S1", "B0B8K4L7S2"], type: "VARIATION", variationTheme: { attributes: ["color", "size"] } }], - }, - ], - }; +export class Context { + static #instance: Context; + readonly engine: DatabaseEngine; + + private constructor() { + assertEnumMatchesRegistry(); + const resolvedConfig: DatabaseEngineConfig = loadConfigFromEnvironment(); + this.engine = new DatabaseEngine(resolvedConfig); } + /** Get or create the singleton */ public static get instance(): Context { if (!Context.#instance) { Context.#instance = new Context(); } - return Context.#instance; } + + /** Reset singleton for test isolation */ + public static reset(): void { + Context.#instance = undefined as unknown as Context; + } + + /** Clear all data */ + public clear(): void { + this.engine.clear(); + } } diff --git a/local-ai-sandbox/src/database/DatabaseEngine.ts b/local-ai-sandbox/src/database/DatabaseEngine.ts new file mode 100644 index 000000000..48aa868d1 --- /dev/null +++ b/local-ai-sandbox/src/database/DatabaseEngine.ts @@ -0,0 +1,248 @@ +import Loki from "lokijs"; +import { Api, type DatabaseEngineConfig, InvalidDomainError, InvalidKeyError } from "./types.js"; +import { TriggerProcessor } from "../trigger/TriggerProcessor.js"; +import { dbNamespaces } from "../registry/operationRegistry.js"; + +type DocumentRecord = Record; + +/** Write options. `silent` skips data-event emission, so no triggers fire. */ +export interface WriteOptions { + silent?: boolean; +} + +/** + * Core database engine wrapping LokiJS with typed CRUD operations, + * domain validation, and pluggable persistence. + */ +export class DatabaseEngine { + private db: Loki; + private collections: Map>; + private domains: Set; + + constructor(config: DatabaseEngineConfig) { + this.collections = new Map(); + this.domains = new Set(); + + if (config.mode === "persistent" && config.filePath) { + this.db = new Loki(config.filePath, { + autoload: true, + autoloadCallback: this.initialize, + autosave: true, + autosaveInterval: 4000, + persistenceMethod: "fs", + }); + } else { + this.db = new Loki("database.db"); + this.initialize(); + } + } + + /** + * Initialize all collections for known API domains. + * Creates a LokiJS collection per domain with a unique index on `_key`. + * In persistent mode, loads existing data from the storage file first. + */ + public initialize = () => { + this.domains = new Set(dbNamespaces()); + + for (const domain of dbNamespaces()) { + let collection: Collection = this.db.getCollection(domain); + collection ??= this.db.addCollection(domain, { unique: ["_key"] }); + this.collections.set(domain, collection); + } + }; + + /** + * Get a typed collection by domain name. + * Returns null if the domain has no associated collection. + */ + getCollection(domain: Api): Collection | null { + return this.collections.get(domain) ?? null; + } + + /** + * Insert or overwrite a document by key (upsert). + * Emits an INSERT or UPDATE data event unless `{ silent: true }` is passed. + * Triggers run detached, so the write returns without waiting for them. + * Throws InvalidDomainError if domain is not initialized. + * Throws InvalidKeyError if key is null, undefined, or empty. + */ + put(domain: Api, key: string, document: DocumentRecord, options: WriteOptions = {}): void { + this.validateDomain(domain); + this.validateKey(key); + + const collection = this.getValidatedCollection(domain); + const existing = collection.by("_key", key); + // `existing` is the live LokiJS object, so copy it out before overwriting. + const previous = existing ? this.stripInternalFields(existing) : undefined; + + if (existing) { + // Remove all non-internal fields from the existing document + for (const prop of Object.keys(existing)) { + if (prop !== "$loki" && prop !== "meta" && prop !== "_key") { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (existing as Record)[prop]; + } + } + // Apply new document fields + Object.assign(existing, document, { _key: key }); + collection.update(existing); + } else { + collection.insert({ ...document, _key: key }); + } + + if (!options.silent) { + this.emitDetached(previous ? "UPDATE" : "INSERT", domain, key, this.get(domain, key) ?? undefined, previous); + } + } + + /** + * Retrieve a document by key. + * Returns a clean copy without LokiJS internal fields, or null if not found. + */ + get(domain: Api, key: string): DocumentRecord | null { + this.validateDomain(domain); + this.validateKey(key); + + const collection = this.getValidatedCollection(domain); + const result = collection.by("_key", key); + + if (!result) { + return null; + } + + return this.stripInternalFields(result); + } + + /** + * Find matching documents for domain and query. + * Returns a clean copy without LokiJS internal fields. + */ + find(domain: Api, query: LokiQuery>): DocumentRecord[] { + const collection = this.getValidatedCollection(domain); + const docs = collection.find(query); + return docs.map((doc) => this.stripInternalFields(doc)); + } + + /** + * Remove a document by key. + * Emits a DELETE data event unless `{ silent: true }` is passed. + * Triggers run detached, exactly as for put, so the write returns without + * waiting for them. + * Returns true regardless of whether the key existed (desired end state achieved). + */ + remove(domain: Api, key: string, options: WriteOptions = {}): Promise { + // Kept promise-returning (rather than async) so callers and existing + // tests keep the awaitable contract, while validation errors still + // surface as a rejection rather than a synchronous throw. + try { + this.validateDomain(domain); + this.validateKey(key); + + const collection = this.getValidatedCollection(domain); + const existing = collection.by("_key", key); + + if (existing) { + const previous = this.stripInternalFields(existing); + collection.remove(existing); + if (!options.silent) { + this.emitDetached("DELETE", domain, key, undefined, previous); + } + } + return Promise.resolve(true); + } catch (error) { + return Promise.reject(error instanceof Error ? error : new Error(String(error))); + } + } + + /** + * Queues a data event for a later tick so the originating write returns + * first: trigger handlers are the sandbox analogue of Amazon's + * asynchronous downstream processing. Used by every write path, so + * INSERT, UPDATE and DELETE all have the same trigger semantics. + */ + private emitDetached( + type: "INSERT" | "UPDATE" | "DELETE", + domain: Api, + key: string, + entity: DocumentRecord | undefined, + previous: DocumentRecord | undefined, + ): void { + setImmediate(() => { + void TriggerProcessor.emit(type, domain, key, entity, previous).catch((error: unknown) => { + console.error("[Trigger] Unhandled failure processing %s for %s:%s", type, domain, key, error); + }); + }); + } + + /** + * Batch retrieve documents by keys. + * Returns a Map with the document (or null) for each requested key. + */ + getBatch(domain: Api, keys: string[]): Map { + this.validateDomain(domain); + + const result = new Map(); + const collection = this.getValidatedCollection(domain); + + for (const key of keys) { + this.validateKey(key); + const doc = collection.by("_key", key); + result.set(key, doc ? this.stripInternalFields(doc) : null); + } + + return result; + } + + /** + * Clear all data from all collections. + */ + clear(): void { + for (const collection of this.collections.values()) { + collection.clear(); + } + } + + /** + * Get a collection that is guaranteed to exist (called after validateDomain). + */ + private getValidatedCollection(domain: Api): Collection { + const collection = this.collections.get(domain); + if (!collection) { + throw new InvalidDomainError(domain); + } + return collection; + } + + /** + * Validate that the domain is one of the initialized domains. + */ + private validateDomain(domain: Api): void { + if (!this.domains.has(domain)) { + throw new InvalidDomainError(domain); + } + } + + /** + * Validate that the key is non-null, non-undefined, and non-empty. + */ + private validateKey(key: string | null | undefined): void { + if (key == null || key === "") { + const reason = key == null ? "key is null or undefined" : "key is empty"; + throw new InvalidKeyError(reason); + } + } + + /** + * Strip LokiJS internal fields ($loki, meta) from a document. + */ + private stripInternalFields(doc: DocumentRecord): DocumentRecord { + const result: DocumentRecord = {}; + for (const [key, value] of Object.entries(doc)) { + if (key !== "$loki" && key !== "meta") { + result[key] = value; + } + } + return result; + } +} diff --git a/local-ai-sandbox/src/database/types.ts b/local-ai-sandbox/src/database/types.ts new file mode 100644 index 000000000..afba635f3 --- /dev/null +++ b/local-ai-sandbox/src/database/types.ts @@ -0,0 +1,80 @@ +export { Api } from "./Context.js"; + +export interface DatabaseEngineConfig { + mode: "memory" | "persistent"; + filePath?: string; +} + +/** + * Separator between the parts of a composite primary key. The spec constrains + * neither seller IDs nor SKUs to exclude it, so a SKU containing this character + * could in principle produce an ambiguous key; escaping the parts is the fix if + * that ever matters in practice. + */ +const KEY_SEPARATOR = "|"; + +/** + * Joins the parts of a composite primary key, in key order, for partitions + * whose identifier is unique only within a scope — a listing's SKU is unique + * per seller, not globally. Data fidelity, not access control: the sandbox + * authenticates nobody. + */ +export function buildEntityKey(parts: string[]): string { + return parts.join(KEY_SEPARATOR); +} + +export interface StoredDocument { + _key: string; + _parentRef?: { + domain: string; + key: string; + }; + [field: string]: any; +} + +export interface DocumentMetadata { + _key: string; + _parentRef?: { + domain: string; + key: string; + }; +} + +export interface FieldQuery { + field: string; // Dot-notation path (e.g., "summaries.0.brandName") + value: any; // Exact match value + values?: any[]; // IN-style match (alternative to value) +} + +export interface QueryOptions { + fields?: string[]; // Projection: only return these fields +} + +export interface RelationshipQuery { + parentId?: string; // Find children of this parent + childId?: string; // Find parent of this child + parentDomain?: string; // Domain of the parent collection + childDomain?: string; // Domain of the child collection +} + +export class DatabaseError extends Error { + constructor( + message: string, + public readonly code: string, + ) { + super(message); + this.name = "DatabaseError"; + } +} + +export class InvalidDomainError extends DatabaseError { + constructor(domain: string) { + super(`Invalid API domain: "${domain}"`, "INVALID_DOMAIN"); + } +} + +export class InvalidKeyError extends DatabaseError { + constructor(reason: string) { + super(`Invalid document key: ${reason}`, "INVALID_KEY"); + } +} diff --git a/local-ai-sandbox/src/index.ts b/local-ai-sandbox/src/index.ts index ca681f4eb..545127d6f 100644 --- a/local-ai-sandbox/src/index.ts +++ b/local-ai-sandbox/src/index.ts @@ -1,95 +1,60 @@ import express, { Request, Response } from "express"; -import { createProxyMiddleware } from "http-proxy-middleware"; import { configureLogging } from "@strands-agents/sdk"; -import { Context } from "./database/Context.js"; +import { Context, Api } from "./database/Context.js"; +import { validate as validateOperationRegistry } from "./registry/operationRegistry.js"; import { AsyncLocalStorage } from "node:async_hooks"; import { generateData } from "./controller/dataGeneratorController.js"; -import { trackRequest, identifyApiSection, shutdownTelemetry } from "./service/telemetryService.js"; -import { - createReport, - getReport, - getReports, - cancelReport, - getReportDocument, - downloadReportDocument, - createReportSchedule, - getReportSchedule, - getReportSchedules, - cancelReportSchedule, -} from "./controller/reportsController.js"; +import { createOrder, updateOrder, deleteOrder } from "./controller/ordersManagementController.js"; +import { getNotificationSchemas, sendNotification } from "./controller/notificationsManagementController.js"; +import { downloadReportDocument, downloadDataKioskDocument } from "./controller/spapiController.js"; +import { listScenarios, seedScenario } from "./controller/scenariosController.js"; /** * APPLICATION SETUP */ const app = express(); -/** - * TELEMETRY MIDDLEWARE (before all routes) - */ -app.use((req, res, next) => { - const start = Date.now(); - res.on("finish", () => { - try { - trackRequest(identifyApiSection(req.path), Date.now() - start, res.statusCode); - } catch { - /* fail-safe */ - } - }); - next(); -}); - const port = process.env.PORT ?? "9001"; -const region = process.env.REGION && ["NA", "EU", "FE"].includes(process.env.REGION) ? process.env.REGION : "NA"; -export const PROD_BACKEND = `https://sellingpartnerapi-${region.toLowerCase()}.amazon.com`; configureLogging(console); export const asyncLocalStorage = new AsyncLocalStorage(); /** - * PASS-THROUGH CONFIGURATION (before body parser) - */ -const passThroughProxy = createProxyMiddleware({ - target: PROD_BACKEND, - changeOrigin: true, -}); - -app.get(["/definitions/2020-09-01/{*splat}", "/listings/2021-08-01/restrictions"], passThroughProxy); - -app.post("/batches/products/pricing/2022-05-01/items/competitiveSummary", passThroughProxy); - -/** - * SANDBOX PASS-THROUGH CONFIGURATION (before body parser) + * NON-SCHEMA ROUTES (download reports, data kiosk documents) */ -export const SANDBOX_BACKEND = `https://sandbox.sellingpartnerapi-${region.toLowerCase()}.amazon.com`; -const sandboxProxy = createProxyMiddleware({ - target: SANDBOX_BACKEND, - changeOrigin: true, -}); - -app.get("/fba/inventory/v1/summaries", sandboxProxy); -app.post("/fba/inventory/v1/items", sandboxProxy); -app.post("/fba/inventory/v1/items/inventory", sandboxProxy); -app.delete("/fba/inventory/v1/items/{*splat}", sandboxProxy); +app.get("/reports/download/:documentId", downloadReportDocument); +app.get("/dataKiosk/download/:documentId", downloadDataKioskDocument); /** - * BODY PARSER (after proxy routes) + * BODY PARSER */ app.use(express.json()); app.use(express.static("public")); +app.use(express.static("res/response")) /** * RETURN DB CONTENT */ -app.get("/data", async (request: Request, response: Response) => { - await Context.instance.db.read(); - const data = Context.instance.db.data; +app.get("/data", (request: Request, response: Response) => { + const data: Record> = {}; + for (const domain of Object.values(Api)) { + const collection = Context.instance.engine.getCollection(domain); + if (collection) { + const docs: Record = {}; + for (const doc of collection.find()) { + const { $loki, meta, _key, ...rest } = doc as Record; + docs[_key as string] = rest; + } + data[domain] = docs; + } + } response.status(200).json(data); }); /** * CLEAR DB CONTENT */ -app.delete("/data", async (request: Request, response: Response) => { - await Context.instance.clear(); +app.delete("/data", (request: Request, response: Response) => { + Context.instance.clear(); response.status(200).json({ message: "All data has been cleared." }); }); @@ -99,18 +64,23 @@ app.delete("/data", async (request: Request, response: Response) => { app.post("/chat", generateData); /** - * REPORTS API (deterministic, no AI agent) + * GUIDED SCENARIOS (pre-seeded, runnable SP-API journeys) */ -app.post("/reports/2021-06-30/reports", createReport); -app.get("/reports/2021-06-30/reports", getReports); -app.get("/reports/2021-06-30/reports/:reportId", getReport); -app.delete("/reports/2021-06-30/reports/:reportId", cancelReport); -app.get("/reports/2021-06-30/documents/:reportDocumentId", getReportDocument); -app.get("/reports/download/:documentId", downloadReportDocument); -app.post("/reports/2021-06-30/schedules", createReportSchedule); -app.get("/reports/2021-06-30/schedules", getReportSchedules); -app.get("/reports/2021-06-30/schedules/:reportScheduleId", getReportSchedule); -app.delete("/reports/2021-06-30/schedules/:reportScheduleId", cancelReportSchedule); +app.get("/scenarios", listScenarios); +app.post("/scenarios/:scenarioId/seed", seedScenario); + +/** + * ORDERS MANAGEMENT + */ +app.post("/manage/orders", createOrder); +app.put("/manage/orders", updateOrder); +app.delete("/manage/orders/:orderId", deleteOrder); + +/** + * NOTIFICATIONS MANAGEMENT + */ +app.get("/manage/notifications/schemas", getNotificationSchemas); +app.post("/manage/notifications/send", sendNotification); /** * GENERIC REQUEST HANDLER @@ -123,13 +93,6 @@ app.all("/{*splat}", async (req, res) => { /** * APPLICATION STARTUP */ -const shutdown = async () => { - await shutdownTelemetry(); - process.exit(0); -}; -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); - console.log(` _______ ___ ___ ____ / __/ _ \\ / _ | / _ \\/ _/ @@ -140,6 +103,10 @@ console.log(` /_/ /_/ |_/___/___/ /_/ `); +validateOperationRegistry(); +// Init database +Context.instance; + app.listen(port, () => { console.log(`App listening on port ${port}`); }); diff --git a/local-ai-sandbox/src/marketplaceIds.ts b/local-ai-sandbox/src/marketplaceIds.ts new file mode 100644 index 000000000..8eaf34c74 --- /dev/null +++ b/local-ai-sandbox/src/marketplaceIds.ts @@ -0,0 +1,72 @@ +/** + * Centralized marketplace ID definitions by region. + * + * NA: US, CA, MX, BR + * EU: UK, DE, FR, IT, ES, NL, SE, PL, TR, SA, AE, IN, EG, BE, ZA, NG + * FE: JP, AU, SG + */ + +export const MARKETPLACE_IDS_NA = ["ATVPDKIKX0DER", "A2EUQ1WTGCTBG2", "A1AM78C64UM0Y8", "A2Q3Y263D00KWC"] as const; + +export const MARKETPLACE_IDS_EU = [ + "A28R8C7NBKEWEA", + "A1RKKUPIHCS9HS", + "A1F83G8C2ARO7P", + "A13V1IB3VIYZZH", + "AMEN7PMS3EDWL", + "A1805IZSGTT6HS", + "A1PA6795UKMFR9", + "APJ6JRA9NG5V4", + "A2NODRKZP88ZB9", + "AE08WJ6YKNBMC", + "A1C3SOZRARQ6R3", + "ARBP9OOSHTCHU", + "A33AVAJ2PDY3EV", + "A17E79C6D8DWNP", + "A2VIGQ35RCS4UG", + "A21TJRUUN4KGV", +] as const; + +export const MARKETPLACE_IDS_FE = ["A1VC38T7YXB528", "A39IBJ37TRP1C6", "A19VAU5U5O7RUS"] as const; + +export const MARKETPLACE_IDS_ALL = [...MARKETPLACE_IDS_NA, ...MARKETPLACE_IDS_EU, ...MARKETPLACE_IDS_FE] as const; + +export const MARKETPLACE_IDS_BY_REGION: Record = { + NA: MARKETPLACE_IDS_NA, + EU: MARKETPLACE_IDS_EU, + FE: MARKETPLACE_IDS_FE, +}; + +// --- Marketplace-to-Currency Mapping --- + +export const MARKETPLACE_CURRENCY_MAP: Record = { + ATVPDKIKX0DER: "USD", // US + A2EUQ1WTGCTBG2: "CAD", // CA + A1AM78C64UM0Y8: "MXN", // MX + A2Q3Y263D00KWC: "BRL", // BR + A1VC38T7YXB528: "JPY", // JP + A39IBJ37TRP1C6: "AUD", // AU + A19VAU5U5O7RUS: "SGD", // SG + A1F83G8C2ARO7P: "GBP", // UK + A1PA6795UKMFR9: "EUR", // DE + A13V1IB3VIYZZH: "EUR", // FR + APJ6JRA9NG5V4: "EUR", // IT + AMEN7PMS3EDWL: "EUR", // ES + A1805IZSGTT6HS: "EUR", // NL + A2NODRKZP88ZB9: "SEK", // SE + A1C3SOZRARQ6R3: "PLN", // PL + AE08WJ6YKNBMC: "TRY", // TR + A33AVAJ2PDY3EV: "SAR", // SA + ARBP9OOSHTCHU: "AED", // AE + A21TJRUUN4KGV: "INR", // IN + A28R8C7NBKEWEA: "EUR", // BE +}; + +/** + * Returns the set of allowed marketplace IDs for the configured region. + * Reads REGION from process.env; defaults to "NA" if unset or invalid. + */ +export function getAllowedMarketplaceIds(): readonly string[] { + const region = process.env.REGION && ["NA", "EU", "FE"].includes(process.env.REGION) ? process.env.REGION : "NA"; + return MARKETPLACE_IDS_BY_REGION[region]; +} diff --git a/local-ai-sandbox/src/operation/catalogItemsOperations.ts b/local-ai-sandbox/src/operation/catalogItemsOperations.ts new file mode 100644 index 000000000..5e07d47e2 --- /dev/null +++ b/local-ai-sandbox/src/operation/catalogItemsOperations.ts @@ -0,0 +1,284 @@ +import type { OperationHandler } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; +import { Paginator } from "../service/Paginator.js"; + +const catalogPaginator = new Paginator({ defaultPageSize: 10, maxPageSize: 20 }); + +/** + * Paginates an array of items based on pageSize and an optional pageToken. + * + * - Default pageSize: 10 + * - Max pageSize: 20 (caps if exceeds) + * - Invalid or out-of-range pageToken results in empty page with correct numberOfResults + * - Generates nextToken if more items exist after current page + * - Generates previousToken if offset > 0 + */ +export function paginate( + items: T[], + pageSize: number, + pageToken?: string, +): { page: T[]; numberOfResults: number; nextToken?: string; previousToken?: string } { + return catalogPaginator.paginate(items, { pageSize, pageToken }); +} + +// --- includedData filtering logic for Catalog Items --- + +/** + * Valid includedData categories for Catalog Items API responses. + */ +export const INCLUDED_DATA_CATEGORIES = [ + "summaries", + "attributes", + "classifications", + "dimensions", + "identifiers", + "images", + "productTypes", + "relationships", + "salesRanks", + "vendorDetails", +] as const; + +/** + * Filters a stored catalog item to include only the `asin` field + * and the data categories specified in `includedData`. + * + * - Always includes the `asin` field. + * - For each value in `includedData`, includes the corresponding top-level key if it exists on the item. + * - Omits keys not in `includedData` (except `asin`). + * - Silently skips categories not present on the item (no error thrown). + */ +export function filterCatalogItem(item: Record, includedData: string[]): Record { + const result: Record = {}; + + // Always include asin + if ("asin" in item) { + result.asin = item.asin; + } + + // Include each requested data category if it exists on the item + for (const category of includedData) { + if (category in item) { + result[category] = item[category]; + } + } + + return result; +} + +// --- Utility helpers --- + +/** + * Escapes special regex characters in a string so it can be used safely in a RegExp. + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Parses a query parameter that may be a single string (possibly comma-separated) + * or an array of strings into a flat string array. Returns undefined if absent. + */ +function parseArrayParam(param: string | string[] | undefined): string[] | undefined { + if (param === undefined || param === "") { + return undefined; + } + if (Array.isArray(param)) { + const result = param.flatMap((v) => v.split(",")).filter((v) => v !== ""); + return result.length > 0 ? result : undefined; + } + const result = param.split(",").filter((v) => v !== ""); + return result.length > 0 ? result : undefined; +} + +// --- Handlers --- + +/** + * Parses the includedData query parameter, handling both comma-separated strings + * and arrays (depending on how Express parses repeated query params). + * Defaults to ["summaries"] if absent. + */ +function parseIncludedData(includedDataParam: string | string[] | undefined): string[] { + if (!includedDataParam) { + return ["summaries"]; + } + if (Array.isArray(includedDataParam)) { + // Express may split repeated params into an array; also handle comma-separated within each element + return includedDataParam.flatMap((v) => v.split(",")); + } + // Single string, possibly comma-separated + return includedDataParam.split(","); +} + +/** + * Handler for getCatalogItem (Catalog Items API v2022-04-01). + * + * Reads a catalog item from the local database and filters it + * to include only the requested includedData categories. + */ +export const getCatalogItemHandler: OperationHandler = async (validationResult) => { + const asin = validationResult.pathParams.asin; + const item = Context.instance.engine.get(Api.CATALOG, asin); + + const includedData = parseIncludedData(validationResult.queryParams.includedData); + const filteredItem = filterCatalogItem(item as Record, includedData); + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: filteredItem }, + }; +}; + +/** + * Matches a document against the keywords search mode. + * Case-insensitive substring match on summaries[].itemName or summaries[].brand. + */ +function matchesKeywords(doc: Record, keywordRegexes: RegExp[]): boolean { + const summaries = doc.summaries as { itemName?: string; brand?: string }[] | undefined; + if (!summaries) return false; + return keywordRegexes.some((re) => summaries.some((s) => (s.itemName && re.test(s.itemName)) || (s.brand && re.test(s.brand)))); +} + +/** + * Matches a document against the identifiers search mode. + * + * When identifiersType is "ASIN", matches against the document's `asin` field directly + * since ASINs are stored as the document key rather than in the identifiers array. + * + * For all other identifier types, performs an exact match on + * identifiers[].identifiers[].identifier for the given identifiersType. + */ +function matchesIdentifiers(doc: Record, identifierValues: string[], identifiersType: string | undefined): boolean { + if (identifiersType === "ASIN") { + const asin = (doc.asin ?? doc._key) as string | undefined; + return asin !== undefined && identifierValues.includes(asin); + } + + const idGroups = doc.identifiers as { identifiers?: { identifierType: string; identifier: string }[] }[] | undefined; + if (!idGroups) return false; + return idGroups.some((group) => group.identifiers?.some((id) => id.identifierType === identifiersType && identifierValues.includes(id.identifier))); +} + +/** + * Matches a document against the brandNames search mode. + * Case-insensitive exact match on summaries[].brand. + */ +function matchesBrandNames(doc: Record, brandRegexes: RegExp[]): boolean { + const summaries = doc.summaries as { brand?: string }[] | undefined; + if (!summaries) return false; + return brandRegexes.some((re) => summaries.some((s) => s.brand && re.test(s.brand))); +} + +/** + * Matches a document against a classificationIds filter. + * Checks if any classification in the document matches the provided IDs. + */ +function matchesClassificationIds(doc: Record, classificationIds: string[]): boolean { + const classGroups = doc.classifications as { classifications?: { classificationId: string }[] }[] | undefined; + if (!classGroups) return false; + return classGroups.some((group) => group.classifications?.some((c) => classificationIds.includes(c.classificationId))); +} + +/** + * Handler for searchCatalogItems (Catalog Items API v2022-04-01). + * + * Searches catalog items in the local database using one of three search modes: + * - Keywords: case-insensitive substring match on summaries[].itemName or summaries[].brand + * - Identifiers: exact match on identifiers[].identifiers[] for the specified identifiersType + * - BrandNames: case-insensitive exact match on summaries[].brand + * + * Optionally filters by classificationIds, applies pagination, and filters each + * result item by includedData. + */ +export const searchCatalogItemsHandler: OperationHandler = async (validationResult) => { + const qp = validationResult.queryParams; + + // Parse array query params + const keywords = parseArrayParam(qp.keywords); + const identifiers = parseArrayParam(qp.identifiers); + const identifiersType = qp.identifiersType as string | undefined; + const brandNames = parseArrayParam(qp.brandNames); + const classificationIds = parseArrayParam(qp.classificationIds); + const pageToken = qp.pageToken as string | undefined; + const pageSize = qp.pageSize ? Number(qp.pageSize) : 10; + + const allItems = Context.instance.engine.find(Api.CATALOG, {}); + + // Build a predicate based on the search mode + let searchPredicate: (doc: Record) => boolean; + + if (keywords && keywords.length > 0) { + const keywordRegexes = keywords.map((kw: string) => new RegExp(escapeRegex(kw), "i")); + if (brandNames && brandNames.length > 0) { + // brandNames narrows keyword results by requiring an exact brand match + const brandRegexes = brandNames.map((b: string) => new RegExp(`^${escapeRegex(b)}$`, "i")); + searchPredicate = (doc) => matchesKeywords(doc, keywordRegexes) && matchesBrandNames(doc, brandRegexes); + } else { + searchPredicate = (doc) => matchesKeywords(doc, keywordRegexes); + } + } else if (identifiers && identifiers.length > 0) { + searchPredicate = (doc) => matchesIdentifiers(doc, identifiers, identifiersType); + } else { + // No search criteria — return empty results + searchPredicate = () => false; + } + + // Apply search predicate and optional classificationIds filter + const results = allItems.filter((doc) => { + if (!searchPredicate(doc)) return false; + if (classificationIds && classificationIds.length > 0) { + return matchesClassificationIds(doc, classificationIds); + } + return true; + }); + + // Apply pagination + const paginationResult = paginate(results, pageSize, pageToken); + + // Parse includedData + const includedData = parseIncludedData(qp.includedData); + + // Filter each paginated item by includedData + const filteredItems = paginationResult.page.map((item) => filterCatalogItem(item as Record, includedData)); + + // Build pagination response object — only include if there are tokens + const pagination: Record = {}; + if (paginationResult.nextToken) { + pagination.nextToken = paginationResult.nextToken; + } + if (paginationResult.previousToken) { + pagination.previousToken = paginationResult.previousToken; + } + + const responseBody: Record = { + numberOfResults: paginationResult.numberOfResults, + items: filteredItems, + }; + + if (Object.keys(pagination).length > 0) { + responseBody.pagination = pagination; + } + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { + body: responseBody, + }, + }; +}; diff --git a/local-ai-sandbox/src/operation/dataKioskDatasets.ts b/local-ai-sandbox/src/operation/dataKioskDatasets.ts new file mode 100644 index 000000000..ade3da2bf --- /dev/null +++ b/local-ai-sandbox/src/operation/dataKioskDatasets.ts @@ -0,0 +1,250 @@ +import type { Mode } from "../registry/operationRegistry.js"; +import type { ParsedQuery } from "./dataKioskQueryParser.js"; + +/** + * Deterministic dataset registry for Data Kiosk Level B. + * + * Each entry declares which modes may query it, a representative static JSONL + * sample (for docs/tests), and a deterministic `generate` that turns a parsed + * query into JSONL. Adding a dataset requires no handler changes. + */ + +export type Outcome = "DATA" | "NO_DATA" | "FATAL"; + +export interface DatasetDefinition { + supportedModes: Mode[]; + /** Representative JSONL sample (one JSON object per line). */ + sample: string; + /** Deterministic JSONL generator. Returns "" to signal the no-data branch. */ + generate: (q: ParsedQuery) => string; + /** When true, any query for this dataset is classified FATAL (simulated failure). */ + alwaysFails?: boolean; +} + +/** + * Fixed reference "today" so that "range entirely in the future" (the no-data + * branch) is deterministic and independent of the wall clock. + */ +export const DATASET_REFERENCE_DATE = "2024-01-31"; + +/** Marker token that forces a query to a simulated FATAL processing failure. */ +export const FATAL_MARKER = "FATAL_TEST"; + +/** Deterministic seeded ASINs for salesAndTrafficByAsin generation. */ +const SEEDED_ASINS = ["B0SANDBOX01", "B0SANDBOX02", "B0SANDBOX03"]; + +// --- Deterministic helpers (no randomness) --- + +/** + * A stable non-negative 32-bit hash of a string, used to derive deterministic + * metrics. Uses `>>> 0` (unsigned right shift) to fold into the unsigned 32-bit + * range, which — unlike `Math.abs(h | 0)` — has no negative edge case + * (`Math.abs(-2147483648)` overflows back to a negative value). + */ +function hashString(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (h * 31 + s.charCodeAt(i)) | 0; + } + return h >>> 0; +} + +/** + * Maximum number of days a single query will materialize. Real Data Kiosk + * paginates large result sets; the sandbox does not implement result pagination, + * so this cap bounds document size and prevents a pathological wide date range + * from synthesizing an unbounded document in memory. + */ +export const MAX_GENERATED_ROWS = 400; + +/** + * Inclusive list of ISO dates (YYYY-MM-DD) from start to end, capped at the fixed + * reference date (the sandbox has no data for the future) and at MAX_GENERATED_ROWS + * days total. Empty if start > end, start is after the reference date, or the + * inputs are missing/invalid. + * + * All arithmetic is anchored to UTC midnight (`...T00:00:00Z`) and each day is + * emitted via `toISOString().slice(0, 10)`, so the iteration is DST-immune — + * adding a fixed 24h never skips or duplicates a calendar day. + */ +export function datesInRange(start: string | undefined, end: string | undefined): string[] { + if (!start || !end) return []; + const startMs = Date.parse(`${start}T00:00:00Z`); + const referenceMs = Date.parse(`${DATASET_REFERENCE_DATE}T00:00:00Z`); + // Cap the end at the reference date — no data exists beyond "today". + const endMs = Math.min(Date.parse(`${end}T00:00:00Z`), referenceMs); + if (Number.isNaN(startMs) || Number.isNaN(endMs) || startMs > endMs) return []; + const days: string[] = []; + const DAY = 24 * 60 * 60 * 1000; + for (let t = startMs; t <= endMs && days.length < MAX_GENERATED_ROWS; t += DAY) { + days.push(new Date(t).toISOString().slice(0, 10)); + } + return days; +} + +function marketplaceId(q: ParsedQuery): string { + return q.marketplaceIds[0] ?? "ATVPDKIKX0DER"; +} + +/** + * Groups the inclusive in-range dates into buckets according to `aggregateBy`, + * so generation can emit one row per period. Each bucket is `[startDate, endDate]` + * (the first and last in-range day of the period). + * + * - DAY (default / unknown): one bucket per day. + * - WEEK: buckets keyed by ISO year-week (Monday-based). + * - MONTH: buckets keyed by year-month. + * + * Buckets preserve chronological order and are clipped to the actual in-range + * days (a partial leading/trailing week or month spans only the days present). + */ +function bucketDates(dates: string[], aggregateBy: string | undefined): { startDate: string; endDate: string }[] { + const period = (aggregateBy ?? "DAY").toUpperCase(); + if (period !== "WEEK" && period !== "MONTH") { + return dates.map((d) => ({ startDate: d, endDate: d })); + } + + const keyOf = (isoDate: string): string => { + const ms = Date.parse(`${isoDate}T00:00:00Z`); + const d = new Date(ms); + if (period === "MONTH") { + return `${String(d.getUTCFullYear())}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; + } + // WEEK: key by the UTC Monday that starts the week. + const dayOfWeek = (d.getUTCDay() + 6) % 7; // 0 = Monday + const monday = new Date(ms - dayOfWeek * 24 * 60 * 60 * 1000); + return monday.toISOString().slice(0, 10); + }; + + const buckets = new Map(); + for (const date of dates) { + const key = keyOf(date); + const existing = buckets.get(key); + if (existing) { + existing.endDate = date; // dates are chronological, so this extends the bucket + } else { + buckets.set(key, { startDate: date, endDate: date }); + } + } + return [...buckets.values()]; +} + +// --- salesAndTraffic (Seller) generation --- + +function salesAndTrafficByDate(q: ParsedQuery): string { + const mp = marketplaceId(q); + return bucketDates(datesInRange(q.startDate, q.endDate), q.aggregateBy) + .map(({ startDate, endDate }) => { + const seed = hashString(`${startDate}:${endDate}:${mp}`); + const amount = 1000 + (seed % 9000) + (seed % 100) / 100; + return JSON.stringify({ + startDate, + endDate, + marketplaceId: mp, + sales: { + orderedProductSales: { amount: Number(amount.toFixed(2)), currencyCode: "USD" }, + unitsShipped: 20 + (seed % 80), + unitsOrdered: 25 + (seed % 90), + }, + traffic: { browserPageViews: 100 + (seed % 900), browserSessions: 50 + (seed % 500) }, + }); + }) + .join("\n"); +} + +function salesAndTrafficByAsin(q: ParsedQuery): string { + const mp = marketplaceId(q); + // Derive the emitted window from the reference-capped range so byAsin never + // reports dates beyond DATASET_REFERENCE_DATE (consistent with byDate). No rows + // for an empty/invalid range. + const days = datesInRange(q.startDate, q.endDate); + if (days.length === 0) return ""; + const rangeStart = days[0]; + const rangeEnd = days[days.length - 1]; + return SEEDED_ASINS.map((asin) => { + const seed = hashString(`${asin}:${mp}`); + const amount = 500 + (seed % 5000) + (seed % 100) / 100; + return JSON.stringify({ + parentAsin: asin, + childAsin: asin, + marketplaceId: mp, + startDate: rangeStart, + endDate: rangeEnd, + sales: { + orderedProductSales: { amount: Number(amount.toFixed(2)), currencyCode: "USD" }, + totalOrderItems: 1 + (seed % 50), + }, + traffic: { browserPageViews: 10 + (seed % 400), unitSessionPercentage: seed % 100 }, + }); + }).join("\n"); +} + +// --- vendorSales (Vendor-only) generation --- + +function vendorSalesByDate(q: ParsedQuery): string { + const mp = marketplaceId(q); + return bucketDates(datesInRange(q.startDate, q.endDate), q.aggregateBy) + .map(({ startDate, endDate }) => { + const seed = hashString(`vendor:${startDate}:${endDate}:${mp}`); + const revenue = 5000 + (seed % 45000) + (seed % 100) / 100; + return JSON.stringify({ + startDate, + endDate, + marketplaceId: mp, + shippedRevenue: { amount: Number(revenue.toFixed(2)), currencyCode: "USD" }, + orderedUnits: 100 + (seed % 900), + shippedUnits: 90 + (seed % 850), + }); + }) + .join("\n"); +} + +export const DATASETS: Record = { + analytics_salesAndTraffic_2024_04_24: { + supportedModes: ["Seller"], + // Represents both query-field schemas this dataset supports: byDate (2 lines) + // and byAsin (one line per seeded ASIN). + sample: [ + salesAndTrafficByDate({ + dataset: "analytics_salesAndTraffic_2024_04_24", + queryField: "salesAndTrafficByDate", + startDate: "2023-01-01", + endDate: "2023-01-02", + marketplaceIds: ["ATVPDKIKX0DER"], + raw: "", + }), + salesAndTrafficByAsin({ + dataset: "analytics_salesAndTraffic_2024_04_24", + queryField: "salesAndTrafficByAsin", + startDate: "2023-01-01", + endDate: "2023-01-02", + marketplaceIds: ["ATVPDKIKX0DER"], + raw: "", + }), + ].join("\n"), + generate: (q) => (q.queryField === "salesAndTrafficByAsin" ? salesAndTrafficByAsin(q) : salesAndTrafficByDate(q)), + }, + analytics_vendorSales: { + supportedModes: ["Vendor"], + sample: vendorSalesByDate({ + dataset: "analytics_vendorSales", + queryField: "vendorSalesByDate", + startDate: "2023-01-01", + endDate: "2023-01-02", + marketplaceIds: ["ATVPDKIKX0DER"], + raw: "", + }), + generate: (q) => vendorSalesByDate(q), + }, +}; + +/** + * Pure outcome classification, decided at createQuery time and stored on the + * Query_Record so the eventual DONE/FATAL transition is timing-independent. + * Assumes the dataset exists and is allowed for the current mode (validated earlier). + */ +export function classifyOutcome(q: ParsedQuery): Outcome { + const def = DATASETS[q.dataset]; + if (def.alwaysFails || q.raw.includes(FATAL_MARKER)) return "FATAL"; + return def.generate(q).length > 0 ? "DATA" : "NO_DATA"; +} diff --git a/local-ai-sandbox/src/operation/dataKioskOperations.ts b/local-ai-sandbox/src/operation/dataKioskOperations.ts new file mode 100644 index 000000000..baaeae8e4 --- /dev/null +++ b/local-ai-sandbox/src/operation/dataKioskOperations.ts @@ -0,0 +1,319 @@ +import { randomUUID } from "node:crypto"; +import { OperationHandler, OperationContext } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; +import { Paginator } from "../service/Paginator.js"; +import type { Mode } from "../registry/operationRegistry.js"; +import { parseGraphQLQuery, type ParsedQuery } from "./dataKioskQueryParser.js"; +import { DATASETS, classifyOutcome, type Outcome } from "./dataKioskDatasets.js"; + +/** + * Data Kiosk API (v2023-11-15) — local deterministic handlers. + * + * Mirrors the Reports API submit/poll/download pattern. All state lives in the + * DATA_KIOSK namespace, which holds two document kinds distinguished by an + * explicit `recordType` discriminator: + * - Query_Record: recordType "query", keyed by queryId + * - Document_Record: recordType "document", keyed by documentId, carries `content` + * + * Level B lifecycle: createQuery parses + validates the query, classifies its + * outcome (DATA | NO_DATA | FATAL), and stores it as IN_QUEUE with pollCount 0. + * Each getQuery poll advances IN_QUEUE -> IN_PROGRESS -> terminal; the terminal + * transition materializes the document(s). getQueries never advances the state. + */ + +/** Discriminator values for the two document kinds sharing the DATA_KIOSK namespace. */ +const RECORD_TYPE_QUERY = "query"; +const RECORD_TYPE_DOCUMENT = "document"; + +/** Data Kiosk getQueries pagination: default page size 10, max 100 (per the model's pageSize bounds). */ +const queriesPaginator = new Paginator({ defaultPageSize: 10, maxPageSize: 100 }); + +/** + * Poll count at which a query reaches a terminal state. + * poll 0 -> IN_QUEUE, poll 1 -> IN_PROGRESS, poll >= 2 -> terminal (DONE/FATAL). + */ +const LIFECYCLE_THRESHOLD = 2; + +/** + * Generates a short, prefixed id. Uses the first 8 hex chars of a UUID (32 bits + * of entropy) for readability. Collision probability is negligible at sandbox + * scale (birthday bound ~77k ids); ids are unique only in combination with a + * selling-partner account, matching the real Data Kiosk contract. + */ +function shortId(prefix: string): string { + return `${prefix}-${randomUUID().slice(0, 8).toUpperCase()}`; +} + +/** Internal bookkeeping fields never returned to the client. */ +function stripInternal(doc: Record): Record { + const { $loki, meta, _key, recordType, content, contentType, pollCount, parsed, outcome, ...rest } = doc; + return rest; +} + +/** Computes the processingStatus for a given pollCount + outcome. */ +function statusForPoll(pollCount: number, outcome: Outcome): "IN_QUEUE" | "IN_PROGRESS" | "DONE" | "FATAL" { + if (pollCount < 1) return "IN_QUEUE"; + if (pollCount < LIFECYCLE_THRESHOLD) return "IN_PROGRESS"; + return outcome === "FATAL" ? "FATAL" : "DONE"; +} + +/** + * Builds an OperationContext from the validation result, filling the repetitive + * metadata fields so each handler only supplies what differs (status + data). + */ +function buildContext( + validationResult: Parameters[0], + overrides: { statusCode: number; data: Record; body?: Record }, +): OperationContext { + return { + statusCode: overrides.statusCode, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: overrides.body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: overrides.data, + }; +} + +/** + * The current operating mode, read at call time so runtime changes to + * `process.env.MODE` are observed (used in tests). + * + * This must NOT import the `MODES`/`readModeFromEnv` VALUES from operationRegistry: + * operationRegistry imports this handler module, so a value import would form a + * runtime cycle and leave the registry's handler map undefined at construction. + * The valid-mode set is therefore inlined here (kept in sync with the registry's + * `Mode` type, imported type-only above). MODE is validated at boot by the + * registry; here we fall back to the "Seller" default for an unset/invalid value. + */ +const VALID_MODES: readonly Mode[] = ["Seller", "Vendor"]; +function currentMode(): Mode { + const raw = process.env.MODE; + return raw && VALID_MODES.includes(raw as Mode) ? (raw as Mode) : "Seller"; +} + +// --- createQuery --- + +/** Builds a 400 InvalidInput OperationContext for a rejected createQuery. */ +function createQueryError(validationResult: Parameters[0], body: Record, message: string): OperationContext { + return buildContext(validationResult, { + statusCode: 400, + body, + data: { body: { errors: [{ code: "InvalidInput", message }] } }, + }); +} + +export const createQueryHandler: OperationHandler = async (validationResult, request) => { + const body = (request.body ?? {}) as Record; + const query = body.query as string; + const paginationToken = body.paginationToken; + + // Parse the GraphQL query (the 8000-char cap is enforced by the pipeline). + const parsed = parseGraphQLQuery(query); + if (!parsed) { + return createQueryError(validationResult, body, "The provided query could not be parsed. It must select a top-level dataset."); + } + + const dataset = DATASETS[parsed.dataset]; + if (!dataset) { + return createQueryError(validationResult, body, `The dataset '${parsed.dataset}' is not supported.`); + } + if (!dataset.supportedModes.includes(currentMode())) { + return createQueryError(validationResult, body, `The dataset '${parsed.dataset}' is not available in the current mode '${currentMode()}'.`); + } + + // Decide the eventual outcome now, so the DONE/FATAL transition is timing-independent. + const outcome: Outcome = classifyOutcome(parsed); + + const queryId = shortId("DK"); + const now = new Date().toISOString(); + + Context.instance.engine.put(Api.DATA_KIOSK, queryId, { + recordType: RECORD_TYPE_QUERY, + queryId, + query, + ...(typeof paginationToken === "string" && paginationToken.length > 0 ? { paginationToken } : {}), + processingStatus: "IN_QUEUE", + createdTime: now, + pollCount: 0, + parsed, + outcome, + }); + + return buildContext(validationResult, { statusCode: 202, body, data: { body: { queryId } } }); +}; + +// --- getQuery --- + +/** True when a query is in a terminal state and should not advance further. */ +function isTerminal(status: unknown): boolean { + return status === "DONE" || status === "FATAL" || status === "CANCELLED"; +} + +/** + * Materializes the terminal outcome on the record: generates the data or error + * document, links its id, and sets the terminal processingStatus + timestamps. + * Mutates `record` in place. + */ +function materializeOutcome(record: Record, now: string): void { + const outcome = record.outcome as Outcome; + const parsed = record.parsed as ParsedQuery; + record.processingStartTime = record.processingStartTime ?? now; + record.processingEndTime = now; + + if (outcome === "DATA") { + const dataDocumentId = shortId("DKDOC"); + Context.instance.engine.put(Api.DATA_KIOSK, dataDocumentId, { + recordType: RECORD_TYPE_DOCUMENT, + documentId: dataDocumentId, + content: DATASETS[parsed.dataset].generate(parsed), + contentType: "application/jsonl", + }); + record.dataDocumentId = dataDocumentId; + record.processingStatus = "DONE"; + } else if (outcome === "FATAL") { + const errorDocumentId = shortId("DKDOC"); + Context.instance.engine.put(Api.DATA_KIOSK, errorDocumentId, { + recordType: RECORD_TYPE_DOCUMENT, + documentId: errorDocumentId, + content: JSON.stringify({ + errors: [{ code: "InternalFailure", message: `Query processing failed for dataset '${parsed.dataset}'.` }], + }), + contentType: "application/json", + }); + record.errorDocumentId = errorDocumentId; + record.processingStatus = "FATAL"; + } else { + // NO_DATA: DONE with neither document. + record.processingStatus = "DONE"; + } +} + +export const getQueryHandler: OperationHandler = async (validationResult) => { + const queryId = validationResult.pathParams.queryId; + + // Re-read the record from the database rather than mutating the resolved-entity + // copy in place: this keeps correctness independent of how the validation layer + // resolves entities (live reference vs. clone). The pipeline has already + // guaranteed the record exists; fall back to the resolved entity defensively. + const query = Context.instance.engine.get(Api.DATA_KIOSK, queryId) ?? validationResult.resolvedEntities.query; + + // Advance the lifecycle for non-terminal records; terminal records are returned unchanged. + if (!isTerminal(query.processingStatus)) { + const nextPollCount = ((query.pollCount as number | undefined) ?? 0) + 1; + query.pollCount = nextPollCount; + const outcome = (query.outcome as Outcome | undefined) ?? "DATA"; + const nextStatus = statusForPoll(nextPollCount, outcome); + const now = new Date().toISOString(); + + if (nextStatus === "IN_PROGRESS") { + query.processingStatus = "IN_PROGRESS"; + query.processingStartTime = query.processingStartTime ?? now; + } else if (nextStatus === "DONE" || nextStatus === "FATAL") { + materializeOutcome(query, now); + } else { + query.processingStatus = "IN_QUEUE"; + } + + Context.instance.engine.put(Api.DATA_KIOSK, queryId, query); + } + + return buildContext(validationResult, { statusCode: 200, data: { body: stripInternal(query) } }); +}; + +// --- getQueries --- + +export const getQueriesHandler: OperationHandler = async (validationResult) => { + const collection = Context.instance.engine.getCollection(Api.DATA_KIOSK); + const allDocs = collection ? collection.find().map((d) => d as Record) : []; + + // Keep only Query_Records via the explicit discriminator (with a legacy fallback + // for records written before recordType existed: has queryId, no content). + let queries = allDocs.filter((d) => + d.recordType !== undefined ? d.recordType === RECORD_TYPE_QUERY : typeof d.queryId === "string" && d.content === undefined, + ); + + // Filter: processingStatuses (array param or comma-separated string). + const rawStatuses = validationResult.queryParams.processingStatuses; + if (rawStatuses !== undefined) { + const statuses = Array.isArray(rawStatuses) ? rawStatuses : rawStatuses.split(","); + const statusSet = new Set(statuses.map((s) => s.trim()).filter(Boolean)); + if (statusSet.size > 0) { + queries = queries.filter((q) => statusSet.has(q.processingStatus as string)); + } + } + + // Filter: createdSince / createdUntil (inclusive) on createdTime. The schema + // declares these as date-time (openapi-enforcer rejects malformed values + // upstream); guard against NaN here so a bad value can never silently filter + // out every record. + const createdSince = validationResult.queryParams.createdSince as string | undefined; + const createdUntil = validationResult.queryParams.createdUntil as string | undefined; + if (createdSince) { + const since = Date.parse(createdSince); + if (!Number.isNaN(since)) { + queries = queries.filter((q) => Date.parse(q.createdTime as string) >= since); + } + } + if (createdUntil) { + const until = Date.parse(createdUntil); + if (!Number.isNaN(until)) { + queries = queries.filter((q) => Date.parse(q.createdTime as string) <= until); + } + } + + // Stable ordering (newest first) for deterministic pagination. + queries.sort((a, b) => Date.parse(b.createdTime as string) - Date.parse(a.createdTime as string)); + + // Paginate via the shared Paginator (default 10, max 100; graceful empty page on bad token). + const { page, nextToken } = queriesPaginator.paginate(queries, { + pageSize: validationResult.queryParams.pageSize as string | undefined, + pageToken: validationResult.queryParams.paginationToken as string | undefined, + }); + + const responseBody: Record = { queries: page.map(stripInternal) }; + if (nextToken !== undefined) { + responseBody.pagination = { nextToken }; + } + + return buildContext(validationResult, { statusCode: 200, data: { body: responseBody } }); +}; + +// --- cancelQuery --- + +export const cancelQueryHandler: OperationHandler = async (validationResult) => { + const queryId = validationResult.pathParams.queryId; + + // Re-read from the database (see getQueryHandler) rather than mutating the + // resolved-entity copy. The pipeline guarantees the query exists and is not + // DONE/FATAL. Already-CANCELLED is a no-op. + const query = Context.instance.engine.get(Api.DATA_KIOSK, queryId) ?? validationResult.resolvedEntities.query; + + if (query.processingStatus !== "CANCELLED") { + query.processingStatus = "CANCELLED"; + Context.instance.engine.put(Api.DATA_KIOSK, queryId, query); + } + + return buildContext(validationResult, { statusCode: 204, data: {} }); +}; + +// --- getDocument --- + +export const getDocumentHandler: OperationHandler = async (validationResult, request) => { + const documentId = validationResult.pathParams.documentId; + const host = request.get("host") ?? "localhost:9001"; + + return buildContext(validationResult, { + statusCode: 200, + data: { + body: { + documentId, + documentUrl: `http://${host}/dataKiosk/download/${documentId}`, + }, + }, + }); +}; diff --git a/local-ai-sandbox/src/operation/dataKioskQueryParser.ts b/local-ai-sandbox/src/operation/dataKioskQueryParser.ts new file mode 100644 index 000000000..3fe442990 --- /dev/null +++ b/local-ai-sandbox/src/operation/dataKioskQueryParser.ts @@ -0,0 +1,103 @@ +/** + * Dependency-free extractor for Data Kiosk GraphQL queries. + * + * Data Kiosk queries look like: + * {analytics_salesAndTraffic_2024_04_24{salesAndTrafficByDate(startDate:"2023-01-01" + * endDate:"2023-01-03" aggregateBy:DAY marketplaceIds:["ATVPDKIKX0DER"]){...}}} + * + * We do NOT run a real GraphQL engine — Level B only needs the top-level dataset, + * the inner query field, and a handful of scalar/list arguments to drive + * deterministic data generation. Extraction is done with targeted regexes. + * + * SCOPE / GUARANTEE: extraction is correct for the sandbox's supported shape — a + * single top-level dataset selection wrapping a single query field with FLAT + * arguments (`startDate`, `endDate`, `aggregateBy`, `marketplaceIds`). The + * argument extractors scan the whole query for the FIRST occurrence of each + * argument name; they are not selection-scoped, so a query with nested + * selections that repeat these argument names (e.g. a `startDate` inside a + * filter object) could mis-extract. That shape is not produced by the supported + * datasets. If richer datasets are added, replace these regexes with a + * selection-scoped extractor. + */ + +export interface ParsedQuery { + /** The top-level dataset selection, e.g. "analytics_salesAndTraffic_2024_04_24". */ + dataset: string; + /** The inner query field, e.g. "salesAndTrafficByDate" | "salesAndTrafficByAsin". */ + queryField?: string; + /** "YYYY-MM-DD" start date argument, when present. */ + startDate?: string; + /** "YYYY-MM-DD" end date argument, when present. */ + endDate?: string; + /** aggregateBy enum argument (unquoted), e.g. "DAY". */ + aggregateBy?: string; + /** marketplaceIds list argument; empty when absent. */ + marketplaceIds: string[]; + /** The original query string (used for marker-based outcome classification). */ + raw: string; +} + +/** + * Length of the query after collapsing insignificant whitespace, per the + * model's "at most 8000 characters after unnecessary whitespace is removed" rule. + * Runs of whitespace collapse to a single space and leading/trailing space is trimmed. + */ +export function normalizedLength(query: string): number { + return query.replace(/\s+/g, " ").trim().length; +} + +/** Identifier characters allowed in a GraphQL field/dataset name. */ +const IDENT = "[A-Za-z_][A-Za-z0-9_]*"; + +/** + * Parses a Data Kiosk GraphQL query into a ParsedQuery. + * Returns null when no top-level dataset selection can be found. + */ +export function parseGraphQLQuery(query: string): ParsedQuery | null { + if (typeof query !== "string") return null; + + // Strip an optional leading `query` keyword and operation name, then find the + // first selection name inside the outermost braces: `{ { ... } }`. + const datasetMatch = new RegExp(`\\{\\s*(${IDENT})\\b`).exec(query); + if (!datasetMatch) return null; + const dataset = datasetMatch[1]; + + // The inner query field is the next identifier that is immediately followed by + // `(` (arguments) or `{` (sub-selection) after the dataset. + const afterDataset = query.slice(datasetMatch.index + datasetMatch[0].length); + const fieldMatch = new RegExp(`\\{\\s*(${IDENT})\\s*[({]`).exec(afterDataset); + const queryField = fieldMatch ? fieldMatch[1] : undefined; + + const startDate = extractStringArg(query, "startDate"); + const endDate = extractStringArg(query, "endDate"); + const aggregateBy = extractEnumArg(query, "aggregateBy"); + const marketplaceIds = extractStringListArg(query, "marketplaceIds"); + + return { dataset, queryField, startDate, endDate, aggregateBy, marketplaceIds, raw: query }; +} + +/** Extracts a quoted scalar argument, e.g. `startDate:"2023-01-01"`. */ +function extractStringArg(query: string, name: string): string | undefined { + const m = new RegExp(`\\b${name}\\s*:\\s*"([^"]*)"`).exec(query); + return m ? m[1] : undefined; +} + +/** Extracts an unquoted enum argument, e.g. `aggregateBy:DAY`. */ +function extractEnumArg(query: string, name: string): string | undefined { + const m = new RegExp(`\\b${name}\\s*:\\s*(${IDENT})`).exec(query); + return m ? m[1] : undefined; +} + +/** Extracts a list-of-strings argument, e.g. `marketplaceIds:["A","B"]`. */ +function extractStringListArg(query: string, name: string): string[] { + const listMatch = new RegExp(`\\b${name}\\s*:\\s*\\[([^\\]]*)\\]`).exec(query); + if (!listMatch) return []; + const inner = listMatch[1]; + const result: string[] = []; + const itemRe = /"([^"]*)"/g; + let item: RegExpExecArray | null; + while ((item = itemRe.exec(inner)) !== null) { + result.push(item[1]); + } + return result; +} diff --git a/local-ai-sandbox/src/operation/extFulfillmentInventoryOperations.ts b/local-ai-sandbox/src/operation/extFulfillmentInventoryOperations.ts new file mode 100644 index 000000000..e365ffcf4 --- /dev/null +++ b/local-ai-sandbox/src/operation/extFulfillmentInventoryOperations.ts @@ -0,0 +1,256 @@ +import type { OperationHandler } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; + +interface InventoryRequestParams { + quantity?: number; + clientSequenceNumber?: number; + marketplaceAttributes?: { marketplaceId?: string; channelName?: string }; +} + +interface InventorySubResponse { + status: { statusCode: number; reasonPhrase: string }; + body: { + locationId?: string; + skuId?: string; + sellableQuantity?: number; + reservedQuantity?: number; + clientSequenceNumber?: number; + marketplaceAttributes?: { marketplaceId?: string; channelName?: string }; + actionableErrors: Array<{ errorType: string; errorSubType: string }>; + }; +} + +interface SubRequest { + uri: string; + method?: string; + body?: InventoryRequestParams; +} + +/** + * Parses a sub-request URI to extract the operation type (update/fetch/null), + * locationId, and skuId from the query parameters. + */ +function parseSubRequestUri(uri: string): { operation: "update" | "fetch" | null; locationId: string | null; skuId: string | null } { + let operation: "update" | "fetch" | null = null; + + if (uri.includes("/inventory/update")) { + operation = "update"; + } else if (uri.includes("/inventory/fetch")) { + operation = "fetch"; + } + + // Extract query params from the URI + let locationId: string | null = null; + let skuId: string | null = null; + + const queryIndex = uri.indexOf("?"); + if (queryIndex !== -1) { + const queryString = uri.substring(queryIndex + 1); + const params = new URLSearchParams(queryString); + locationId = params.get("locationId"); + skuId = params.get("skuId"); + } + + return { operation, locationId, skuId }; +} + +/** + * Processes an inventory update sub-request. + * Validates body fields, checks concurrency via stored clientSequenceNumber, + * and performs DB upsert. + */ +function processUpdate(locationId: string, skuId: string, body: InventoryRequestParams | undefined): InventorySubResponse { + // Validate quantity is present + if (body?.quantity === undefined || body.quantity === null) { + return { + status: { statusCode: 400, reasonPhrase: "Invalid Input" }, + body: { + locationId, + skuId, + actionableErrors: [{ errorType: "INVALID_INPUT", errorSubType: "Quantity is required for update operations" }], + }, + }; + } + + // Validate quantity is an integer + if (!Number.isInteger(body.quantity)) { + return { + status: { statusCode: 400, reasonPhrase: "Invalid Input" }, + body: { + locationId, + skuId, + actionableErrors: [{ errorType: "INVALID_INPUT", errorSubType: "Quantity must be an integer" }], + }, + }; + } + + // Validate quantity is non-negative + if (body.quantity < 0) { + return { + status: { statusCode: 400, reasonPhrase: "Invalid Input" }, + body: { + locationId, + skuId, + actionableErrors: [{ errorType: "INVALID_INPUT", errorSubType: "Quantity must be non-negative" }], + }, + }; + } + + // Validate clientSequenceNumber is present + if (body.clientSequenceNumber === undefined || body.clientSequenceNumber === null) { + return { + status: { statusCode: 400, reasonPhrase: "Invalid Input" }, + body: { + locationId, + skuId, + actionableErrors: [{ errorType: "INVALID_INPUT", errorSubType: "clientSequenceNumber is required for update operations" }], + }, + }; + } + + const compositeKey = `${locationId}:${skuId}`; + const existing = Context.instance.engine.get(Api.EXT_FULFILLMENT_INVENTORY, compositeKey); + + // Check concurrency: stale clientSequenceNumber + if (existing && (body.clientSequenceNumber as number) <= (existing.clientSequenceNumber as number)) { + return { + status: { statusCode: 400, reasonPhrase: "Invalid Input" }, + body: { + locationId, + skuId, + actionableErrors: [{ errorType: "STALE_DATA", errorSubType: "Client sequence number is not greater than the current value" }], + }, + }; + } + + // Perform upsert — preserve reservedQuantity on update + const reservedQuantity = existing ? (existing.reservedQuantity as number) : 0; + + const record: Record = { + locationId, + skuId, + sellableQuantity: body.quantity, + reservedQuantity, + clientSequenceNumber: body.clientSequenceNumber, + marketplaceAttributes: body.marketplaceAttributes, + }; + + Context.instance.engine.put(Api.EXT_FULFILLMENT_INVENTORY, compositeKey, record); + + return { + status: { statusCode: 200, reasonPhrase: "Success" }, + body: { + locationId, + skuId, + sellableQuantity: body.quantity, + reservedQuantity, + clientSequenceNumber: body.clientSequenceNumber, + marketplaceAttributes: body.marketplaceAttributes, + actionableErrors: [], + }, + }; +} + +/** + * Processes an inventory fetch sub-request. + * Looks up a record by composite key and returns success or INVALID_SKU error. + */ +function processFetch(locationId: string, skuId: string): InventorySubResponse { + const compositeKey = `${locationId}:${skuId}`; + const record = Context.instance.engine.get(Api.EXT_FULFILLMENT_INVENTORY, compositeKey); + + if (!record) { + return { + status: { statusCode: 400, reasonPhrase: "Invalid Input" }, + body: { + locationId, + skuId, + actionableErrors: [{ errorType: "INVALID_SKU", errorSubType: "SKU does not exist for the seller at the requested location" }], + }, + }; + } + + return { + status: { statusCode: 200, reasonPhrase: "Success" }, + body: { + locationId, + skuId, + sellableQuantity: record.sellableQuantity as number, + reservedQuantity: record.reservedQuantity as number, + clientSequenceNumber: record.clientSequenceNumber as number, + marketplaceAttributes: record.marketplaceAttributes as { marketplaceId?: string; channelName?: string } | undefined, + actionableErrors: [], + }, + }; +} + +/** + * Handler for External Fulfillment Inventory v2024-09-11 batchInventory. + * Processes batches of 1–10 sub-requests (update or fetch) against the LokiJS database, + * returning HTTP 207 Multi-Status with per-item success/error entries. + */ +export const batchInventoryHandler: OperationHandler = async (validationResult) => { + const body = validationResult.body as { requests: SubRequest[] } | undefined; + const requests = body?.requests ?? []; + const responses: InventorySubResponse[] = []; + + for (const subRequest of requests) { + try { + const { operation, locationId, skuId } = parseSubRequestUri(subRequest.uri ?? ""); + + // Unrecognized URI path + if (operation === null) { + responses.push({ + status: { statusCode: 400, reasonPhrase: "Invalid Input" }, + body: { + locationId: locationId ?? undefined, + skuId: skuId ?? undefined, + actionableErrors: [{ errorType: "INVALID_REQUEST", errorSubType: "Unrecognized sub-operation in URI path" }], + }, + }); + continue; + } + + // Missing locationId or skuId + if (!locationId || !skuId) { + responses.push({ + status: { statusCode: 400, reasonPhrase: "Invalid Input" }, + body: { + locationId: locationId ?? undefined, + skuId: skuId ?? undefined, + actionableErrors: [{ errorType: "INVALID_REQUEST", errorSubType: "Both locationId and skuId query parameters are required" }], + }, + }); + continue; + } + + // Dispatch to update or fetch + if (operation === "update") { + responses.push(processUpdate(locationId, skuId, subRequest.body)); + } else { + responses.push(processFetch(locationId, skuId)); + } + } catch { + // Per-item error isolation: unexpected errors + responses.push({ + status: { statusCode: 500, reasonPhrase: "Internal Server Error" }, + body: { + actionableErrors: [{ errorType: "INTERNAL_ERROR", errorSubType: "Unexpected processing failure" }], + }, + }); + } + } + + return { + statusCode: 207, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { responses } }, + }; +}; diff --git a/local-ai-sandbox/src/operation/extFulfillmentReturnsOperations.ts b/local-ai-sandbox/src/operation/extFulfillmentReturnsOperations.ts new file mode 100644 index 000000000..a24c2ab16 --- /dev/null +++ b/local-ai-sandbox/src/operation/extFulfillmentReturnsOperations.ts @@ -0,0 +1,158 @@ +import type { OperationHandler } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; +import { Paginator } from "../service/Paginator.js"; + +const returnsPaginator = new Paginator({ defaultPageSize: 10, maxPageSize: 100 }); + +/** + * Handler for External Fulfillment Returns v2024-09-11 listReturns. + * Queries the EXT_FULFILLMENT_RETURNS namespace with exact-match and date-range filtering, plus pagination. + */ +export const listReturnsHandler: OperationHandler = async (validationResult) => { + const qp = validationResult.queryParams; + + // Extract query params + const returnLocationId = qp.returnLocationId as string | undefined; + const rmaId = qp.rmaId as string | undefined; + const status = qp.status as string | undefined; + const reverseTrackingId = qp.reverseTrackingId as string | undefined; + const createdSince = qp.createdSince as string | undefined; + const createdUntil = qp.createdUntil as string | undefined; + const lastUpdatedSince = qp.lastUpdatedSince as string | undefined; + const lastUpdatedUntil = qp.lastUpdatedUntil as string | undefined; + const maxResultsRaw = qp.maxResults as string | undefined; + const nextToken = qp.nextToken as string | undefined; + + // Query all records from the EXT_FULFILLMENT_RETURNS namespace + const allItems = Context.instance.engine.find(Api.EXT_FULFILLMENT_RETURNS, {}); + + // Apply filters (all are combined as logical AND) + let filteredItems = allItems; + + if (returnLocationId !== undefined) { + filteredItems = filteredItems.filter((item) => item.returnLocationId === returnLocationId); + } + + if (rmaId !== undefined) { + filteredItems = filteredItems.filter((item) => { + const metadata = item.returnMetadata as Record | undefined; + return metadata?.rmaId === rmaId; + }); + } + + if (status !== undefined) { + filteredItems = filteredItems.filter((item) => item.status === status); + } + + if (reverseTrackingId !== undefined) { + filteredItems = filteredItems.filter((item) => { + const shippingInfo = item.returnShippingInfo as Record | undefined; + const reverseTrackingInfo = shippingInfo?.reverseTrackingInfo as Record | undefined; + return reverseTrackingInfo?.trackingId === reverseTrackingId; + }); + } + + if (createdSince !== undefined) { + const sinceTime = new Date(createdSince).getTime(); + filteredItems = filteredItems.filter((item) => { + const creationDateTime = item.creationDateTime as string | undefined; + if (!creationDateTime) return false; + return new Date(creationDateTime).getTime() >= sinceTime; + }); + } + + if (createdUntil !== undefined) { + const untilTime = new Date(createdUntil).getTime(); + filteredItems = filteredItems.filter((item) => { + const creationDateTime = item.creationDateTime as string | undefined; + if (!creationDateTime) return false; + return new Date(creationDateTime).getTime() <= untilTime; + }); + } + + if (lastUpdatedSince !== undefined) { + const sinceTime = new Date(lastUpdatedSince).getTime(); + filteredItems = filteredItems.filter((item) => { + const lastUpdatedDateTime = item.lastUpdatedDateTime as string | undefined; + if (!lastUpdatedDateTime) return false; + return new Date(lastUpdatedDateTime).getTime() >= sinceTime; + }); + } + + if (lastUpdatedUntil !== undefined) { + const untilTime = new Date(lastUpdatedUntil).getTime(); + filteredItems = filteredItems.filter((item) => { + const lastUpdatedDateTime = item.lastUpdatedDateTime as string | undefined; + if (!lastUpdatedDateTime) return false; + return new Date(lastUpdatedDateTime).getTime() <= untilTime; + }); + } + + // Apply pagination + const paginationResult = returnsPaginator.paginate(filteredItems, { pageSize: maxResultsRaw, pageToken: nextToken }); + + // Strip _key from each returned item + const returns = paginationResult.page.map((item) => { + const { _key, ...rest } = item; + return rest; + }); + + // Build response body + const responseBody: Record = { returns }; + + // Include nextToken when more pages exist + if (paginationResult.nextToken) { + responseBody.nextToken = paginationResult.nextToken; + } + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: responseBody }, + }; +}; + +/** + * Handler for External Fulfillment Returns v2024-09-11 getReturn. + * Reads the pre-resolved entity from the validation pipeline and strips the internal `_key` field. + */ +export const getReturnHandler: OperationHandler = async (validationResult) => { + const entity = validationResult.resolvedEntities["return"]; + + if (entity === undefined) { + return { + statusCode: 500, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InternalError", message: "Resolved entity 'return' is not available" }] } }, + }; + } + + const { _key: _, ...data } = entity; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: data }, + }; +}; diff --git a/local-ai-sandbox/src/operation/extFulfillmentShipmentsOperations.ts b/local-ai-sandbox/src/operation/extFulfillmentShipmentsOperations.ts new file mode 100644 index 000000000..18707fa34 --- /dev/null +++ b/local-ai-sandbox/src/operation/extFulfillmentShipmentsOperations.ts @@ -0,0 +1,589 @@ +import type { OperationHandler } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; +import { Paginator } from "../service/Paginator.js"; + +const shipmentsPaginator = new Paginator({ defaultPageSize: 10, maxPageSize: 100 }); + +/** + * Handler for External Fulfillment Shipments v2024-09-11 processShipment. + * Reads the resolved entity, applies CONFIRM or REJECT status transition, + * processes lineItem cancellations for REJECT, updates timestamp, and persists. + */ +export const processShipmentHandler: OperationHandler = async (validationResult) => { + const entity = validationResult.resolvedEntities["shipment"]; + + if (entity === undefined) { + return { + statusCode: 500, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InternalError", message: "Resolved entity 'shipment' is not available" }] } }, + }; + } + + const operation = validationResult.queryParams.operation as string; + + if (operation === "CONFIRM") { + entity.status = "CONFIRMED"; + } else if (operation === "REJECT") { + entity.status = "CANCELLED"; + + const body = validationResult.body as Record | undefined; + const lineItems = body?.lineItems as Array<{ lineItem: { id: string; quantity: number }; reason: string }> | undefined; + + if (lineItems && Array.isArray(lineItems)) { + const entityLineItems = entity.lineItems as Array> | undefined; + + if (entityLineItems && Array.isArray(entityLineItems)) { + for (const entry of lineItems) { + const matchingLineItem = entityLineItems.find((li) => li.id === entry.lineItem.id); + if (matchingLineItem) { + if (!Array.isArray(matchingLineItem.cancellations)) { + matchingLineItem.cancellations = []; + } + (matchingLineItem.cancellations as Array>).push({ + reason: entry.reason, + cancelledQuantity: entry.lineItem.quantity, + cancelledAt: new Date().toISOString(), + }); + } + // Non-matching IDs are skipped silently + } + } + } + } + + entity.lastUpdatedDateTime = new Date().toISOString(); + Context.instance.engine.put(Api.EXT_FULFILLMENT_SHIPMENTS, entity.id as string, entity); + + return { + statusCode: 204, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: {} }, + }; +}; + +/** + * Handler for External Fulfillment Shipments v2024-09-11 getShipments. + * Queries the EXT_FULFILLMENT_SHIPMENTS namespace with exact-match and date-range filtering, plus pagination. + */ +export const getShipmentsHandler: OperationHandler = async (validationResult) => { + const qp = validationResult.queryParams; + + // Extract query params + const status = qp.status as string | undefined; + const locationId = qp.locationId as string | undefined; + const marketplaceId = qp.marketplaceId as string | undefined; + const channelName = qp.channelName as string | undefined; + const lastUpdatedAfter = qp.lastUpdatedAfter as string | undefined; + const lastUpdatedBefore = qp.lastUpdatedBefore as string | undefined; + const maxResultsRaw = qp.maxResults as string | undefined; + const paginationToken = qp.paginationToken as string | undefined; + + // Query all records from the EXT_FULFILLMENT_SHIPMENTS namespace + const allItems = Context.instance.engine.find(Api.EXT_FULFILLMENT_SHIPMENTS, {}); + + // Apply filters (all are combined as logical AND) + let filteredItems = allItems; + + if (status !== undefined) { + filteredItems = filteredItems.filter((item) => item.status === status); + } + + if (locationId !== undefined) { + filteredItems = filteredItems.filter((item) => item.locationId === locationId); + } + + if (marketplaceId !== undefined) { + filteredItems = filteredItems.filter((item) => { + const marketplaceAttributes = item.marketplaceAttributes as Record | undefined; + return marketplaceAttributes?.marketplaceId === marketplaceId; + }); + } + + if (channelName !== undefined) { + filteredItems = filteredItems.filter((item) => { + const marketplaceAttributes = item.marketplaceAttributes as Record | undefined; + return marketplaceAttributes?.channelName === channelName; + }); + } + + if (lastUpdatedAfter !== undefined) { + const afterTime = new Date(lastUpdatedAfter).getTime(); + filteredItems = filteredItems.filter((item) => { + const lastUpdatedDateTime = item.lastUpdatedDateTime as string | undefined; + if (!lastUpdatedDateTime) return false; + return new Date(lastUpdatedDateTime).getTime() > afterTime; + }); + } + + if (lastUpdatedBefore !== undefined) { + const beforeTime = new Date(lastUpdatedBefore).getTime(); + filteredItems = filteredItems.filter((item) => { + const lastUpdatedDateTime = item.lastUpdatedDateTime as string | undefined; + if (!lastUpdatedDateTime) return false; + return new Date(lastUpdatedDateTime).getTime() < beforeTime; + }); + } + + // Apply pagination + const paginationResult = shipmentsPaginator.paginate(filteredItems, { pageSize: maxResultsRaw, pageToken: paginationToken }); + + // Strip _key from each returned item + const shipments = paginationResult.page.map((item) => { + const { _key, ...rest } = item; + return rest; + }); + + // Build response body + const responseBody: Record = { shipments }; + + // Include pagination.nextToken when more pages exist + if (paginationResult.nextToken) { + responseBody.pagination = { nextToken: paginationResult.nextToken }; + } + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: responseBody }, + }; +}; + +/** + * Handler for External Fulfillment Shipments v2024-09-11 updatePackage. + * Finds the package by packageId path param, replaces it with the request body (preserving ID), + * updates lastUpdatedDateTime, writes to DB, and returns 204. + */ +export const updatePackageHandler: OperationHandler = async (validationResult) => { + const entity = validationResult.resolvedEntities["shipment"]; + + if (entity === undefined) { + return { + statusCode: 500, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InternalError", message: "Resolved entity 'shipment' is not available" }] } }, + }; + } + + const packageId = validationResult.pathParams.packageId; + const body = validationResult.body as Record; + const packages = entity.packages as Array>; + + const packageIndex = packages.findIndex((pkg) => pkg.id === packageId); + if (packageIndex !== -1) { + packages[packageIndex] = { ...body, id: packageId }; + } + + entity.lastUpdatedDateTime = new Date().toISOString(); + Context.instance.engine.put(Api.EXT_FULFILLMENT_SHIPMENTS, entity.id as string, entity); + + return { + statusCode: 204, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: {} }, + }; +}; + +/** + * Handler for External Fulfillment Shipments v2024-09-11 getShipment. + * Reads the pre-resolved entity from the validation pipeline and strips the internal `_key` field. + */ +export const getShipmentHandler: OperationHandler = async (validationResult) => { + const entity = validationResult.resolvedEntities["shipment"]; + + if (entity === undefined) { + return { + statusCode: 500, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InternalError", message: "Resolved entity 'shipment' is not available" }] } }, + }; + } + + const { _key: _, ...data } = entity; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: data }, + }; +}; + +/** + * Handler for External Fulfillment Shipments v2024-09-11 updatePackageStatus. + * Applies optional status, subStatus, and reason fields to the target package, + * checks shipment-level status propagation, updates lastUpdatedDateTime, and returns 204. + */ +export const updatePackageStatusHandler: OperationHandler = async (validationResult) => { + const entity = validationResult.resolvedEntities["shipment"]; + + if (entity === undefined) { + return { + statusCode: 500, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InternalError", message: "Resolved entity 'shipment' is not available" }] } }, + }; + } + + const packageId = validationResult.pathParams.packageId; + const packages = entity.packages as Array>; + const pkg = packages.find((p) => p.id === packageId); + + const body = validationResult.body as Record | undefined; + + // Apply optional fields from body to the target package + if (body?.status !== undefined) { + pkg!.status = body.status; + } + if (body?.subStatus !== undefined) { + pkg!.subStatus = body.subStatus; + } + if (body?.reason !== undefined) { + pkg!.reason = body.reason; + } + + // Shipment status propagation + const effectiveStatus = (body?.status !== undefined ? body.status : pkg!.status) as string | undefined; + + if (effectiveStatus === "SHIPPED" && packages.every((p) => p.status === "SHIPPED")) { + entity.status = "SHIPPED"; + } else if (effectiveStatus === "DELIVERED" && packages.every((p) => p.status === "DELIVERED")) { + entity.status = "DELIVERED"; + } + + // Update timestamp and persist + entity.lastUpdatedDateTime = new Date().toISOString(); + Context.instance.engine.put(Api.EXT_FULFILLMENT_SHIPMENTS, entity.id as string, entity); + + return { + statusCode: 204, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: {} }, + }; +}; + +/** + * Handler for External Fulfillment Shipments v2024-09-11 retrieveShippingOptions. + * If the shipment's shippingInfo.shippingType is MARKETPLACE, returns a deterministic + * shipping option derived from shipmentId and packageId. Otherwise returns empty options. + */ +export const retrieveShippingOptionsHandler: OperationHandler = async (validationResult) => { + const entity = validationResult.resolvedEntities["shipment"]; + + if (entity === undefined) { + return { + statusCode: 500, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InternalError", message: "Resolved entity 'shipment' is not available" }] } }, + }; + } + + const shipmentId = validationResult.pathParams.shipmentId; + const packageId = validationResult.pathParams.packageId; + const shippingInfo = entity.shippingInfo as { shippingType?: string } | undefined; + + if (shippingInfo?.shippingType === "MARKETPLACE") { + const shippingOptionId = `so-${shipmentId}-${packageId}`; + const option = { + shippingOptionId, + carrierName: "ATS", + shipBy: "MARKETPLACE", + pickupWindow: { startTime: "1612933142", endTime: "1612494142" }, + timeSlot: { startTime: "1612933142", endTime: "1612494142", handoverMethod: "PICKUP" }, + }; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { shippingOptions: [option], recommendedShippingOption: option } }, + }; + } + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { shippingOptions: [] } }, + }; +}; + +/** + * Handler for External Fulfillment Shipments v2024-09-11 generateShipLabels. + * Generates a label entry for each packageId in the request body, sets shipment status + * to SHIPLABEL_GENERATED, updates lastUpdatedDateTime, and writes to DB. + */ +export const generateShipLabelsHandler: OperationHandler = async (validationResult, request) => { + const entity = validationResult.resolvedEntities["shipment"]; + + if (entity === undefined) { + return { + statusCode: 500, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InternalError", message: "Resolved entity 'shipment' is not available" }] } }, + }; + } + + const body = validationResult.body as { packageIds?: string[]; courierSupportedAttributes?: { carrierName?: string; trackingId?: string } } | undefined; + const packageIds = body?.packageIds ?? []; + const carrierName = body?.courierSupportedAttributes?.carrierName ?? ""; + const trackingId = body?.courierSupportedAttributes?.trackingId ?? ""; + const host = request.get("host") ?? "localhost:9001"; + + const packageShipLabelList = packageIds.map((packageId) => ({ + packageId, + shipLabelMetadata: { carrierName, trackingId }, + fileData: { url: `http://${host}/label.png` }, + status: "SUCCESS", + })); + + entity.status = "SHIPLABEL_GENERATED"; + entity.lastUpdatedDateTime = new Date().toISOString(); + Context.instance.engine.put(Api.EXT_FULFILLMENT_SHIPMENTS, entity.id as string, entity); + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { packageShipLabelList } }, + }; +}; + +/** + * Handler for External Fulfillment Shipments v2024-09-11 createPackages. + * Initializes the packages array if absent, appends new packages from the request body, + * sets status to PACKAGE_CREATED, updates lastUpdatedDateTime, and writes to DB. + */ +/** + * Handler for External Fulfillment Shipments v2024-09-11 generateInvoice. + * Sets shipmentRequirements.invoice.status to AVAILABLE, updates timestamp, writes to DB, + * and returns the static invoice document reference. + */ +export const generateInvoiceHandler: OperationHandler = async (validationResult, request) => { + const entity = validationResult.resolvedEntities["shipment"]; + + if (entity === undefined) { + return { + statusCode: 500, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InternalError", message: "Resolved entity 'shipment' is not available" }] } }, + }; + } + + // Set nested path shipmentRequirements.invoice.status = "AVAILABLE" (creating intermediates if absent) + if (!entity.shipmentRequirements) entity.shipmentRequirements = {}; + const requirements = entity.shipmentRequirements as Record; + if (!requirements.invoice) requirements.invoice = {}; + (requirements.invoice as Record).status = "AVAILABLE"; + + entity.lastUpdatedDateTime = new Date().toISOString(); + Context.instance.engine.put(Api.EXT_FULFILLMENT_SHIPMENTS, entity.id as string, entity); + const host = request.get("host") ?? "localhost:9001"; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { document: { format: "PDF", content: `http://${host}/invoice.pdf` } } }, + }; +}; + +/** + * Handler for External Fulfillment Shipments v2024-09-11 retrieveInvoice. + * Returns the static invoice document reference without modifying the database. + */ +export const retrieveInvoiceHandler: OperationHandler = async (validationResult, request) => { + const entity = validationResult.resolvedEntities["shipment"]; + + if (entity === undefined) { + return { + statusCode: 500, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InternalError", message: "Resolved entity 'shipment' is not available" }] } }, + }; + } + + const host = request.get("host") ?? "localhost:9001"; + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { document: { format: "PDF", content: `http://${host}/invoice.pdf` } } }, + }; +}; + +/** + * Handler for External Fulfillment Shipments v2024-09-11 createPackages. + * Initializes the packages array if absent, appends new packages from the request body, + * sets status to PACKAGE_CREATED, updates lastUpdatedDateTime, and writes to DB. + */ +export const createPackagesHandler: OperationHandler = async (validationResult) => { + const entity = validationResult.resolvedEntities["shipment"]; + + if (entity === undefined) { + return { + statusCode: 500, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InternalError", message: "Resolved entity 'shipment' is not available" }] } }, + }; + } + + // Initialize packages array if undefined or null + if (!entity.packages) { + entity.packages = []; + } + + // Append each package from the request body + const body = validationResult.body as { packages?: Record[] } | undefined; + const newPackages = body?.packages ?? []; + for (const pkg of newPackages) { + (entity.packages as Record[]).push(pkg); + } + + // Update status and timestamp + entity.status = "PACKAGE_CREATED"; + entity.lastUpdatedDateTime = new Date().toISOString(); + + // Persist to database + Context.instance.engine.put(Api.EXT_FULFILLMENT_SHIPMENTS, entity.id as string, entity); + + return { + statusCode: 204, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: {} }, + }; +}; diff --git a/local-ai-sandbox/src/operation/fbaInventoryOperations.ts b/local-ai-sandbox/src/operation/fbaInventoryOperations.ts new file mode 100644 index 000000000..58e20ee83 --- /dev/null +++ b/local-ai-sandbox/src/operation/fbaInventoryOperations.ts @@ -0,0 +1,96 @@ +import type { OperationHandler } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; +import { Paginator } from "../service/Paginator.js"; + +const inventoryPaginator = new Paginator({ defaultPageSize: 50, maxPageSize: 50 }); + +/** + * Handler for FBA Inventory v1 getInventorySummaries. + * Queries the INVENTORY namespace with SKU/date filtering, pagination, and detail toggle. + */ +export const getInventorySummariesHandler: OperationHandler = async (validationResult) => { + const qp = validationResult.queryParams; + + // Extract query params + const sellerSkus = qp.sellerSkus as string | string[] | undefined; + const sellerSku = qp.sellerSku as string | undefined; + const startDateTime = qp.startDateTime as string | undefined; + const granularityType = qp.granularityType as string; + const granularityId = qp.granularityId as string; + const details = qp.details as string | undefined; + const nextToken = qp.nextToken as string | undefined; + + // Query all items from the INVENTORY namespace + const allItems = Context.instance.engine.find(Api.INVENTORY, {}); + + // Determine filter strategy by priority: startDateTime > sellerSkus > sellerSku > all + let filterPredicate: (item: Record) => boolean; + + if (startDateTime) { + // Filter by lastUpdatedTime strictly after startDateTime + const startTime = new Date(startDateTime).getTime(); + filterPredicate = (item) => { + const lastUpdated = item.lastUpdatedTime as string | undefined; + if (!lastUpdated) return false; + return new Date(lastUpdated).getTime() > startTime; + }; + } else if (sellerSkus) { + // Parse sellerSkus (may be array or comma-separated string) + const skuList = Array.isArray(sellerSkus) ? sellerSkus : sellerSkus.split(",").filter((s) => s !== ""); + filterPredicate = (item) => { + const sku = item.sellerSku as string | undefined; + return sku !== undefined && skuList.includes(sku); + }; + } else if (sellerSku) { + filterPredicate = (item) => item.sellerSku === sellerSku; + } else { + // No filter — return all items + filterPredicate = () => true; + } + + // Apply filter + const filteredItems = allItems.filter(filterPredicate); + + // Apply details toggle: strip inventoryDetails when details is not "true" + const items = filteredItems.map((item) => { + const copy = { ...item }; + if (details !== "true") { + delete copy.inventoryDetails; + } + return copy; + }); + + // Apply pagination + const paginationResult = inventoryPaginator.paginate(items, { pageToken: nextToken }); + + // Build response body + const responseBody: Record = { + payload: { + granularity: { + granularityType, + granularityId, + }, + inventorySummaries: paginationResult.page, + }, + }; + + // Include pagination.nextToken when more pages exist + if (paginationResult.nextToken) { + responseBody.pagination = { + nextToken: paginationResult.nextToken, + }; + } + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: responseBody }, + }; +}; diff --git a/local-ai-sandbox/src/operation/listingsItemModel.ts b/local-ai-sandbox/src/operation/listingsItemModel.ts new file mode 100644 index 000000000..660a3b5e6 --- /dev/null +++ b/local-ai-sandbox/src/operation/listingsItemModel.ts @@ -0,0 +1,739 @@ +/** + * Listings Item data model — stored document shape, derivation rules, and + * patch/merge semantics for the Listings Items API v2021-08-01. + * + * Two-layer model: + * - `attributes` is the raw seller submission, mutated only by put/patch — + * never by triggers, so a read returns exactly what was submitted. + * - System data is STORED (issues, mfnAvailability, asin, dates) or DERIVED + * at read time (summaries, status, offers, fulfillmentAvailability, + * procurement, relationships). Deriving avoids the two layers drifting + * apart. Every derivation reads one document except `relationships`, where + * half of each relationship is recorded on the listing at the other end. + */ +import { Api, Context } from "../database/Context.js"; +import { buildEntityKey } from "../database/types.js"; + +/** + * Primary key of a listing. A SKU is unique per seller, so both parts are + * required; every access to the listings partition goes through this, or two + * sellers sharing a SKU collide on one record. Identity, not access control — + * the sandbox authenticates nobody. + */ +export function listingKey(sellerId: string, sku: string): string { + return buildEntityKey([sellerId, sku]); +} + +// --- Types --- + +export interface ListingIssue { + code: string; + message: string; + severity: "ERROR" | "WARNING" | "INFO"; + attributeNames?: string[]; + categories: string[]; + enforcements?: { actions: { action: string }[]; exemption?: { status: string } }; +} + +export interface MfnAvailabilityEntry { + fulfillmentChannelCode: string; + quantity: number; +} + +/** + * A `fulfillmentAvailability` entry as reported to the caller. `quantity` is + * absent for an FBA channel, which Amazon manages and does not echo back. + */ +export interface FulfillmentAvailabilityEntry { + fulfillmentChannelCode: string; + quantity?: number; +} + +/** + * The stored listing document (DB layer). + * + * `_key` is the composite primary key (seller + SKU), because a SKU is only + * unique within one seller — two sellers routinely use the same SKU, and + * keying by SKU alone would make them collide on a single record. Use + * `listingKey()` to build it, and `sku` (never `_key`) whenever the seller's + * own identifier is what is meant. + */ +export interface ListingDoc { + _key: string; + sku: string; + sellerId: string; + productType: string; + requirements?: string; + /** Amazon store the submission was made against. */ + marketplaceId: string; + attributes: Record; + /** System-managed: set from validation results, never seller-submitted. */ + issues: ListingIssue[]; + /** Resolved (offer-only) or generated (new product) catalog identity. */ + asin?: string; + /** + * Live MFN inventory ledger. Seeded/reset when the seller submits + * fulfillment_availability quantities; reduced by order triggers. + * FBA quantities live in the FBA inventory partition, never here. + */ + mfnAvailability: MfnAvailabilityEntry[]; + createdDate: string; + lastUpdatedDate: string; +} + +// --- Constants --- + +/** + * Sales-term (offer) attributes, from the "offer" property group of the + * PRODUCT-rooted product type definitions. Seller PUT semantics: product + * facts are REPLACED, but sales terms are MERGED — omitting a sales-term + * attribute does NOT remove previously submitted values. + * (Building Listings Management Workflows Guide, "Update a listing".) + */ +export const SALES_TERM_ATTRIBUTES = new Set([ + "purchasable_offer", + "fulfillment_availability", + "condition_type", + "condition_note", + "list_price", + "product_tax_code", + "merchant_release_date", + "merchant_shipping_group", + "max_order_quantity", + "gift_options", +]); + +/** + * Patch `merge` is only supported for these attributes, keyed by their + * selector fields. Merge on any other attribute is rejected (sync INVALID). + * (Merge a listing + Manage purchasable offer docs.) + */ +const MERGEABLE_ATTRIBUTE_SELECTORS: Record = { + fulfillment_availability: ["fulfillment_channel_code"], + purchasable_offer: ["marketplace_id", "currency", "audience"], +}; + +/** + * Selector fields across listings attributes. An attribute instance is + * addressed by whichever of these it carries, which is what makes + * add/replace/delete operate on a single instance rather than on the whole + * attribute. + */ +const SELECTOR_FIELDS = new Set(["marketplace_id", "language_tag", "currency", "audience", "fulfillment_channel_code"]); + +/** Sub-attribute that can never be deleted via merge-null. */ +const NON_NULLABLE_MERGE_FIELDS = new Set(["our_price"]); + +/** + * Reported when a submission names an ASIN that does not match a catalog + * item. Publicly documented in the Listings Items API issues + * troubleshooting guide. + */ +export const ISSUE_CODE_ASIN_MISMATCH = "4005015"; + +/** + * Reported when a submission cannot be matched to an existing ASIN and + * carries too little product data to create a new one. Publicly documented + * and observed on asynchronous processing of a sparse submission. + */ +export const ISSUE_CODE_UNMATCHABLE = "8560"; + +/** Generic "provided value is invalid" code from the troubleshooting guide. */ +export const ISSUE_CODE_INVALID_VALUE = "4000001"; + +type AttrInstance = Record; + +/** Safe string coercion for unknown values (avoids '[object Object]'). */ +function asString(value: unknown, fallback: string): string { + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + return fallback; +} + +/** Reads the first instance's `value` (or given field) of a listings attribute. */ +function firstAttrValue(attributes: Record, name: string, field = "value"): unknown { + const instances = attributes[name]; + if (!Array.isArray(instances) || instances.length === 0) return undefined; + const first = instances[0] as AttrInstance; + return first[field]; +} + +// --- Seller PUT attribute semantics --- + +/** + * Computes the final attributes for a PUT submission. + * Product facts: full replacement (omitted => dropped). + * Sales terms: attribute-level merge (omitted => previous value retained; + * provided => replaced). + */ +export function applySellerPutSemantics( + previousAttributes: Record | undefined, + submittedAttributes: Record, +): Record { + const result: Record = { ...submittedAttributes }; + if (!previousAttributes) return result; + + // Only sales terms are retained when omitted. + for (const [name, value] of Object.entries(previousAttributes)) { + if (result[name] === undefined && SALES_TERM_ATTRIBUTES.has(name)) { + result[name] = value; + } + } + return result; +} + +// --- Patch operations --- + +export interface ListingsPatchOperation { + op: "add" | "replace" | "merge" | "delete"; + path: string; + value?: unknown[]; +} + +export interface PatchApplicationResult { + ok: boolean; + attributes: Record; + /** Populated when ok=false: sync issues to return with status INVALID. */ + issues: ListingIssue[]; +} + +/** Extracts the attribute name from a JSON Pointer path like "/attributes/item_name". */ +function attributeNameFromPath(path: string): string | null { + const parts = path.split("/").filter(Boolean); + if (parts.length === 2 && parts[0] === "attributes") return parts[1]; + if (parts.length === 1) return parts[0]; + return null; // deeper pointers unsupported: "patching content within attributes is not supported" +} + +function invalidPatch(message: string, attributeName?: string): ListingIssue { + return { + code: ISSUE_CODE_INVALID_VALUE, + message, + severity: "ERROR", + categories: ["INVALID_ATTRIBUTE"], + ...(attributeName ? { attributeNames: [attributeName] } : {}), + }; +} + +/** True when every selector field present on `candidate` matches `existing`. */ +function selectorsMatch(existing: AttrInstance, candidate: AttrInstance, selectors: string[]): boolean { + return selectors.every((sel) => candidate[sel] === undefined || existing[sel] === candidate[sel]); +} + +/** Selector fields carried by an attribute instance. */ +function selectorsOf(instance: AttrInstance): string[] { + return Object.keys(instance).filter((field) => SELECTOR_FIELDS.has(field)); +} + +/** + * Applies an add/replace to a single attribute. + * + * When the submitted instances carry selector fields, each one replaces the + * matching existing instance wholesale — sub-attributes omitted from the + * submitted instance are dropped from it (that is the difference from + * `merge`), but instances the patch does not address are left untouched. + * Instances matching nothing are appended. With no selectors to address an + * instance by, the whole attribute is replaced. + */ +function applyReplace(existing: unknown, submitted: unknown[] | undefined): unknown { + if (!Array.isArray(existing) || submitted === undefined) return submitted; + + const instances = [...(existing as AttrInstance[])]; + let addressedAny = false; + + for (const candidate of submitted as AttrInstance[]) { + const selectors = selectorsOf(candidate); + if (selectors.length === 0) continue; + addressedAny = true; + const index = instances.findIndex((inst) => selectorsMatch(inst, candidate, selectors)); + if (index === -1) { + instances.push(candidate); + } else { + instances[index] = candidate; + } + } + + return addressedAny ? instances : submitted; +} + +/** + * Applies JSON Patch operations to the attributes layer, honoring + * production semantics: + * - add / replace: replaces the selector-addressed instances, or the whole + * attribute when the value carries no selectors. + * - delete: with a value, removes the instances matching the provided + * selector objects; without a value, removes the whole attribute. + * - merge: only for fulfillment_availability and purchasable_offer. + * Selector-keyed instance merge that preserves omitted sub-attributes; + * explicit null deletes a field (except our_price); unmatched selector + * instances are appended. + * + * Returns a new attributes object; never mutates the input. + */ +export function applyPatches(attributes: Record, patches: ListingsPatchOperation[]): PatchApplicationResult { + const result: Record = structuredClone(attributes); + + for (const patch of patches) { + const attrName = attributeNameFromPath(patch.path); + if (!attrName) { + return { + ok: false, + attributes, + issues: [invalidPatch(`Unsupported patch path '${patch.path}'. Patching content within attributes is not supported.`)], + }; + } + + switch (patch.op) { + case "add": + case "replace": + result[attrName] = applyReplace(result[attrName], patch.value); + break; + + case "delete": { + const existing = result[attrName]; + if (!Array.isArray(patch.value) || patch.value.length === 0 || !Array.isArray(existing)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete result[attrName]; + break; + } + const selectors = MERGEABLE_ATTRIBUTE_SELECTORS[attrName] ?? Object.keys(patch.value[0] as AttrInstance); + const remaining = (existing as AttrInstance[]).filter( + (inst) => !(patch.value as AttrInstance[]).some((sel) => selectorsMatch(inst, sel, selectors)), + ); + if (remaining.length === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete result[attrName]; + } else { + result[attrName] = remaining; + } + break; + } + + case "merge": { + const selectors = MERGEABLE_ATTRIBUTE_SELECTORS[attrName]; + if (!selectors) { + return { + ok: false, + attributes, + issues: [ + invalidPatch( + `The 'merge' operation is only supported for ${Object.keys(MERGEABLE_ATTRIBUTE_SELECTORS).join(", ")}. Attribute '${attrName}' is not supported.`, + attrName, + ), + ], + }; + } + if (!Array.isArray(patch.value)) { + return { + ok: false, + attributes, + issues: [invalidPatch(`The 'merge' operation for '${attrName}' requires a value array with selector fields.`, attrName)], + }; + } + + const instances = Array.isArray(result[attrName]) ? (result[attrName] as AttrInstance[]) : []; + for (const mergeValue of patch.value as AttrInstance[]) { + const target = instances.find((inst) => selectorsMatch(inst, mergeValue, selectors)); + if (!target) { + // No matching instance: append (strip explicit nulls). + const appended = Object.fromEntries(Object.entries(mergeValue).filter(([, v]) => v !== null)); + instances.push(appended); + continue; + } + for (const [field, value] of Object.entries(mergeValue)) { + if (selectors.includes(field)) continue; + if (value === null) { + if (NON_NULLABLE_MERGE_FIELDS.has(field)) { + return { + ok: false, + attributes, + issues: [ + invalidPatch(`The '${field}' sub-attribute cannot be deleted. Use the delete operation to remove the offer entirely.`, attrName), + ], + }; + } + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete target[field]; + } else { + target[field] = value; + } + } + } + result[attrName] = instances; + break; + } + } + } + + return { ok: true, attributes: result, issues: [] }; +} + +// --- MFN ledger sync --- + +/** + * Rebuilds the live MFN ledger from a submitted fulfillment_availability + * attribute. Submitting a quantity SETS the live quantity (restock + * semantics); channels without a quantity (FBA enablement rows like + * AMAZON_NA) never enter the MFN ledger. + */ +export function ledgerFromFulfillmentAttribute(attributes: Record): MfnAvailabilityEntry[] { + const instances = attributes.fulfillment_availability; + if (!Array.isArray(instances)) return []; + const ledger: MfnAvailabilityEntry[] = []; + for (const inst of instances as AttrInstance[]) { + const channel = inst.fulfillment_channel_code; + const quantity = inst.quantity; + if (typeof channel === "string" && typeof quantity === "number") { + ledger.push({ fulfillmentChannelCode: channel, quantity }); + } + } + return ledger; +} + +// --- Derivations (read-time projections) --- + +function hasEnforcement(issues: ListingIssue[], action: string): boolean { + return issues.some((i) => i.severity === "ERROR" && i.enforcements?.actions.some((a) => a.action === action)); +} + +/** + * Reads a date that may be submitted either bare or wrapped in `{ value }`. + * Anything else — including `null`, which `typeof` reports as "object" — yields + * undefined rather than throwing, since a read must not fail on data a write + * accepted. + */ +function dateFieldValue(raw: unknown): string | undefined { + if (typeof raw === "string") return raw; + if (typeof raw === "object" && raw !== null) { + const value = (raw as { value?: unknown }).value; + return typeof value === "string" ? value : undefined; + } + return undefined; +} + +/** An offer schedule entry is active if now is within its optional start/end window. */ +function scheduleActive(inst: AttrInstance, now: Date): boolean { + const startStr = dateFieldValue(inst.start_at); + const endStr = dateFieldValue(inst.end_at); + if (startStr && new Date(startStr) > now) return false; + if (endStr && new Date(endStr) < now) return false; + return true; +} + +/** Active purchasable_offer instances (having a price, within schedule). */ +function activeOffers(attributes: Record, now: Date): AttrInstance[] { + const offers = attributes.purchasable_offer; + if (!Array.isArray(offers)) return []; + return (offers as AttrInstance[]).filter((o) => Array.isArray(o.our_price) && o.our_price.length > 0 && scheduleActive(o, now)); +} + +/** FBA (Amazon-fulfilled) channel codes declared in the attributes. */ +function fbaChannels(attributes: Record): string[] { + const instances = attributes.fulfillment_availability; + if (!Array.isArray(instances)) return []; + return (instances as AttrInstance[]) + .map((inst) => inst.fulfillment_channel_code) + .filter((code): code is string => typeof code === "string" && code.startsWith("AMAZON_")); +} + +/** Fulfillable FBA quantity for this SKU, held in the inventory partition. */ +function fbaQuantity(sku: string): number { + const fba = Context.instance.engine.get(Api.INVENTORY, sku); + return typeof fba?.fulfillableQuantity === "number" ? fba.fulfillableQuantity : 0; +} + +/** Total quantity across the MFN ledger. */ +function mfnQuantity(doc: ListingDoc): number { + return doc.mfnAvailability.reduce((sum, e) => sum + e.quantity, 0); +} + +/** + * Live fulfillment availability: the channel that would fulfil the next order. + * + * Amazon draws on its own inventory first, so a hybrid listing reports only its + * FBA channels while FBA stock lasts, and falls back to the merchant channels + * once it runs out. FBA entries carry no `quantity` — Amazon owns that number + * and does not echo it back — whereas merchant entries report the live ledger. + * + * With nothing in stock anywhere, the primary channel is still reported, so the + * section never comes back empty for a listing that declares a channel. + */ +function deriveFulfillmentAvailability(doc: ListingDoc): FulfillmentAvailabilityEntry[] { + const fba = fbaChannels(doc.attributes); + const asFbaEntries = () => fba.map((fulfillmentChannelCode) => ({ fulfillmentChannelCode })); + const asMfnEntries = () => doc.mfnAvailability.map((e) => ({ ...e })); + + if (fba.length === 0) return asMfnEntries(); + if (fbaQuantity(doc.sku) > 0) return asFbaEntries(); + return mfnQuantity(doc) > 0 ? asMfnEntries() : asFbaEntries(); +} + +/** + * Purchasable quantity, for status derivation only — never reported. Either + * channel can fulfil, so stock in either keeps the listing buyable. + */ +function totalAvailableQuantity(doc: ListingDoc): number { + return (fbaChannels(doc.attributes).length > 0 ? fbaQuantity(doc.sku) : 0) + mfnQuantity(doc); +} + +/** True when any channel declares always-available inventory (never depletes). */ +function hasAlwaysAvailableInventory(attributes: Record): boolean { + const instances = attributes.fulfillment_availability; + if (!Array.isArray(instances)) return false; + return (instances as AttrInstance[]).some((inst) => inst.is_inventory_available === true); +} + +/** + * Listing status derivation (summaries[].status): + * - BUYABLE: offer not skipped AND active offer AND in stock (positive + * quantity or always-available inventory) AND not listing-suppressed + * - DISCOVERABLE: not search-suppressed + */ +export function deriveStatus(doc: ListingDoc, now = new Date()): string[] { + const skipOffer = firstAttrValue(doc.attributes, "skip_offer") === true; + const inStock = totalAvailableQuantity(doc) > 0 || hasAlwaysAvailableInventory(doc.attributes); + + const status: string[] = []; + const buyable = !skipOffer && activeOffers(doc.attributes, now).length > 0 && inStock && !hasEnforcement(doc.issues, "LISTING_SUPPRESSED"); + if (buyable) status.push("BUYABLE"); + if (!hasEnforcement(doc.issues, "SEARCH_SUPPRESSED")) status.push("DISCOVERABLE"); + return status; +} + +/** Summaries derivation: the marketplace-scoped live view of the listing. */ +function deriveSummaries(doc: ListingDoc, marketplaceId: string, now = new Date()): Record[] { + const summary: Record = { + marketplaceId, + productType: doc.productType, + status: deriveStatus(doc, now), + createdDate: doc.createdDate, + lastUpdatedDate: doc.lastUpdatedDate, + }; + if (doc.asin) summary.asin = doc.asin; + + const conditionType = firstAttrValue(doc.attributes, "condition_type"); + if (typeof conditionType === "string") summary.conditionType = conditionType; + + const itemName = firstAttrValue(doc.attributes, "item_name"); + if (typeof itemName === "string") summary.itemName = itemName; + + const mainImage = firstAttrValue(doc.attributes, "main_product_image_locator", "media_location"); + if (typeof mainImage === "string") { + summary.mainImage = { link: mainImage, height: 500, width: 500 }; + } + + return [summary]; +} + +/** Buyer-segment display names for derived offers (IVP audiences). */ +const AUDIENCE_DISPLAY_NAMES: Record = { + ALL: "Sell on Amazon", + B2B: "Amazon Business", +}; + +/** Single-unit price for an offer instance: active discounted_price else our_price. */ +function offerPrice(inst: AttrInstance, now: Date): { currencyCode: string; amount: string } | null { + const schedules = (field: string): AttrInstance[] => { + const arr = inst[field]; + if (!Array.isArray(arr) || arr.length === 0) return []; + const first = (arr[0] as AttrInstance).schedule; + return Array.isArray(first) ? (first as AttrInstance[]) : []; + }; + + const discounted = schedules("discounted_price").find((s) => scheduleActive(s, now)); + const regularList = schedules("our_price"); + const regular = regularList.length > 0 ? regularList[0] : undefined; + const chosen = discounted ?? regular; + if (chosen?.value_with_tax === undefined) return null; + return { currencyCode: asString(inst.currency, "USD"), amount: asString(chosen.value_with_tax, "") }; +} + +/** + * Offers derivation from attributes.purchasable_offer: one entry per + * active instance. audience=ALL (or absent) => B2C; anything else => B2B. + * IVP audiences (B2B_*) carry an audience object with a display name. + */ +function deriveOffers(doc: ListingDoc, marketplaceId: string, now = new Date()): Record[] { + return activeOffers(doc.attributes, now).flatMap((inst) => { + const price = offerPrice(inst, now); + if (!price) return []; + const audience = asString(inst.audience, "ALL"); + const offer: Record = { + marketplaceId: asString(inst.marketplace_id, marketplaceId), + offerType: audience === "ALL" ? "B2C" : "B2B", + price, + }; + if (audience !== "ALL") { + offer.audience = { value: audience, displayName: AUDIENCE_DISPLAY_NAMES[audience] ?? audience }; + } + return [offer]; + }); +} + +/** + * Splits a variation theme into the attribute names it is composed of: + * `SIZE/COLOR/NUMBER_OF_ITEMS` becomes `["color", "number_of_items", "size"]`. + * Sorted, because production reports them alphabetically rather than in theme + * order. + */ +function themeAttributes(theme: string): string[] { + return theme + .split("/") + .map((token) => token.trim().toLowerCase()) + .filter((token) => token !== "") + .sort((a, b) => a.localeCompare(b)); +} + +/** The variation theme declared on a listing, if it declared one. */ +function variationTheme(attributes: Record): { attributes: string[]; theme: string } | undefined { + const name = firstAttrValue(attributes, "variation_theme", "name"); + if (typeof name !== "string" || name === "") return undefined; + return { attributes: themeAttributes(name), theme: name }; +} + +/** SKUs this listing names as its variation parents. */ +export function declaredParentSkus(attributes: Record): string[] { + const instances = attributes.child_parent_sku_relationship; + if (!Array.isArray(instances)) return []; + return (instances as AttrInstance[]) + .map((inst) => inst.parent_sku) + .filter((sku): sku is string => typeof sku === "string" && sku !== ""); +} + +/** SKUs this listing declares itself to contain (`package_contains_sku`). */ +export function declaredContainedSkus(attributes: Record): string[] { + const instances = attributes.package_contains_sku; + if (!Array.isArray(instances)) return []; + return (instances as AttrInstance[]).map((inst) => inst.sku).filter((sku): sku is string => typeof sku === "string" && sku !== ""); +} + +/** The seller's other listings, which relationship resolution has to consult. */ +function siblingListings(doc: ListingDoc): ListingDoc[] { + return Context.instance.engine + .find(Api.LISTINGS, { sellerId: doc.sellerId }) + .map(asListingDoc) + .filter((sibling) => sibling.sku !== doc.sku); +} + +/** + * Relationship derivation — the one response section that is not a function of + * a single listing document, because half of each relationship is recorded on + * the other listing. + * + * The two types record it from opposite ends, so each needs a lookup in the + * opposite direction: + * - VARIATION: the child declares `child_parent_sku_relationship.parent_sku`, + * so a child reads its own attributes and a parent finds the siblings that + * name it. + * - PACKAGE_HIERARCHY: the container declares `package_contains_sku`, so a + * case reads its own attributes and a contained unit finds the siblings + * that list it. + * + * A listing in the middle of a package hierarchy (a case inside a pallet) + * therefore reports both `parentSkus` and `childSkus` on one entry. Only + * `VARIATION` carries a `variationTheme`. + * + * Returns an empty array for an unrelated listing rather than omitting the + * section, which is what production does. + */ +function deriveRelationships(doc: ListingDoc, marketplaceId: string): Record[] { + const siblings = siblingListings(doc); + const relationships: Record[] = []; + + const variationParents = declaredParentSkus(doc.attributes); + const variationChildren = siblings.filter((s) => declaredParentSkus(s.attributes).includes(doc.sku)).map((s) => s.sku); + if (variationParents.length > 0 || variationChildren.length > 0) { + const entry: Record = { type: "VARIATION" }; + if (variationParents.length > 0) entry.parentSkus = variationParents; + if (variationChildren.length > 0) entry.childSkus = variationChildren; + const theme = variationTheme(doc.attributes); + if (theme) entry.variationTheme = theme; + relationships.push(entry); + } + + const containedSkus = declaredContainedSkus(doc.attributes); + const containers = siblings.filter((s) => declaredContainedSkus(s.attributes).includes(doc.sku)).map((s) => s.sku); + if (containedSkus.length > 0 || containers.length > 0) { + const entry: Record = { type: "PACKAGE_HIERARCHY" }; + if (containers.length > 0) entry.parentSkus = containers; + if (containedSkus.length > 0) entry.childSkus = containedSkus; + relationships.push(entry); + } + + return relationships.length > 0 ? [{ marketplaceId, relationships }] : []; +} + +/** + * Procurement derivation from attributes.cost_price: the cost Amazon pays a + * vendor for the product, which is the vendor counterpart of a merchant's + * `offers`. Derived from the submission rather than stored separately, so a + * vendor reads back exactly the cost they submitted. + * + * Empty when no cost was submitted, which is also the merchant case — a seller + * never submits `cost_price`, so the section stays empty for them. + */ +function deriveProcurement(doc: ListingDoc): Record[] { + const instances = doc.attributes.cost_price; + if (!Array.isArray(instances)) return []; + return (instances as AttrInstance[]).flatMap((inst) => { + if (inst.value === undefined || inst.value === null) return []; + return [{ costPrice: { currencyCode: asString(inst.currency, "USD"), amount: asString(inst.value, "") } }]; + }); +} + +/** + * Builds the Item response for a listing per the requested includedData + * sections. `sku` is always present; sections are derived or read from + * the stored document. + */ +export function buildItemResponse(doc: ListingDoc, includedData: string[], marketplaceId: string, now = new Date()): Record { + const item: Record = { sku: doc.sku }; + + for (const section of includedData) { + switch (section) { + case "summaries": + item.summaries = deriveSummaries(doc, marketplaceId, now); + break; + case "attributes": + item.attributes = doc.attributes; + break; + case "issues": + item.issues = doc.issues; + break; + case "offers": + item.offers = deriveOffers(doc, marketplaceId, now); + break; + case "fulfillmentAvailability": + item.fulfillmentAvailability = deriveFulfillmentAvailability(doc); + break; + case "procurement": + item.procurement = deriveProcurement(doc); + break; + case "relationships": + item.relationships = deriveRelationships(doc, marketplaceId); + break; + case "productTypes": + item.productTypes = [{ marketplaceId, productType: doc.productType }]; + break; + } + } + return item; +} + +/** Coerces a raw DB record into the typed ListingDoc shape with defaults. */ +export function asListingDoc(record: Record): ListingDoc { + return { + _key: asString(record._key, ""), + sku: asString(record.sku, ""), + sellerId: asString(record.sellerId, ""), + productType: asString(record.productType, ""), + requirements: record.requirements as string | undefined, + marketplaceId: asString(record.marketplaceId, ""), + attributes: (record.attributes as Record | undefined) ?? {}, + issues: (record.issues as ListingIssue[] | undefined) ?? [], + asin: record.asin as string | undefined, + mfnAvailability: (record.mfnAvailability as MfnAvailabilityEntry[] | undefined) ?? [], + createdDate: asString(record.createdDate, ""), + lastUpdatedDate: asString(record.lastUpdatedDate, ""), + }; +} diff --git a/local-ai-sandbox/src/operation/listingsOperations.ts b/local-ai-sandbox/src/operation/listingsOperations.ts new file mode 100644 index 000000000..e8549b9c2 --- /dev/null +++ b/local-ai-sandbox/src/operation/listingsOperations.ts @@ -0,0 +1,393 @@ +/** + * Listings Items API v2021-08-01 — deterministic operation handlers. + * + * Responses combine the stored submission with read-time derivations + * (listingsItemModel.ts). PUT validation is delegated to production + * (listingsValidationPreview.ts); catalog matching runs asynchronously in the + * listings trigger, as in production. + * + * The sandbox has no dry run: a request always mutates the sandbox DB and never + * production, so the request's own `mode=VALIDATION_PREVIEW` changes nothing + * here. (Distinct from the sandbox-wide Seller/Vendor MODE, which does apply: + * both selling partner types call these operations, but the datasets and + * submission features available to each differ.) + */ +import { randomUUID } from "node:crypto"; +import type { OperationHandler } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; +// Read inside handlers only. The registry imports this module, so the binding +// is resolved when a request runs rather than while the modules initialise. +import { CURRENT_MODE } from "../registry/operationRegistry.js"; +import { Paginator } from "../service/Paginator.js"; +import { runValidationPreview } from "./listingsValidationPreview.js"; +import { + applyPatches, + applySellerPutSemantics, + asListingDoc, + buildItemResponse, + declaredContainedSkus, + declaredParentSkus, + deriveStatus, + ISSUE_CODE_INVALID_VALUE, + ledgerFromFulfillmentAttribute, + listingKey, + type ListingDoc, + type ListingIssue, + type ListingsPatchOperation, +} from "./listingsItemModel.js"; + +// --- Shared helpers --- + +type QueryParams = Record; + +function parseIncludedData(queryParams: QueryParams): string[] { + const raw = queryParams.includedData; + if (!raw) return ["summaries"]; // default per API spec + if (Array.isArray(raw)) return raw.flatMap((v) => v.split(",")); + return raw.split(",").map((s) => s.trim()); +} + +function firstMarketplaceId(queryParams: QueryParams): string { + const raw = queryParams.marketplaceIds; + if (Array.isArray(raw)) return raw[0]; + return (raw ?? "").split(",")[0]; +} + +/** Parses csv-or-array query params into a flat string array (or undefined). */ +function parseArrayParam(param: string | string[] | undefined): string[] | undefined { + if (param === undefined || param === "") return undefined; + const values = Array.isArray(param) ? param.flatMap((v) => v.split(",")) : param.split(","); + const cleaned = values.map((v) => v.trim()).filter((v) => v !== ""); + return cleaned.length > 0 ? cleaned : undefined; +} + +function buildContext( + validationResult: { + operationId: string; + apiName: string; + apiVersion: string; + pathParams: Record; + queryParams: Record; + body: Record | undefined; + operation: unknown; + resolvedEntities: Record>; + }, + statusCode: number, + body: unknown, +) { + return { + statusCode, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: validationResult.body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body }, + }; +} + +interface SubmissionResponse { + sku: string; + status: "ACCEPTED" | "INVALID"; + submissionId: string; + issues: ListingIssue[]; +} + +function submissionResponse(sku: string, status: "ACCEPTED" | "INVALID", issues: ListingIssue[] = []): SubmissionResponse { + return { sku, status, submissionId: randomUUID().replace(/-/g, ""), issues }; +} + +/** Generates a plausible, deterministic-format ASIN for newly created products. */ +function generateAsin(): string { + return "B0" + randomUUID().replace(/-/g, "").slice(0, 8).toUpperCase(); +} + +/** Error body for a PUT that cannot be validated against production. */ +function upstreamErrorBody(code: string, message: string) { + return { errors: [{ code, message }] }; +} + +// --- getListingsItem --- + +export const getListingsItemHandler: OperationHandler = (validationResult) => { + const doc = asListingDoc(validationResult.resolvedEntities.listing); + const includedData = parseIncludedData(validationResult.queryParams); + const marketplaceId = firstMarketplaceId(validationResult.queryParams); + + return Promise.resolve(buildContext(validationResult, 200, buildItemResponse(doc, includedData, marketplaceId))); +}; + +// --- putListingsItem --- + +interface PutRequestBody { + productType: string; + requirements?: string; + attributes: Record; +} + +export const putListingsItemHandler: OperationHandler = async (validationResult, request) => { + const sku = validationResult.pathParams.sku; + const sellerId = validationResult.pathParams.sellerId; + const body = request.body as PutRequestBody; + const now = new Date().toISOString(); + const marketplaceId = firstMarketplaceId(validationResult.queryParams); + + // Synchronous validation is production's answer, never a local imitation. + const preview = await runValidationPreview(request); + switch (preview.outcome) { + case "NO_CREDENTIALS": + return buildContext( + validationResult, + 403, + upstreamErrorBody( + "Unauthorized", + "putListingsItem requires a valid 'x-amz-access-token': the sandbox validates submissions against the real Listings Items API.", + ), + ); + case "UNAVAILABLE": + return buildContext( + validationResult, + 502, + upstreamErrorBody("BadGateway", `Could not validate the submission against the Listings Items API (${preview.detail}).`), + ); + case "INVALID": + // Rejected synchronously: nothing is created, as in production. + return buildContext(validationResult, 200, { ...submissionResponse(sku, "INVALID"), issues: preview.issues }); + case "VALID": + break; + } + + const key = listingKey(sellerId, sku); + const existingRecord = Context.instance.engine.get(Api.LISTINGS, key); + const existing = existingRecord ? asListingDoc(existingRecord) : undefined; + + const attributes = applySellerPutSemantics(existing?.attributes, body.attributes); + const suggestedAsin = (attributes.merchant_suggested_asin as { value?: string }[] | undefined)?.[0]?.value; + + // Submitting fulfillment_availability quantities resets the live MFN + // ledger (restock semantics). Omitting it keeps the current ledger. + const submittedFulfillment = body.attributes.fulfillment_availability !== undefined; + const mfnAvailability = submittedFulfillment ? ledgerFromFulfillmentAttribute(attributes) : (existing?.mfnAvailability ?? []); + + const doc: ListingDoc = { + _key: key, + sku, + sellerId, + productType: body.productType, + requirements: body.requirements, + marketplaceId, + attributes, + // The preview answer covers the whole new submission, so it replaces the + // previous validation issues. The trigger reconciles its own codes. + issues: preview.issues, + // Seller-suggested match target, previously resolved identity, or a new + // ASIN for a product Amazon does not know yet. + asin: suggestedAsin ?? existing?.asin ?? generateAsin(), + mfnAvailability, + createdDate: existing?.createdDate ?? now, + lastUpdatedDate: now, + }; + + Context.instance.engine.put(Api.LISTINGS, key, doc as unknown as Record); + + return buildContext(validationResult, 200, submissionResponse(sku, "ACCEPTED")); +}; + +// --- patchListingsItem --- + +interface PatchRequestBody { + productType: string; + patches: ListingsPatchOperation[]; +} + +/** + * Patch is an upsert: production creates the SKU when it does not exist yet, + * so an unknown SKU is not a 404 here either. Patches are not sent to + * production for validation — the same submission can draw different issues + * from a real account, so patch results would not be reproducible. + */ +export const patchListingsItemHandler: OperationHandler = (validationResult, request) => { + const sku = validationResult.pathParams.sku; + const sellerId = validationResult.pathParams.sellerId; + const now = new Date().toISOString(); + const marketplaceId = firstMarketplaceId(validationResult.queryParams); + const body = request.body as PatchRequestBody; + + // A vendor may add or replace attribute values but not delete them. This is + // per-operation rather than per-request, so it cannot be a modeRestriction + // rule, which resolves one named parameter. + if (CURRENT_MODE !== "Seller" && body.patches.some((p) => p.op === "delete")) { + const issue: ListingIssue = { + code: ISSUE_CODE_INVALID_VALUE, + message: "The 'delete' patch operation is not supported for vendors. Use 'add' or 'replace' to change attribute values.", + severity: "ERROR", + categories: ["INVALID_ATTRIBUTE"], + }; + return Promise.resolve(buildContext(validationResult, 200, { ...submissionResponse(sku, "INVALID"), issues: [issue] })); + } + + // Keyed by seller and SKU, so the upsert lands on this seller's listing + // rather than on a different seller's listing that reuses the SKU. + const key = listingKey(sellerId, sku); + const existingRecord = Context.instance.engine.get(Api.LISTINGS, key); + const existing: ListingDoc = existingRecord + ? asListingDoc(existingRecord) + : { + _key: key, + sku, + sellerId, + productType: body.productType, + marketplaceId, + attributes: {}, + issues: [], + mfnAvailability: [], + createdDate: now, + lastUpdatedDate: now, + }; + + const patchResult = applyPatches(existing.attributes, body.patches); + if (!patchResult.ok) { + // Structural patch rejection: HTTP 200 with status INVALID + issues, + // matching production put/patch response semantics. + return Promise.resolve(buildContext(validationResult, 200, { ...submissionResponse(sku, "INVALID"), issues: patchResult.issues })); + } + + // Any patch touching fulfillment_availability resets the affected + // channels' live quantities (restock semantics). + const touchedFulfillment = body.patches.some((p) => p.path.includes("fulfillment_availability")); + const mfnAvailability = touchedFulfillment ? ledgerFromFulfillmentAttribute(patchResult.attributes) : existing.mfnAvailability; + + const suggestedAsin = (patchResult.attributes.merchant_suggested_asin as { value?: string }[] | undefined)?.[0]?.value; + + const doc: ListingDoc = { + ...existing, + attributes: patchResult.attributes, + mfnAvailability, + asin: suggestedAsin ?? existing.asin ?? generateAsin(), + lastUpdatedDate: now, + }; + + Context.instance.engine.put(Api.LISTINGS, key, doc as unknown as Record); + + return Promise.resolve(buildContext(validationResult, 200, submissionResponse(sku, "ACCEPTED"))); +}; + +// --- deleteListingsItem --- + +export const deleteListingsItemHandler: OperationHandler = async (validationResult) => { + const sku = validationResult.pathParams.sku; + // engine.remove emits the DELETE data event itself. + await Context.instance.engine.remove(Api.LISTINGS, listingKey(validationResult.pathParams.sellerId, sku)); + + return buildContext(validationResult, 200, submissionResponse(sku, "ACCEPTED")); +}; + +// --- searchListingsItems --- + +/** Production caps search results at 1000 across all pages. */ +const SEARCH_RESULT_CAP = 1000; + +const listingsPaginator = new Paginator({ defaultPageSize: 10, maxPageSize: 20 }); + +function matchesIdentifiers(doc: ListingDoc, identifiers: string[], identifiersType: string): boolean { + switch (identifiersType) { + case "SKU": + return identifiers.includes(doc.sku); + case "ASIN": + return doc.asin !== undefined && identifiers.includes(doc.asin); + default: { + // EAN, UPC, GTIN, ISBN, JAN, MINSAN, FNSKU — match against + // externally_assigned_product_identifier instances. + const instances = doc.attributes.externally_assigned_product_identifier; + if (!Array.isArray(instances)) return false; + return (instances as { value?: unknown }[]).some((inst) => typeof inst.value === "string" && identifiers.includes(inst.value)); + } + } +} + +function withinDateRange(value: string, after?: string, before?: string): boolean { + if (after && value < after) return false; + if (before && value > before) return false; + return true; +} + +export const searchListingsItemsHandler: OperationHandler = (validationResult) => { + const qp = validationResult.queryParams; + const marketplaceId = firstMarketplaceId(qp); + const now = new Date(); + + const identifiers = parseArrayParam(qp.identifiers); + // Guaranteed present alongside identifiers by the requiredTogether rule. + const identifiersType = (qp.identifiersType as string | undefined) ?? "SKU"; + const variationParentSku = qp.variationParentSku as string | undefined; + const packageHierarchySku = qp.packageHierarchySku as string | undefined; + const withStatus = parseArrayParam(qp.withStatus); + const withoutStatus = parseArrayParam(qp.withoutStatus); + const withIssueSeverity = parseArrayParam(qp.withIssueSeverity); + const createdAfter = qp.createdAfter as string | undefined; + const createdBefore = qp.createdBefore as string | undefined; + const lastUpdatedAfter = qp.lastUpdatedAfter as string | undefined; + const lastUpdatedBefore = qp.lastUpdatedBefore as string | undefined; + + // Filtered to the selling partner in the path, so a search only ever lists + // that seller's own listings. + let docs = Context.instance.engine.find(Api.LISTINGS, { sellerId: validationResult.pathParams.sellerId }).map(asListingDoc); + + if (identifiers) docs = docs.filter((d) => matchesIdentifiers(d, identifiers, identifiersType)); + + // Relationship filters. Mutually exclusive with each other and with + // identifiers, enforced by the atMostOneAllowed rule. + if (variationParentSku) { + docs = docs.filter((d) => declaredParentSkus(d.attributes).includes(variationParentSku)); + } + if (packageHierarchySku) { + // "contain or are contained by": a listing qualifies if it names the + // anchor among its contents, or the anchor names it among its own. The + // anchor itself does neither, so it is not in its own results. + const anchor = Context.instance.engine.get(Api.LISTINGS, listingKey(validationResult.pathParams.sellerId, packageHierarchySku)); + const anchorContains = anchor ? declaredContainedSkus(asListingDoc(anchor).attributes) : []; + docs = docs.filter((d) => declaredContainedSkus(d.attributes).includes(packageHierarchySku) || anchorContains.includes(d.sku)); + } + docs = docs.filter((d) => withinDateRange(d.createdDate, createdAfter, createdBefore)); + docs = docs.filter((d) => withinDateRange(d.lastUpdatedDate, lastUpdatedAfter, lastUpdatedBefore)); + + if (withStatus || withoutStatus) { + docs = docs.filter((d) => { + const status = new Set(deriveStatus(d, now)); + if (withStatus?.every((s) => status.has(s)) === false) return false; + if (withoutStatus?.some((s) => status.has(s)) === true) return false; + return true; + }); + } + + if (withIssueSeverity) { + docs = docs.filter((d) => d.issues.some((i) => withIssueSeverity.includes(i.severity))); + } + + // Sort: sku | createdDate | lastUpdatedDate (default lastUpdatedDate DESC). + const sortBy = (qp.sortBy as string | undefined) ?? "lastUpdatedDate"; + const sortOrder = (qp.sortOrder as string | undefined) ?? "DESC"; + const sortValue = (d: ListingDoc): string => (sortBy === "sku" ? d.sku : sortBy === "createdDate" ? d.createdDate : d.lastUpdatedDate); + docs.sort((a, b) => (sortOrder === "ASC" ? sortValue(a).localeCompare(sortValue(b)) : sortValue(b).localeCompare(sortValue(a)))); + + // Cap at 1000 results across all pages, per production behavior. + docs = docs.slice(0, SEARCH_RESULT_CAP); + + // Pagination (pageSize default 10, max 20; base64 offset tokens). + const paginationResult = listingsPaginator.paginate(docs, { pageSize: qp.pageSize as string | undefined, pageToken: qp.pageToken as string | undefined }); + + const includedData = parseIncludedData(qp); + const items = paginationResult.page.map((d) => buildItemResponse(d, includedData, marketplaceId, now)); + + const pagination: Record = {}; + if (paginationResult.nextToken) pagination.nextToken = paginationResult.nextToken; + if (paginationResult.previousToken) pagination.previousToken = paginationResult.previousToken; + + const responseBody: Record = { numberOfResults: paginationResult.numberOfResults, items }; + if (Object.keys(pagination).length > 0) responseBody.pagination = pagination; + + return Promise.resolve(buildContext(validationResult, 200, responseBody)); +}; diff --git a/local-ai-sandbox/src/operation/listingsRestrictionsOperations.ts b/local-ai-sandbox/src/operation/listingsRestrictionsOperations.ts new file mode 100644 index 000000000..613ff4fa6 --- /dev/null +++ b/local-ai-sandbox/src/operation/listingsRestrictionsOperations.ts @@ -0,0 +1,79 @@ +/** + * Listings Restrictions API v2021-08-01 — deterministic handler. + * + * Restrictions are seeded into the LISTINGS_RESTRICTIONS partition keyed + * by ASIN, shaped like the spec's RestrictionList: + * { restrictions: [{ marketplaceId, conditionType?, reasons: [{ reasonCode, message, links? }] }] } + * + * Behavior: + * - ASIN unknown to the local catalog AND no seeded restrictions → + * ASIN_NOT_FOUND reason per requested marketplace (spec vocabulary). + * - Seeded restrictions are filtered by requested marketplaceIds and + * optional conditionType. + * - Otherwise → empty restrictions (no restrictions, eligible to list). + */ +import type { OperationHandler } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; + +interface RestrictionReason { + message?: string; + reasonCode?: "APPROVAL_REQUIRED" | "ASIN_NOT_FOUND" | "NOT_ELIGIBLE"; + links?: { resource: string; verb: string; title?: string; type?: string }[]; +} + +interface Restriction { + marketplaceId: string; + conditionType?: string; + reasons?: RestrictionReason[]; +} + +function parseMarketplaceIds(raw: string | string[] | undefined): string[] { + const values = Array.isArray(raw) ? raw.flatMap((v) => v.split(",")) : (raw ?? "").split(","); + return values.map((s) => s.trim()).filter((s) => s !== ""); +} + +/** + * Looks up seeded restrictions for an ASIN, filtered by marketplaces and + * optional condition type. + */ +function findRestrictions(asin: string, marketplaceIds: string[], conditionType?: string): Restriction[] { + const record = Context.instance.engine.get(Api.LISTINGS_RESTRICTIONS, asin); + const seeded = (record?.restrictions as Restriction[] | undefined) ?? []; + + return seeded.filter((r) => { + if (marketplaceIds.length > 0 && !marketplaceIds.includes(r.marketplaceId)) return false; + if (conditionType && r.conditionType && r.conditionType !== conditionType) return false; + return true; + }); +} + +export const getListingsRestrictionsHandler: OperationHandler = (validationResult) => { + const qp = validationResult.queryParams; + const rawAsin = qp.asin; + const asin = Array.isArray(rawAsin) ? rawAsin[0] : (rawAsin ?? ""); + const conditionType = qp.conditionType as string | undefined; + const marketplaceIds = parseMarketplaceIds(qp.marketplaceIds); + + let restrictions = findRestrictions(asin, marketplaceIds, conditionType); + + // Unknown ASIN with no seeded restrictions: answer with ASIN_NOT_FOUND. + if (restrictions.length === 0 && !Context.instance.engine.get(Api.CATALOG, asin)) { + restrictions = marketplaceIds.map((marketplaceId) => ({ + marketplaceId, + reasons: [{ reasonCode: "ASIN_NOT_FOUND" as const, message: `The specified ASIN '${asin}' does not exist in the requested marketplace.` }], + })); + } + + return Promise.resolve({ + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation as unknown, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { restrictions } }, + }); +}; diff --git a/local-ai-sandbox/src/operation/listingsValidationPreview.ts b/local-ai-sandbox/src/operation/listingsValidationPreview.ts new file mode 100644 index 000000000..5e227e442 --- /dev/null +++ b/local-ai-sandbox/src/operation/listingsValidationPreview.ts @@ -0,0 +1,77 @@ +/** + * Synchronous validation for putListingsItem, delegated to production. + * + * Rather than reimplement Amazon's validation, every PUT calls the real API + * with `mode=VALIDATION_PREVIEW`, which returns the full synchronous result + * without mutating production. + * + * PUT therefore requires real credentials — deliberate, since a local + * approximation would go stale and teach callers the wrong rules. + */ +import type { Request } from "express"; +import { PROD_BACKEND } from "./passThroughOperations.js"; +import { ISSUE_CODE_ASIN_MISMATCH, ISSUE_CODE_UNMATCHABLE, type ListingIssue } from "./listingsItemModel.js"; + +const PREVIEW_TIMEOUT_MS = Number(process.env.PREVIEW_TIMEOUT_MS) || 10_000; + +/** + * Catalog-matching issues, dropped here: production judges them against the + * real catalog, which knows nothing of sandbox-only ASINs, so its verdict does + * not transfer. Matching is decided against the sandbox catalog by the listings + * trigger. + */ +const MATCHING_ISSUE_CODES = new Set([ISSUE_CODE_ASIN_MISMATCH, ISSUE_CODE_UNMATCHABLE]); + +export type PreviewOutcome = + | { outcome: "VALID"; issues: ListingIssue[] } + | { outcome: "INVALID"; issues: ListingIssue[] } + | { outcome: "NO_CREDENTIALS" } + | { outcome: "UNAVAILABLE"; detail: string }; + +/** + * Validates a putListingsItem submission against production. + * Never throws: transport failures are reported as `UNAVAILABLE` so the + * caller can answer with an upstream error rather than a false pass. + */ +export async function runValidationPreview(request: Request): Promise { + const accessToken = typeof request.header === "function" ? request.header("x-amz-access-token") : undefined; + if (!accessToken) return { outcome: "NO_CREDENTIALS" }; + + // Rebuild the query string so the caller's own `mode` cannot leak through. + const params = new URLSearchParams(request.originalUrl.split("?")[1] ?? ""); + params.set("mode", "VALIDATION_PREVIEW"); + const url = `${PROD_BACKEND}${request.path}?${params.toString()}`; + + try { + const response = await globalThis.fetch(url, { + method: "PUT", + headers: { "content-type": "application/json", "x-amz-access-token": accessToken }, + body: JSON.stringify(request.body), + signal: AbortSignal.timeout(PREVIEW_TIMEOUT_MS), + }); + + if (!response.ok) { + return { outcome: "UNAVAILABLE", detail: `validation preview returned HTTP ${String(response.status)}` }; + } + + const body = (await response.json()) as { status?: string; issues?: ListingIssue[] }; + // A preview answers VALID or INVALID. Anything else means the contract + // changed, so report it rather than guessing at its meaning. + const passed = body.status === "VALID"; + if (!passed && body.status !== "INVALID") { + return { outcome: "UNAVAILABLE", detail: `validation preview returned an unrecognized status '${String(body.status)}'` }; + } + + const issues = (body.issues ?? []).filter((issue) => !MATCHING_ISSUE_CODES.has(issue.code)); + + // Dropping the matching issues can leave a rejection with nothing left to + // report, which means production only objected to matching: pass it and + // let the sandbox decide the catalog outcome itself. + if (!passed && issues.length === 0) return { outcome: "VALID", issues: [] }; + + return { outcome: passed ? "VALID" : "INVALID", issues }; + } catch (error) { + const detail = error instanceof Error ? error.message : "validation preview call failed"; + return { outcome: "UNAVAILABLE", detail }; + } +} diff --git a/local-ai-sandbox/src/operation/notificationsOperations.ts b/local-ai-sandbox/src/operation/notificationsOperations.ts new file mode 100644 index 000000000..389814195 --- /dev/null +++ b/local-ai-sandbox/src/operation/notificationsOperations.ts @@ -0,0 +1,510 @@ +import { randomUUID } from "node:crypto"; +import { OperationHandler } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; +// Read inside handlers only. The registry imports this module, so the binding +// is resolved when a request runs rather than while the modules initialise. +import { CURRENT_MODE } from "../registry/operationRegistry.js"; + +// --- Supported Notification Types Allowlist --- + +export const SUPPORTED_NOTIFICATION_TYPES: Record = { + ORDER_CHANGE: { payloadVersions: ["1.0"], supportedModes: ["Seller"] }, +}; + +// --- createDestination --- + +export const createDestinationHandler: OperationHandler = async (validationResult, request) => { + const body = request.body as Record; + const name = body.name as string; + const resourceSpecification = body.resourceSpecification as Record | undefined; + + // Validate name length + if (name && name.length > 256) { + return { + statusCode: 400, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InvalidInput", message: "Destination name must not exceed 256 characters." }] } }, + }; + } + + // Validate resource specification + const hasSqs = resourceSpecification?.sqs != null; + const hasEventBridge = resourceSpecification?.eventBridge != null; + + if (hasEventBridge) { + return { + statusCode: 501, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "NotImplemented", message: "EventBridge destinations are not supported in the sandbox." }] } }, + }; + } + + if (!hasSqs) { + return { + statusCode: 400, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InvalidInput", message: "Resource specification must contain a valid sqs or eventBridge resource." }] } }, + }; + } + + // Check uniqueness (name) + const existingByName = Context.instance.engine.find(Api.NOTIFICATIONS, { _type: "destination", name }); + if (existingByName.length > 0) { + return { + statusCode: 409, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "Conflict", message: `A destination with the name '${name}' already exists.` }] } }, + }; + } + + // Generate destination + const destinationId = randomUUID(); + const destination = { + _key: destinationId, + _type: "destination" as const, + destinationId, + name, + resource: resourceSpecification, + }; + + Context.instance.engine.put(Api.NOTIFICATIONS, destinationId, destination); + + // Return the destination without internal fields + const { _key: _, _type: __, ...payload } = destination; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { payload } }, + }; +}; + +// --- getDestinations --- + +export const getDestinationsHandler: OperationHandler = async (validationResult) => { + const collection = Context.instance.engine.getCollection(Api.NOTIFICATIONS); + const allDocs = collection + ? collection.find({ _type: "destination" }).map((d) => { + const { $loki, meta, _key, _type, ...rest } = d as Record; + return rest; + }) + : []; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { payload: allDocs } }, + }; +}; + +// --- getDestination --- + +export const getDestinationHandler: OperationHandler = async (validationResult) => { + const { $loki, meta, _key, _type, ...payload } = validationResult.resolvedEntities.destination as Record; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { payload } }, + }; +}; + +// --- deleteDestination --- + +export const deleteDestinationHandler: OperationHandler = async (validationResult) => { + const destinationId = validationResult.pathParams.destinationId; + + // Check for active subscriptions referencing this destination + const subscriptions = Context.instance.engine.find(Api.NOTIFICATIONS, { _type: "subscription", destinationId }); + + if (subscriptions.length > 0) { + return { + statusCode: 409, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { + body: { + errors: [{ code: "Conflict", message: "Cannot delete destination because it has active subscriptions referencing it." }], + }, + }, + }; + } + + void Context.instance.engine.remove(Api.NOTIFICATIONS, destinationId); + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: {} }, + }; +}; + +// --- createSubscription --- + +export const createSubscriptionHandler: OperationHandler = async (validationResult, request) => { + const body = request.body as Record; + const notificationType = validationResult.pathParams.notificationType; + const payloadVersion = body.payloadVersion as string | undefined; + const destinationId = body.destinationId as string | undefined; + const processingDirective = body.processingDirective as Record | undefined; + + // Validate required fields + if (!payloadVersion || typeof payloadVersion !== "string" || payloadVersion.trim() === "") { + return { + statusCode: 400, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InvalidInput", message: "payloadVersion is required and must be a non-empty string." }] } }, + }; + } + + if (!destinationId || typeof destinationId !== "string" || destinationId.trim() === "") { + return { + statusCode: 400, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InvalidInput", message: "destinationId is required and must be a non-empty string." }] } }, + }; + } + + // Validate notificationType is in supported allowlist + if (!(notificationType in SUPPORTED_NOTIFICATION_TYPES)) { + return { + statusCode: 400, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "InvalidInput", message: `Notification type '${notificationType}' is not supported.` }] } }, + }; + } + + // Validate payloadVersion is supported for this notificationType + const supportedConfig = SUPPORTED_NOTIFICATION_TYPES[notificationType]; + if (!supportedConfig.payloadVersions.includes(payloadVersion)) { + return { + statusCode: 400, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { + body: { errors: [{ code: "InvalidInput", message: `Payload version '${payloadVersion}' is not supported for notification type '${notificationType}'.` }] }, + }, + }; + } + + // Check mode availability + const currentMode = CURRENT_MODE; + if (!supportedConfig.supportedModes.includes(currentMode)) { + return { + statusCode: 400, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { + body: { errors: [{ code: "InvalidInput", message: `Notification type '${notificationType}' is not available for the current mode '${currentMode}'.` }] }, + }, + }; + } + + // Check uniqueness (notificationType + payloadVersion) + const existing = Context.instance.engine.find(Api.NOTIFICATIONS, { _type: "subscription", notificationType, payloadVersion }); + if (existing.length > 0) { + return { + statusCode: 409, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { + body: { errors: [{ code: "Conflict", message: `A subscription already exists for notification type '${notificationType}' with payload version '${payloadVersion}'.` }] }, + }, + }; + } + + // Generate subscription + const subscriptionId = randomUUID(); + const subscription: Record = { + _key: subscriptionId, + _type: "subscription" as const, + subscriptionId, + notificationType, + payloadVersion, + destinationId, + }; + + if (processingDirective) { + subscription.processingDirective = processingDirective; + } + + Context.instance.engine.put(Api.NOTIFICATIONS, subscriptionId, subscription); + + // Return subscription without internal fields + const { _key: _, _type: __, ...payload } = subscription; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { payload } }, + }; +}; + +// --- getSubscription --- + +export const getSubscriptionHandler: OperationHandler = async (validationResult) => { + const notificationType = validationResult.pathParams.notificationType; + const payloadVersion = validationResult.queryParams.payloadVersion as string | undefined; + + let subscriptions: Record[]; + + if (payloadVersion) { + // Find subscription matching both notificationType and payloadVersion + subscriptions = Context.instance.engine.find(Api.NOTIFICATIONS, { _type: "subscription", notificationType, payloadVersion }); + } else { + // Find all subscriptions for this notificationType + subscriptions = Context.instance.engine.find(Api.NOTIFICATIONS, { _type: "subscription", notificationType }); + } + + if (subscriptions.length === 0) { + return { + statusCode: 404, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "NotFound", message: `No subscription found for notification type '${notificationType}'.` }] } }, + }; + } + + // Return the one with the highest (latest) payloadVersion + const sorted = subscriptions.sort((a, b) => { + const vA = typeof a.payloadVersion === "string" ? a.payloadVersion : ""; + const vB = typeof b.payloadVersion === "string" ? b.payloadVersion : ""; + return vB.localeCompare(vA, undefined, { numeric: true }); + }); + + const result = sorted[0]; + const { $loki, meta, _key, _type, ...payload } = result as Record; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { payload } }, + }; +}; + +// --- getSubscriptionById --- + +export const getSubscriptionByIdHandler: OperationHandler = async (validationResult) => { + const { $loki, meta, _key, _type, ...payload } = validationResult.resolvedEntities.subscription as Record; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { payload } }, + }; +}; + +// --- getSubscriptions --- + +export const getSubscriptionsHandler: OperationHandler = async (validationResult) => { + const notificationTypesRaw = validationResult.queryParams.notificationTypes; + const payloadVersion = validationResult.queryParams.payloadVersion as string | undefined; + const pageSize = Math.min(Math.max(Number(validationResult.queryParams.pageSize) || 30, 30), 100); + const nextToken = validationResult.queryParams.nextToken as string | undefined; + + // notificationTypes is required and limited to a single value per the spec + const notificationTypes: string[] = Array.isArray(notificationTypesRaw) + ? notificationTypesRaw + : typeof notificationTypesRaw === "string" + ? notificationTypesRaw.split(",") + : []; + + // Find all subscriptions matching the filter criteria + let subscriptions = Context.instance.engine.find(Api.NOTIFICATIONS, { _type: "subscription" }) as Record[]; + + // Filter by notificationTypes + if (notificationTypes.length > 0) { + subscriptions = subscriptions.filter((s) => notificationTypes.includes(s.notificationType as string)); + } + + // Filter by payloadVersion if provided + if (payloadVersion) { + subscriptions = subscriptions.filter((s) => s.payloadVersion === payloadVersion); + } + + // Sort by subscriptionId for deterministic pagination + subscriptions.sort((a, b) => (a.subscriptionId as string).localeCompare(b.subscriptionId as string)); + + // Handle pagination via nextToken (offset-based using subscriptionId) + let startIndex = 0; + if (nextToken) { + const idx = subscriptions.findIndex((s) => s.subscriptionId === nextToken); + startIndex = idx >= 0 ? idx : subscriptions.length; + } + + const page = subscriptions.slice(startIndex, startIndex + pageSize); + const hasMore = startIndex + pageSize < subscriptions.length; + const responseNextToken = hasMore ? (subscriptions[startIndex + pageSize].subscriptionId as string) : undefined; + + // Strip internal fields + const cleaned = page.map((s) => { + const { $loki, meta, _key, _type, ...rest } = s; + return rest; + }); + + const payload: Record = { subscriptions: cleaned }; + if (responseNextToken) { + payload.nextToken = responseNextToken; + } + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { payload } }, + }; +}; + +// --- deleteSubscriptionById --- + +export const deleteSubscriptionByIdHandler: OperationHandler = async (validationResult) => { + const subscriptionId = validationResult.pathParams.subscriptionId; + void Context.instance.engine.remove(Api.NOTIFICATIONS, subscriptionId); + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: {} }, + }; +}; diff --git a/local-ai-sandbox/src/operation/operationTypes.ts b/local-ai-sandbox/src/operation/operationTypes.ts new file mode 100644 index 000000000..9f351878e --- /dev/null +++ b/local-ai-sandbox/src/operation/operationTypes.ts @@ -0,0 +1,42 @@ +import type { Request } from "express"; +import type { UnifiedValidationPass } from "../validation/validationTypes.js"; + +/** + * The output of an Operation_Handler. Contains all data the agent needs + * to generate a response, including operation metadata and any entities + * read from or written to the database. + */ +export interface OperationContext { + /** HTTP status code for the operation result (100-599) */ + statusCode: number; + + /** Operation metadata from validation */ + operationId: string; + apiName: string; + apiVersion: string; + + /** Request parameters */ + pathParams: Record; + queryParams: Record; + body: Record | undefined; + + /** OpenAPI operation spec */ + operation: any; + + /** Entities resolved during validation */ + resolvedEntities: Record>; + + /** Data retrieved by the Operation_Handler */ + data: Record; +} + +// --- Handler Type --- + +/** + * An Operation_Handler is an async function that executes deterministic + * business logic for a specific SP-API operation. + * + * Input: The validated request context and the original Express Request. + * Output: An OperationContext with all data needed for response generation. + */ +export type OperationHandler = (validationResult: UnifiedValidationPass, request: Request) => Promise; diff --git a/local-ai-sandbox/src/operation/ordersOperations.ts b/local-ai-sandbox/src/operation/ordersOperations.ts new file mode 100644 index 000000000..4456904be --- /dev/null +++ b/local-ai-sandbox/src/operation/ordersOperations.ts @@ -0,0 +1,325 @@ +import { OperationHandler } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; + +// --- includedData filtering logic for getOrder --- + +const ORDER_BASE_KEYS = ["orderId", "orderAliases", "createdTime", "lastUpdatedTime", "programs", "associatedOrders", "salesChannel", "orderItems"]; + +const ORDER_ITEM_BASE_KEYS = ["orderItemId", "quantityOrdered", "measurement", "associatedOrderItems", "programs", "product"]; + +interface IncludedDataMapping { + orderKeys: string[]; + orderItemKeys: string[]; +} + +const INCLUDED_DATA_MAP: Record = { + BUYER: { orderKeys: ["buyer"], orderItemKeys: [] }, + RECIPIENT: { orderKeys: ["recipient"], orderItemKeys: [] }, + FULFILLMENT: { orderKeys: ["fulfillment"], orderItemKeys: ["fulfillment"] }, + PROCEEDS: { orderKeys: ["proceeds"], orderItemKeys: ["proceeds"] }, + EXPENSE: { orderKeys: [], orderItemKeys: ["expense"] }, + PROMOTION: { orderKeys: [], orderItemKeys: ["promotion"] }, + CANCELLATION: { orderKeys: [], orderItemKeys: ["cancellation"] }, + PACKAGES: { orderKeys: ["packages"], orderItemKeys: [] }, + TAX: { orderKeys: ["tax"], orderItemKeys: ["tax"] }, + PAYMENT: { orderKeys: ["payment"], orderItemKeys: [] }, +}; + +function pickKeys(obj: Record, keys: string[]): Record { + const result: Record = {}; + for (const key of keys) { + if (key in obj) { + result[key] = obj[key]; + } + } + return result; +} + +function filterOrder(order: Record, includedData: string[]): Record { + // Determine allowed keys at order and order-item levels + const allowedOrderKeys = new Set(ORDER_BASE_KEYS); + const allowedItemKeys = new Set(ORDER_ITEM_BASE_KEYS); + + for (const attr of includedData) { + const mapping = INCLUDED_DATA_MAP[attr.toUpperCase()]; + if (mapping) { + for (const k of mapping.orderKeys) allowedOrderKeys.add(k); + for (const k of mapping.orderItemKeys) allowedItemKeys.add(k); + } + } + + // Filter order-level keys + const filtered = pickKeys(order, [...allowedOrderKeys]); + + // Filter each order item if present + if (Array.isArray(filtered.orderItems)) { + filtered.orderItems = (filtered.orderItems as Record[]).map((item) => pickKeys(item, [...allowedItemKeys])); + } + + return filtered; +} + +// --- Handlers --- + +export const getOrderHandler: OperationHandler = async (validationResult) => { + const orderId = validationResult.pathParams.orderId; + const order = Context.instance.engine.get(Api.ORDERS, orderId); + + if (!order) { + return { + statusCode: 404, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "NotFound", message: `Order ${orderId} not found.` }] } }, + }; + } + + const includedDataParam = validationResult.queryParams.includedData; + let resultOrder: Record; + + if (includedDataParam) { + const includedData = Array.isArray(includedDataParam) ? includedDataParam : [includedDataParam]; + resultOrder = filterOrder(order, includedData); + } else { + // No includedData specified — return only base keys + resultOrder = filterOrder(order, []); + } + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { order: resultOrder } }, + }; +}; + +// --- searchOrders handler --- + +function buildQuery(queryParams: Record): Record { + const conditions: Record[] = []; + + const createdAfter = queryParams.createdAfter as string | undefined; + const createdBefore = queryParams.createdBefore as string | undefined; + const lastUpdatedAfter = queryParams.lastUpdatedAfter as string | undefined; + const lastUpdatedBefore = queryParams.lastUpdatedBefore as string | undefined; + const fulfillmentStatus = queryParams.fulfillmentStatus as string | string[] | undefined; + const marketplaceIds = queryParams.marketplaceIds as string | string[] | undefined; + const fulfilledBy = queryParams.fulfilledBy as string | string[] | undefined; + + // createdTime range filter + if (createdAfter || createdBefore) { + const dateCondition: Record = {}; + if (createdAfter) dateCondition["$gte"] = createdAfter; + if (createdBefore) dateCondition["$lte"] = createdBefore; + conditions.push({ createdTime: dateCondition }); + } + + // lastUpdatedTime range filter + if (lastUpdatedAfter || lastUpdatedBefore) { + const dateCondition: Record = {}; + if (lastUpdatedAfter) dateCondition["$gte"] = lastUpdatedAfter; + if (lastUpdatedBefore) dateCondition["$lte"] = lastUpdatedBefore; + conditions.push({ lastUpdatedTime: dateCondition }); + } + + // fulfillment.fulfillmentStatus filter + if (fulfillmentStatus) { + const statuses = Array.isArray(fulfillmentStatus) ? fulfillmentStatus : [fulfillmentStatus]; + conditions.push({ "fulfillment.fulfillmentStatus": { $in: statuses } }); + } + + // salesChannel.marketplaceId filter + if (marketplaceIds) { + const ids = Array.isArray(marketplaceIds) ? marketplaceIds : [marketplaceIds]; + conditions.push({ "salesChannel.marketplaceId": { $in: ids } }); + } + + // fulfillment.fulfilledBy filter + if (fulfilledBy) { + const values = Array.isArray(fulfilledBy) ? fulfilledBy : [fulfilledBy]; + conditions.push({ "fulfillment.fulfilledBy": { $in: values } }); + } + + if (conditions.length === 0) return {}; + if (conditions.length === 1) return conditions[0]; + return { $and: conditions }; +} + +export const searchOrdersHandler: OperationHandler = async (validationResult) => { + const { queryParams } = validationResult; + const includedDataParam = queryParams.includedData as string | string[] | undefined; + + const collection = Context.instance.engine.find(Api.ORDERS, buildQuery(queryParams)); + if (!collection) { + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { orders: [] } }, + }; + } + + // Apply includedData filtering (base keys only when not specified) + let resultOrders: Record[]; + if (includedDataParam) { + const includedData = Array.isArray(includedDataParam) ? includedDataParam : [includedDataParam]; + resultOrders = collection.map((order) => filterOrder(order, includedData)); + } else { + resultOrders = collection.map((order) => filterOrder(order, [])); + } + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { orders: resultOrders } }, + }; +}; + +interface PackageDetailOrderItem { + orderItemId: string; + quantity: number; +} + +interface PackageDetail { + packageReferenceId: string; + carrierCode?: string; + carrierName?: string; + shipDate?: string; + shippingMethod?: string; + trackingNumber?: string; + orderItems: PackageDetailOrderItem[]; +} + +interface ConfirmShipmentBody { + packageDetail: PackageDetail; +} + +interface OrderItemFulfillment { + quantityFulfilled?: number; + quantityUnfulfilled?: number; + [key: string]: unknown; +} + +interface OrderItem { + orderItemId: string; + quantityOrdered?: number; + fulfillment?: OrderItemFulfillment; + [key: string]: unknown; +} + +interface PackageItem { + orderItemId: string; + quantity: number; +} + +interface OrderPackage { + packageReferenceId: string; + createdTime: string; + carrier: { carrierCode?: string; carrierName?: string }; + shipTime?: string; + shippingService?: string; + trackingNumber?: string; + packageStatus: { status: string }; + packageItems: PackageItem[]; +} + +export const confirmShipmentHandler: OperationHandler = async (validationResult, request) => { + const orderId = validationResult.pathParams.orderId; + const existingOrder = structuredClone(validationResult.resolvedEntities.order) as Record; + const body = request.body as ConfirmShipmentBody; + const packageDetail = body.packageDetail; + const now = new Date().toISOString(); + + // --- Update orderItems fulfillment quantities --- + const orderItems = (existingOrder.orderItems as OrderItem[]) ?? []; + const shippedQuantityMap = new Map(); + for (const pkgItem of packageDetail.orderItems) { + shippedQuantityMap.set(pkgItem.orderItemId, (shippedQuantityMap.get(pkgItem.orderItemId) ?? 0) + pkgItem.quantity); + } + + for (const item of orderItems) { + const shippedQty = shippedQuantityMap.get(item.orderItemId); + if (shippedQty != null) { + if (!item.fulfillment) { + const quantityOrdered = (item.quantityOrdered as number) ?? 0; + item.fulfillment = { quantityFulfilled: 0, quantityUnfulfilled: quantityOrdered }; + } + item.fulfillment.quantityFulfilled = (item.fulfillment.quantityFulfilled ?? 0) + shippedQty; + item.fulfillment.quantityUnfulfilled = (item.fulfillment.quantityUnfulfilled ?? 0) - shippedQty; + } + } + existingOrder.orderItems = orderItems; + + // --- Build the new package entry --- + const newPackage: OrderPackage = { + packageReferenceId: packageDetail.packageReferenceId, + createdTime: now, + carrier: { + carrierCode: packageDetail.carrierCode, + carrierName: packageDetail.carrierName, + }, + shipTime: packageDetail.shipDate, + shippingService: packageDetail.shippingMethod, + trackingNumber: packageDetail.trackingNumber, + packageStatus: { status: "SHIPPED" }, + packageItems: packageDetail.orderItems.map((pi) => ({ + orderItemId: pi.orderItemId, + quantity: pi.quantity, + })), + }; + + const packages = (existingOrder.packages as OrderPackage[]) ?? []; + packages.push(newPackage); + existingOrder.packages = packages; + + // --- Update lastUpdatedTime --- + existingOrder.lastUpdatedTime = now; + + // --- Determine overall fulfillment status --- + const allFulfilled = orderItems.every((item) => item.fulfillment != null && (item.fulfillment.quantityUnfulfilled ?? 0) <= 0); + const fulfillment = (existingOrder.fulfillment as Record) ?? {}; + fulfillment.fulfillmentStatus = allFulfilled ? "SHIPPED" : "PARTIALLY_SHIPPED"; + existingOrder.fulfillment = fulfillment; + + // Write updated order to database + Context.instance.engine.put(Api.ORDERS, orderId, existingOrder); + + return { + statusCode: 204, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: request.body as Record, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: {}, + writes: [{ type: "update", api: Api.ORDERS, id: orderId, entity: existingOrder }], + }; +}; \ No newline at end of file diff --git a/local-ai-sandbox/src/operation/passThroughOperations.ts b/local-ai-sandbox/src/operation/passThroughOperations.ts new file mode 100644 index 000000000..9014f1ef9 --- /dev/null +++ b/local-ai-sandbox/src/operation/passThroughOperations.ts @@ -0,0 +1,124 @@ +import type { Request } from "express"; +import type { OperationHandler } from "./operationTypes.js"; + +/** + * Pass-through target backend type. + * - "production": forwards to the real SP-API production endpoint + * - "sandbox": forwards to the SP-API sandbox endpoint + */ +export type PassThroughTarget = "production" | "sandbox"; + +const region = process.env.REGION && ["NA", "EU", "FE"].includes(process.env.REGION) ? process.env.REGION : "NA"; +export const PROD_BACKEND = `https://sellingpartnerapi-${region.toLowerCase()}.amazon.com`; +const SANDBOX_BACKEND = `https://sandbox.sellingpartnerapi-${region.toLowerCase()}.amazon.com`; + +/** + * Builds the target URL for a pass-through request. + * Extracts the raw query string from the original URL to preserve repeated keys + * (e.g., marketplaceIds=A&marketplaceIds=B) and original encoding. + */ +function buildTargetUrl(target: PassThroughTarget, request: Request): string { + const backend = target === "production" ? PROD_BACKEND : SANDBOX_BACKEND; + const queryString = request.originalUrl.split("?")[1] ?? ""; + return queryString ? `${backend}${request.path}?${queryString}` : `${backend}${request.path}`; +} + +/** + * Creates a generic pass-through operation handler that forwards the request + * to the specified backend (production or sandbox) and returns the raw response. + */ +function createPassThroughHandler(target: PassThroughTarget): OperationHandler { + return async (validationResult, request) => { + const accessToken = request.header("x-amz-access-token"); + if (!accessToken) { + return { + statusCode: 401, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: request.body as Record | undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "Unauthorized", message: "Access token is missing or empty. Provide a valid x-amz-access-token header." }] } }, + }; + } + + const url = buildTargetUrl(target, request); + const method = request.method; + + const headers: Record = { + "content-type": request.get("content-type") ?? "application/json", + "x-amz-access-token": accessToken, + }; + + const userAgent = request.header("user-agent"); + if (userAgent) { + headers["user-agent"] = userAgent; + } + + const fetchOptions: RequestInit = { + method, + headers, + }; + + if (!["GET", "HEAD", "DELETE"].includes(method.toUpperCase()) && request.body) { + fetchOptions.body = JSON.stringify(request.body); + } + + console.log(`[PassThrough] ${method} ${url} → ${target}`); + + try { + const response = await globalThis.fetch(url, fetchOptions); + + const text = await response.text(); + let responseBody: unknown; + try { + responseBody = text ? JSON.parse(text) : undefined; + } catch { + responseBody = { raw: text }; + } + + const FORWARDABLE_HEADERS = new Set(["x-amzn-ratelimit-limit", "x-amzn-requestid", "x-amz-request-id", "x-amzn-trace-id"]); + + const responseHeaders = new Map(); + response.headers.forEach((value, key) => { + if (FORWARDABLE_HEADERS.has(key.toLowerCase())) { + responseHeaders.set(key, value); + } + }); + + return { + statusCode: response.status, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: request.body as Record | undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: responseBody, headers: responseHeaders }, + }; + } catch (error) { + console.error("[PassThrough] Error proxying %s %s:", method, url, error); + + return { + statusCode: 502, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: request.body as Record | undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { errors: [{ code: "BadGateway", message: "Failed to proxy request to upstream service" }]}}, + }; + } + }; +} + +export const productionPassThroughHandler = createPassThroughHandler("production"); +export const sandboxPassThroughHandler = createPassThroughHandler("sandbox"); diff --git a/local-ai-sandbox/src/operation/pricingOperations.ts b/local-ai-sandbox/src/operation/pricingOperations.ts new file mode 100644 index 000000000..fc1c3ce68 --- /dev/null +++ b/local-ai-sandbox/src/operation/pricingOperations.ts @@ -0,0 +1,275 @@ +/** + * Deterministic pricing utilities and handler for the Product Pricing API + * getFeaturedOfferExpectedPriceBatch operation (v2022-05-01). + */ + +import type { OperationContext, OperationHandler } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; +import { getAllowedMarketplaceIds, MARKETPLACE_CURRENCY_MAP } from "../marketplaceIds.js"; + +// --- Deterministic Price Utilities --- + +/** + * Generates a stable integer hash from a string seed. + * Uses a simple but deterministic hash algorithm (djb2 variant). + */ +export function deterministicHash(seed: string): number { + let hash = 5381; + for (let i = 0; i < seed.length; i++) { + hash = (hash * 33) ^ seed.charCodeAt(i); + } + return Math.abs(hash); +} + +/** + * Computes the competing offer price deterministically. + * The competing price is 5-20% above the listing price, determined by the SKU hash. + */ +export function computeCompetingOfferPrice(listingPrice: number, sku: string): number { + const hash = deterministicHash(sku); + const markup = 1.05 + (hash % 15) / 100; + return Math.round(listingPrice * markup * 100) / 100; +} + +/** + * Computes the FOEP (Featured Offer Expected Price) deterministically. + * The FOEP is 2-8% below the competing offer price, determined by the SKU hash. + */ +export function computeFoepPrice(competingPrice: number, sku: string): number { + const hash = deterministicHash(sku); + const discountBasis = 2 + (hash % 7); + return Math.round(competingPrice * (1 - discountBasis / 100) * 100) / 100; +} + +/** + * Generates a deterministic seller ID from an ASIN. + * Produces a string that resembles an Amazon seller ID format. + */ +export function generateCompetingSellerId(asin: string): string { + const hash = deterministicHash(asin); + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let sellerId = "A"; + let h = hash; + for (let i = 0; i < 13; i++) { + sellerId += chars[h % chars.length]; + h = Math.abs((h * 31 + i) ^ (h >>> 3)); + } + return sellerId; +} + +/** + * Returns the ISO 4217 currency code for a given marketplace ID. + * Falls back to "USD" if the marketplace is not in the known mapping. + */ +export function getCurrencyForMarketplace(marketplaceId: string): string { + return MARKETPLACE_CURRENCY_MAP[marketplaceId] ?? "USD"; +} + +// --- Listing Data Extraction Helpers --- + +/** + * Extracts the listing price from the listing document. + * Path: purchasable_offer[0].our_price[0].schedule[0].value_with_tax + * Falls back to 29.99 if the path is not resolvable. + */ +function extractListingPrice(listing: Record): number { + try { + const purchasableOffer = listing.purchasable_offer as Record[] | undefined; + if (!purchasableOffer?.[0]) return 29.99; + const ourPrice = purchasableOffer[0].our_price as Record[] | undefined; + if (!ourPrice?.[0]) return 29.99; + const schedule = ourPrice[0].schedule as Record[] | undefined; + if (!schedule?.[0]) return 29.99; + const value = schedule[0].value_with_tax; + if (typeof value === "number" && Number.isFinite(value)) return value; + return 29.99; + } catch { + return 29.99; + } +} + +/** + * Extracts the ASIN from the listing document. + * Searches externally_assigned_product_identifier for an entry with type === "asin". + * Falls back to a deterministic ASIN generated from the SKU. + */ +function extractAsin(listing: Record, sku: string): string { + try { + const identifiers = listing.externally_assigned_product_identifier as { value: string; type: string }[] | undefined; + if (identifiers) { + const asinEntry = identifiers.find((entry) => entry.type === "asin"); + if (asinEntry?.value) return asinEntry.value; + } + } catch { + // Fall through to deterministic generation + } + // Deterministic fallback: generate ASIN from SKU + const hash = deterministicHash(sku); + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let asin = "B0"; + let h = hash; + for (let i = 0; i < 8; i++) { + asin += chars[h % chars.length]; + h = Math.abs((h * 31 + i) ^ (h >>> 3)); + } + return asin; +} + +/** + * Extracts the fulfillment type from the listing document. + * Maps "AMAZON_NA" / "AMAZON_EU" / "AMAZON_FE" to "AFN", everything else to "MFN". + * Defaults to "MFN" if the path is not resolvable. + */ +function extractFulfillmentType(listing: Record): "AFN" | "MFN" { + try { + const fulfillmentAvailability = listing.fulfillment_availability as { fulfillment_channel_code: string }[] | undefined; + if (!fulfillmentAvailability?.[0]) return "MFN"; + const channelCode = fulfillmentAvailability[0].fulfillment_channel_code; + if (channelCode === "AMAZON_NA" || channelCode === "AMAZON_EU" || channelCode === "AMAZON_FE") { + return "AFN"; + } + return "MFN"; + } catch { + return "MFN"; + } +} + +// --- Handler --- + +/** + * Handler for getFeaturedOfferExpectedPriceBatch (Product Pricing API v2022-05-01). + * + * Iterates over the batch `requests` array, validates marketplace IDs against + * the configured region, looks up SKUs in the listings database, and computes + * deterministic FOEP pricing for each found listing. + * + * Always returns outer HTTP 200; per-item errors are expressed as sub-responses. + */ +export const getFeaturedOfferExpectedPriceBatchHandler: OperationHandler = ( + validationResult, +): Promise => { + const body = validationResult.body; + const requests = ((body?.requests ?? []) as Record[]); + const allowedMarketplaceIds = getAllowedMarketplaceIds(); + + const responses: Record[] = []; + + for (const requestItem of requests) { + const marketplaceId = requestItem.marketplaceId as string; + const sku = requestItem.sku as string; + + // Step 1: Validate marketplace ID against configured region + if (!allowedMarketplaceIds.includes(marketplaceId)) { + responses.push({ + request: { marketplaceId, sku }, + status: { statusCode: 400, reasonPhrase: "Bad Request" }, + headers: { "Content-Type": "application/json" }, + body: { + errors: [ + { + code: "InvalidMarketplaceId", + message: "The marketplace ID is not valid for the configured region", + }, + ], + }, + }); + continue; + } + + // Step 2: Look up SKU in listings database + const listing = Context.instance.engine.get(Api.LISTINGS, sku); + + if (!listing) { + responses.push({ + request: { marketplaceId, sku }, + status: { statusCode: 400, reasonPhrase: "Bad Request" }, + headers: { "Content-Type": "application/json" }, + body: { + errors: [ + { + code: "INVALID_SKU", + message: "The requested SKU does not exist for the seller in the requested marketplace.", + }, + ], + }, + }); + continue; + } + + // Step 3: Extract listing data + const listingPrice = extractListingPrice(listing); + const asin = extractAsin(listing, sku); + const fulfillmentType = extractFulfillmentType(listing); + const currencyCode = getCurrencyForMarketplace(marketplaceId); + + // Step 4: Compute deterministic pricing + const competingPrice = computeCompetingOfferPrice(listingPrice, sku); + const foepPrice = computeFoepPrice(competingPrice, sku); + const competingSellerId = generateCompetingSellerId(asin); + + // Step 5: Build success sub-response + responses.push({ + request: { marketplaceId, sku }, + status: { statusCode: 200, reasonPhrase: "Success" }, + headers: { "Content-Type": "application/json" }, + body: { + offerIdentifier: { + marketplaceId, + sku, + asin, + fulfillmentType, + }, + featuredOfferExpectedPriceResults: [ + { + resultStatus: "VALID_FOEP", + featuredOfferExpectedPrice: { + listingPrice: { currencyCode, amount: foepPrice }, + }, + competingFeaturedOffer: { + offerIdentifier: { + marketplaceId, + sellerId: competingSellerId, + asin, + fulfillmentType, + }, + condition: "New", + price: { + listingPrice: { currencyCode, amount: competingPrice }, + shippingPrice: { currencyCode, amount: 0 }, + }, + }, + currentFeaturedOffer: { + offerIdentifier: { + marketplaceId, + sellerId: "CURRENT_SELLER", + asin, + fulfillmentType, + }, + condition: "New", + price: { + listingPrice: { currencyCode, amount: listingPrice }, + }, + }, + }, + ], + }, + }); + } + + return Promise.resolve({ + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: validationResult.body, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { + body: { + responses, + }, + }, + }); +}; diff --git a/local-ai-sandbox/src/operation/reportsOperations.ts b/local-ai-sandbox/src/operation/reportsOperations.ts new file mode 100644 index 000000000..0338dea8e --- /dev/null +++ b/local-ai-sandbox/src/operation/reportsOperations.ts @@ -0,0 +1,232 @@ +import { randomUUID } from "node:crypto"; +import { OperationHandler } from "./operationTypes.js"; +import { Api, Context } from "../database/Context.js"; +import { REPORT_GENERATORS } from "../service/reportGeneratorService.js"; + +// --- createReport --- + +export const createReportHandler: OperationHandler = async (validationResult, request) => { + const { reportType, marketplaceIds, dataStartTime, dataEndTime, reportOptions } = request.body as Record; + + const reportId = `REP-${randomUUID().slice(0, 8).toUpperCase()}`; + const documentId = `DOC-${randomUUID().slice(0, 8).toUpperCase()}`; + const generator = REPORT_GENERATORS[reportType as string]; + const content = generator((reportOptions as Record) ?? {}); + + Context.instance.engine.put(Api.REPORTS, documentId, { content, contentType: "text/tab-separated-values" }); + Context.instance.engine.put(Api.REPORTS, reportId, { + reportId, + reportType, + marketplaceIds, + dataStartTime, + dataEndTime, + reportOptions, + processingStatus: "DONE", + reportDocumentId: documentId, + createdTime: new Date().toISOString(), + processingStartTime: new Date().toISOString(), + processingEndTime: new Date().toISOString(), + }); + + return { + statusCode: 202, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: request.body as Record, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { reportId } }, + }; +}; + +// --- getReport --- + +export const getReportHandler: OperationHandler = async (validationResult) => { + const report = validationResult.resolvedEntities.report; + const { content: _, _key: _k, ...metadata } = report; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: metadata }, + }; +}; + +// --- getReports --- + +export const getReportsHandler: OperationHandler = async (validationResult) => { + const collection = Context.instance.engine.getCollection(Api.REPORTS); + const allDocs = collection + ? collection.find().map((d) => { + const { $loki, meta, _key, ...rest } = d as Record; + return rest; + }) + : []; + + const reportTypes = validationResult.queryParams.reportTypes as string | undefined; + const reports = allDocs + .filter((r: Record) => r.reportId && !r.content) + .filter((r: Record) => !reportTypes || reportTypes.split(",").includes(r.reportType as string)); + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { reports } }, + }; +}; + +// --- cancelReport --- + +export const cancelReportHandler: OperationHandler = async (validationResult) => { + const reportId = validationResult.pathParams.reportId; + const report = validationResult.resolvedEntities.report; + + report.processingStatus = "CANCELLED"; + Context.instance.engine.put(Api.REPORTS, reportId, report); + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: {}, + }; +}; + +// --- getReportDocument --- + +export const getReportDocumentHandler: OperationHandler = async (validationResult, request) => { + const reportDocumentId = validationResult.pathParams.reportDocumentId; + const host = request.get("host") ?? "localhost:9001"; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { + body: { + reportDocumentId, + url: `http://${host}/reports/download/${reportDocumentId}`, + }, + }, + }; +}; + +// --- createReportSchedule --- + +export const createReportScheduleHandler: OperationHandler = async (validationResult, request) => { + const scheduleId = `SCHED-${randomUUID().slice(0, 8).toUpperCase()}`; + Context.instance.engine.put(Api.REPORTS, scheduleId, { + reportScheduleId: scheduleId, + ...(request.body as Record), + createdTime: new Date().toISOString(), + }); + + return { + statusCode: 201, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: request.body as Record, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { reportScheduleId: scheduleId } }, + }; +}; + +// --- getReportSchedule --- + +export const getReportScheduleHandler: OperationHandler = async (validationResult) => { + const schedule = validationResult.resolvedEntities["report schedule"]; + const { _key: _, ...data } = schedule; + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: data }, + }; +}; + +// --- getReportSchedules --- + +export const getReportSchedulesHandler: OperationHandler = async (validationResult) => { + const collection = Context.instance.engine.getCollection(Api.REPORTS); + const allDocs = collection + ? collection.find().map((d) => { + const { $loki, meta, _key, ...rest } = d as Record; + return rest; + }) + : []; + + const schedules = allDocs.filter((r: Record) => r.reportScheduleId); + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: { body: { reportSchedules: schedules } }, + }; +}; + +// --- cancelReportSchedule --- + +export const cancelReportScheduleHandler: OperationHandler = async (validationResult) => { + const reportScheduleId = validationResult.pathParams.reportScheduleId; + await Context.instance.engine.remove(Api.REPORTS, reportScheduleId); + + return { + statusCode: 200, + operationId: validationResult.operationId, + apiName: validationResult.apiName, + apiVersion: validationResult.apiVersion, + pathParams: validationResult.pathParams, + queryParams: validationResult.queryParams, + body: undefined, + operation: validationResult.operation, + resolvedEntities: validationResult.resolvedEntities, + data: {}, + }; +}; diff --git a/local-ai-sandbox/src/registry/operationRegistry.ts b/local-ai-sandbox/src/registry/operationRegistry.ts new file mode 100644 index 000000000..4f9583789 --- /dev/null +++ b/local-ai-sandbox/src/registry/operationRegistry.ts @@ -0,0 +1,340 @@ +/** + * Operation Registry — the single source of truth for API-registration facts. + * + * Generated from the OpenAPI model files by scripts/generateOperationRegistry.ts and checked in + * at res/generated/operationRegistry.json. Every runtime call site (path identification, database + * partitions, resource retrieval, agent routing) reads from here instead of a hand-maintained map. + * + * The OperationHandlerRegistry singleton centralises handler registration: it calls each domain's + * registration function during construction, ensuring handlers are available from first access + * without side-effect imports in index.ts. + */ +import * as fs from "node:fs"; + +// --- Mode --- + +/** The operating mode of the sandbox, controlling which operations are available. */ +export type Mode = "Seller" | "Vendor"; + +/** All valid mode values. */ +export const MODES: Mode[] = ["Seller", "Vendor"] as const; + +/** + * Reads and validates the MODE environment variable. Defaults to "Seller" if unset. + * Throws if set to an invalid value. + * + * This is the single source of truth for mapping the MODE env var to a `Mode`. + * `CURRENT_MODE` captures it once at boot; callers that must observe runtime + * changes to `process.env.MODE` (e.g. handlers under test) should call this + * function rather than re-implementing the mapping. + */ +export function readModeFromEnv(): Mode { + const raw = process.env.MODE; + if (!raw) return "Seller"; + if (MODES.includes(raw as Mode)) return raw as Mode; + throw new Error(`Invalid MODE environment variable: "${raw}". Must be one of: ${MODES.join(", ")}`); +} + +/** The current operating mode, resolved once at module load. */ +export const CURRENT_MODE: Mode = readModeFromEnv(); +import { OperationHandler } from "../operation/operationTypes.js"; +import { getCatalogItemHandler, searchCatalogItemsHandler } from "../operation/catalogItemsOperations.js"; +import { confirmShipmentHandler, getOrderHandler, searchOrdersHandler } from "../operation/ordersOperations.js"; +import { + cancelReportHandler, + cancelReportScheduleHandler, + createReportHandler, + createReportScheduleHandler, + getReportDocumentHandler, + getReportHandler, + getReportScheduleHandler, + getReportSchedulesHandler, + getReportsHandler, +} from "../operation/reportsOperations.js"; +import { productionPassThroughHandler, sandboxPassThroughHandler } from "../operation/passThroughOperations.js"; +import { getInventorySummariesHandler } from "../operation/fbaInventoryOperations.js"; +import { + getListingsItemHandler, + searchListingsItemsHandler, + putListingsItemHandler, + patchListingsItemHandler, + deleteListingsItemHandler, +} from "../operation/listingsOperations.js"; +import { getListingsRestrictionsHandler } from "../operation/listingsRestrictionsOperations.js"; +import { getFeaturedOfferExpectedPriceBatchHandler } from "../operation/pricingOperations.js"; +import { listReturnsHandler, getReturnHandler } from "../operation/extFulfillmentReturnsOperations.js"; +import { + getShipmentsHandler, + getShipmentHandler, + processShipmentHandler, + createPackagesHandler, + updatePackageHandler, + updatePackageStatusHandler, + retrieveShippingOptionsHandler, + generateInvoiceHandler, + retrieveInvoiceHandler, + generateShipLabelsHandler, +} from "../operation/extFulfillmentShipmentsOperations.js"; +import { batchInventoryHandler } from "../operation/extFulfillmentInventoryOperations.js"; +import { + createDestinationHandler, + getDestinationsHandler, + getDestinationHandler, + deleteDestinationHandler, + createSubscriptionHandler, + getSubscriptionHandler, + getSubscriptionsHandler, + getSubscriptionByIdHandler, + deleteSubscriptionByIdHandler, +} from "../operation/notificationsOperations.js"; +import { + createQueryHandler, + getQueriesHandler, + getQueryHandler, + cancelQueryHandler, + getDocumentHandler, +} from "../operation/dataKioskOperations.js"; + +export interface OperationEntry { + operationId: string; + apiName: string; + apiVersion: string; + modelFile: string; + path: string; + method: string; + dbNamespace: string; + pathPrefix: string; +} + +export interface ModelIndexEntry { + modelFile: string; + apiName: string; + apiVersion: string; + pathPrefix: string; + dbNamespace: string; + resourcePath: string | null; +} + +export interface OperationRegistry { + operations: OperationEntry[]; + models: ModelIndexEntry[]; +} + +const REGISTRY_PATH = "res/generated/operationRegistry.json"; + +let cache: OperationRegistry | null = null; + +function data(): OperationRegistry { + cache ??= JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8")) as OperationRegistry; + return cache; +} + +/** Composite operation key: "apiName:apiVersion:operationId" (matches the validation engine's key format). */ +export function buildKey(apiName: string, apiVersion: string, operationId: string): string { + return `${apiName}:${apiVersion}:${operationId}`; +} + +/** The model whose pathPrefix is the longest prefix of the given request path, or undefined. */ +function findModelByPath(requestPath: string): ModelIndexEntry | undefined { + let best: ModelIndexEntry | undefined; + for (const m of data().models) { + if (m.pathPrefix && requestPath.startsWith(m.pathPrefix)) { + if (!best || m.pathPrefix.length > best.pathPrefix.length) best = m; + } + } + return best; +} + +export function identifyApiModel(requestPath: string): string | undefined { + return findModelByPath(requestPath)?.modelFile; +} + +export function identifyApiName(requestPath: string): string | undefined { + return findModelByPath(requestPath)?.apiName; +} + +export function identifyApiVersion(requestPath: string): string | undefined { + return findModelByPath(requestPath)?.apiVersion; +} + +/** Ranks versions so dated schemes (YYYY-MM-DD) outrank legacy vN, then most-recent first. */ +function versionRank(v: string): [number, string] { + return [/^\d{4}-\d{2}-\d{2}$/.test(v) ? 1 : 0, v]; +} + +/** + * Resource path for a database namespace: the resourcePath override if present, else the newest + * model file for that namespace. (Only the Orders namespace has multiple model versions; the + * newest — Orders 2026-01-01 — is selected, matching the previous modelMap.) + */ +export function getModelPath(dbNamespace: string): string | undefined { + const models = data().models.filter((m) => m.dbNamespace === dbNamespace); + if (models.length === 0) return undefined; + models.sort((a, b) => { + const [ta, va] = versionRank(a.apiVersion); + const [tb, vb] = versionRank(b.apiVersion); + return tb - ta || vb.localeCompare(va); + }); + const primary = models[0]; + return primary.resourcePath ?? `./res/models/${primary.modelFile}`; +} + +export function getOperationByKey(key: string): OperationEntry | undefined { + return data().operations.find((o) => buildKey(o.apiName, o.apiVersion, o.operationId) === key); +} + +/** All composite operation keys in the registry. */ +export function operationKeys(): string[] { + return data().operations.map((o) => buildKey(o.apiName, o.apiVersion, o.operationId)); +} + +/** Distinct database namespaces declared by the registry. */ +export function dbNamespaces(): string[] { + return [...new Set(data().models.map((m) => m.dbNamespace))].sort(); +} + +export function isDbNamespace(value: string): boolean { + return dbNamespaces().includes(value); +} + +/** + * Sanity-check the loaded registry. Throws if it is empty or contains duplicate composite keys. + * Call once at startup to fail fast on a corrupt or stale artifact. + */ +export function validate(): void { + const { operations, models } = data(); + if (operations.length === 0 || models.length === 0) { + throw new Error(`Operation registry at ${REGISTRY_PATH} is empty. Run "npm run registry:generate".`); + } + const seen = new Set(); + for (const o of operations) { + const key = buildKey(o.apiName, o.apiVersion, o.operationId); + if (seen.has(key)) throw new Error(`Operation registry contains a duplicate composite key: ${key}`); + seen.add(key); + } +} + +/** Test-only: drop the cached registry so a subsequent call reloads from disk. */ +export function __resetCacheForTests(): void { + cache = null; +} + +// --- Runtime Operation Handlers Registry (Singleton) --- + +/** + * Singleton that owns the runtime map of composite key → OperationHandler. + * All domain-specific handlers are registered during construction via registerAllHandlers(). + */ +class OperationHandlerRegistry { + private readonly handlers = new Map(); + private readonly supportedModes = new Map(); + + constructor() { + this.registerAllHandlers(); + } + + /** Invokes each domain module's registration function to populate the handler map. */ + private registerAllHandlers(): void { + this.register("Orders", "2026-01-01", "getOrder", getOrderHandler, ["Seller"]); + this.register("Orders", "2026-01-01", "searchOrders", searchOrdersHandler, ["Seller"]); + this.register("Orders", "v0", "confirmShipment", confirmShipmentHandler, ["Seller"]); + this.register("Reports", "2021-06-30", "createReport", createReportHandler); + this.register("Reports", "2021-06-30", "getReport", getReportHandler); + this.register("Reports", "2021-06-30", "getReports", getReportsHandler); + this.register("Reports", "2021-06-30", "cancelReport", cancelReportHandler); + this.register("Reports", "2021-06-30", "getReportDocument", getReportDocumentHandler); + this.register("Reports", "2021-06-30", "createReportSchedule", createReportScheduleHandler); + this.register("Reports", "2021-06-30", "getReportSchedule", getReportScheduleHandler); + this.register("Reports", "2021-06-30", "getReportSchedules", getReportSchedulesHandler); + this.register("Reports", "2021-06-30", "cancelReportSchedule", cancelReportScheduleHandler); + this.register("Product Type Definitions", "2020-09-01", "searchDefinitionsProductTypes", productionPassThroughHandler); + this.register("Product Type Definitions", "2020-09-01", "getDefinitionsProductType", productionPassThroughHandler); + // Listings Items is available to sellers AND vendors, so every operation + // is registered for both modes. What differs between them is not the + // operation but the data: the seller-only datasets (offers, + // fulfillmentAvailability), the vendor-only one (procurement), and the + // seller-only LISTING_OFFER_ONLY submission are gated by modeRestriction + // rules in validationRegistry.ts. + this.register("Listings", "2021-08-01", "getListingsItem", getListingsItemHandler); + this.register("Listings", "2021-08-01", "searchListingsItems", searchListingsItemsHandler); + this.register("Listings", "2021-08-01", "putListingsItem", putListingsItemHandler); + this.register("Listings", "2021-08-01", "patchListingsItem", patchListingsItemHandler); + this.register("Listings", "2021-08-01", "deleteListingsItem", deleteListingsItemHandler); + // Listings Restrictions is documented "Sellers only". + this.register("Listings Restrictions", "2021-08-01", "getListingsRestrictions", getListingsRestrictionsHandler, ["Seller"]); + this.register("Product Pricing", "2022-05-01", "getCompetitiveSummary", productionPassThroughHandler, ["Seller"]); + this.register("Product Pricing", "2022-05-01", "getFeaturedOfferExpectedPriceBatch", getFeaturedOfferExpectedPriceBatchHandler, ["Seller"]); + this.register("Catalog Items", "2022-04-01", "getCatalogItem", getCatalogItemHandler); + this.register("Catalog Items", "2022-04-01", "searchCatalogItems", searchCatalogItemsHandler); + this.register("FBA Inventory", "v1", "getInventorySummaries", getInventorySummariesHandler, ["Seller"]); + this.register("External Fulfillment Returns", "2024-09-11", "listReturns", listReturnsHandler, ["Seller"]); + this.register("External Fulfillment Returns", "2024-09-11", "getReturn", getReturnHandler, ["Seller"]); + this.register("External Fulfillment Shipments", "2024-09-11", "getShipments", getShipmentsHandler, ["Seller"]); + this.register("External Fulfillment Shipments", "2024-09-11", "getShipment", getShipmentHandler, ["Seller"]); + this.register("External Fulfillment Shipments", "2024-09-11", "processShipment", processShipmentHandler, ["Seller"]); + this.register("External Fulfillment Shipments", "2024-09-11", "createPackages", createPackagesHandler, ["Seller"]); + this.register("External Fulfillment Shipments", "2024-09-11", "updatePackage", updatePackageHandler, ["Seller"]); + this.register("External Fulfillment Shipments", "2024-09-11", "updatePackageStatus", updatePackageStatusHandler, ["Seller"]); + this.register("External Fulfillment Shipments", "2024-09-11", "retrieveShippingOptions", retrieveShippingOptionsHandler, ["Seller"]); + this.register("External Fulfillment Shipments", "2024-09-11", "generateInvoice", generateInvoiceHandler, ["Seller"]); + this.register("External Fulfillment Shipments", "2024-09-11", "retrieveInvoice", retrieveInvoiceHandler, ["Seller"]); + this.register("External Fulfillment Shipments", "2024-09-11", "generateShipLabels", generateShipLabelsHandler, ["Seller"]); + this.register("External Fulfillment Inventory", "2024-09-11", "batchInventory", batchInventoryHandler, ["Seller"]); + this.register("Notifications", "v1", "createDestination", createDestinationHandler); + this.register("Notifications", "v1", "getDestinations", getDestinationsHandler); + this.register("Notifications", "v1", "getDestination", getDestinationHandler); + this.register("Notifications", "v1", "deleteDestination", deleteDestinationHandler); + this.register("Notifications", "v1", "createSubscription", createSubscriptionHandler); + this.register("Notifications", "v1", "getSubscription", getSubscriptionHandler); + this.register("Notifications", "v1", "getSubscriptions", getSubscriptionsHandler); + this.register("Notifications", "v1", "getSubscriptionById", getSubscriptionByIdHandler); + this.register("Notifications", "v1", "deleteSubscriptionById", deleteSubscriptionByIdHandler); + // Data Kiosk is available to sellers AND vendors (both modes, the default). + this.register("Data Kiosk", "2023-11-15", "createQuery", createQueryHandler); + this.register("Data Kiosk", "2023-11-15", "getQueries", getQueriesHandler); + this.register("Data Kiosk", "2023-11-15", "getQuery", getQueryHandler); + this.register("Data Kiosk", "2023-11-15", "cancelQuery", cancelQueryHandler); + this.register("Data Kiosk", "2023-11-15", "getDocument", getDocumentHandler); + } + + /** + * Registers a single operation handler. + * @param supportedModes - Which modes this operation is available in. Defaults to all modes (both Seller and Vendor). + */ + register( + apiName: string, + apiVersion: string, + operationId: string, + handler: OperationHandler, + supportedModes = MODES + ): void { + const key = buildKey(apiName, apiVersion, operationId); + this.handlers.set(key, handler); + this.supportedModes.set(key, supportedModes); + } + + /** Retrieves the handler for a composite key, or undefined if none is registered. */ + get(key: string): OperationHandler | undefined { + return this.handlers.get(key); + } + + /** Returns true if the operation is allowed in the current MODE, otherwise false. */ + isAllowedInCurrentMode(key: string): boolean { + const modes = this.supportedModes.get(key); + if (!modes) return false; + return modes.includes(CURRENT_MODE); + } +} + +/** Singleton instance — handlers are registered on first access. */ +let instance: OperationHandlerRegistry | null = null; + +/** + * The runtime operation handler registry singleton. + */ +export const OPERATIONS_REGISTRY: OperationHandlerRegistry = (() => { + instance ??= new OperationHandlerRegistry(); + return instance; +})(); + + diff --git a/local-ai-sandbox/src/service/Paginator.ts b/local-ai-sandbox/src/service/Paginator.ts new file mode 100644 index 000000000..7584dfc88 --- /dev/null +++ b/local-ai-sandbox/src/service/Paginator.ts @@ -0,0 +1,119 @@ +/** + * Reusable offset-based pagination utility. + * + * Encodes/decodes opaque page tokens (base64-wrapped JSON) and slices result + * sets into pages with optional nextToken / previousToken generation. + */ + +// --- Token encoding / decoding --- + +/** + * Encodes an offset into an opaque base64 page token. + */ +export function encodePageToken(offset: number): string { + return Buffer.from(JSON.stringify({ offset })).toString("base64"); +} + +/** + * Decodes a base64 page token into an offset number. + * Returns null if the token is invalid or cannot be parsed. + */ +export function decodePageToken(token: string): number | null { + try { + const parsed: unknown = JSON.parse(Buffer.from(token, "base64").toString("utf8")); + if (typeof parsed === "object" && parsed !== null && "offset" in parsed) { + const offset = (parsed as Record).offset; + if (typeof offset === "number" && Number.isFinite(offset) && offset >= 0) { + return offset; + } + } + return null; + } catch { + return null; + } +} + +// --- Paginator class --- + +export interface PaginationResult { + /** Items on the current page. */ + page: T[]; + /** Total number of items before pagination. */ + numberOfResults: number; + /** Token pointing to the next page (undefined when on the last page). */ + nextToken?: string; + /** Token pointing to the previous page (undefined when on the first page). */ + previousToken?: string; +} + +export interface PaginatorOptions { + /** Default page size when none is specified by the caller. */ + defaultPageSize: number; + /** Maximum allowed page size (values above this are capped). */ + maxPageSize: number; +} + +/** + * A configurable paginator that slices arrays into pages using opaque tokens. + * + * Usage: + * ```ts + * const paginator = new Paginator({ defaultPageSize: 10, maxPageSize: 100 }); + * const result = paginator.paginate(items, { pageSize: 25, pageToken: token }); + * ``` + */ +export class Paginator { + private readonly defaultPageSize: number; + private readonly maxPageSize: number; + + constructor(options: PaginatorOptions) { + this.defaultPageSize = options.defaultPageSize; + this.maxPageSize = options.maxPageSize; + } + + /** + * Normalizes a raw page-size value (string or number) into a valid integer + * bounded by defaultPageSize and maxPageSize. + */ + normalizePageSize(raw: string | number | undefined | null): number { + if (raw === undefined || raw === null) return this.defaultPageSize; + const parsed = typeof raw === "string" ? parseInt(raw, 10) : raw; + if (!Number.isFinite(parsed) || parsed < 1) return this.defaultPageSize; + return Math.min(parsed, this.maxPageSize); + } + + /** + * Paginates an array of items. + * + * - If the token is invalid or points beyond the array, returns an empty page + * with the correct numberOfResults (graceful degradation). + * - Generates nextToken / previousToken when applicable. + */ + paginate(items: T[], options?: { pageSize?: string | number | null; pageToken?: string }): PaginationResult { + const effectivePageSize = this.normalizePageSize(options?.pageSize); + const numberOfResults = items.length; + + let offset = 0; + if (options?.pageToken !== undefined && options.pageToken !== null) { + const decoded = decodePageToken(options.pageToken); + if (decoded === null || decoded >= items.length) { + return { page: [], numberOfResults }; + } + offset = decoded; + } + + const page = items.slice(offset, offset + effectivePageSize); + + const result: PaginationResult = { page, numberOfResults }; + + if (offset + effectivePageSize < items.length) { + result.nextToken = encodePageToken(offset + effectivePageSize); + } + + if (offset > 0) { + result.previousToken = encodePageToken(Math.max(0, offset - effectivePageSize)); + } + + return result; + } +} diff --git a/local-ai-sandbox/src/service/apiSchemaIdentificationService.ts b/local-ai-sandbox/src/service/apiSchemaIdentificationService.ts index d68691d8e..9d14b608c 100644 --- a/local-ai-sandbox/src/service/apiSchemaIdentificationService.ts +++ b/local-ai-sandbox/src/service/apiSchemaIdentificationService.ts @@ -1,17 +1,15 @@ -export const identifyApiModel = (path: string): string | undefined => { - const model = [...models.entries()].find((entry) => path.includes(entry[0])); - return model ? model[1] : undefined; -}; +/** + * Identifies which SP-API OpenAPI model (and its API name / version) applies to a request path. + * + * Backed entirely by the generated Operation Registry — there is no hand-maintained path map here. + * The deterministic validation engine reads apiName/apiVersion through these functions, so the + * registry is the single source of truth for identification. + * See src/registry/operationRegistry.ts and res/generated/operationRegistry.json. + */ +import { identifyApiModel as registryModel, identifyApiName as registryName, identifyApiVersion as registryVersion } from "../registry/operationRegistry.js"; -const models = new Map([ - ["/listings/2021-08-01/items", "listingsItems_2021-08-01.json"], - ["/orders/v0", "ordersV0.json"], - ["/orders/2026-01-01", "orders_2026-01-01.json"], - ["/fba/inventory/v1", "fbaInventory_v1.json"], - ["/externalFulfillment/inventory/2024-09-11", "externalFulfillmentInventory_2024-09-11.json"], - ["/externalFulfillment/2024-09-11/returns", "externalFulfillmentReturns_2024-09-11.json"], - ["/externalFulfillment/2024-09-11/shipments", "externalFulfillmentShipments_2024-09-11.json"], - ["/catalog/2022-04-01/items", "catalogItems_2022-04-01.json"], - ["/batches/products/pricing/2022-05-01", "productPricing_2022-05-01.json"], - ["/reports/2021-06-30", "reports_2021-06-30.json"], -]); +export const identifyApiModel = (path: string): string | undefined => registryModel(path); + +export const identifyApiName = (path: string): string | undefined => registryName(path); + +export const identifyApiVersion = (path: string): string | undefined => registryVersion(path); diff --git a/local-ai-sandbox/src/service/reportValidationService.ts b/local-ai-sandbox/src/service/reportValidationService.ts index 1c87b3a66..aa068d0e1 100644 --- a/local-ai-sandbox/src/service/reportValidationService.ts +++ b/local-ai-sandbox/src/service/reportValidationService.ts @@ -2,24 +2,8 @@ * Report type metadata: marketplace availability, scheduling support, and reportOptions validation. */ -// NA: US, CA, MX, BR | EU: UK, DE, FR, IT, ES, NL, SE, PL, TR, SA, AE, IN, EG | FE: JP, AU, SG -const NA = ["ATVPDKIKX0DER", "A2EUQ1WTGCTBG2", "A1AM78C64UM0Y8", "A2Q3Y263D00KWC"]; -const EU = [ - "A1F83G8C2ARO7P", - "A1PA6795UKMFR9", - "A13V1IB3VIYZZH", - "APJ6JRA9NG5V4", - "A1RKKUPIHCS9HS", - "A1805IZSGTT6HS", - "A2NODRKZP88ZB9", - "A21TJRUUN4KGV", - "A17E79C6D8DWNP", - "ARBP9OOSHTCHU", - "A2VIGQ35RCS4UG", - "A33AVAJ2PDY3EV", -]; -const FE = ["A1VC38T7YXB528", "A39IBJ37TRP1C6", "A19VAU5U5O7RUS"]; -const ALL = [...NA, ...EU, ...FE]; +import { MARKETPLACE_IDS_EU, MARKETPLACE_IDS_ALL } from "../marketplaceIds.js"; + interface ReportOptionRule { allowed?: string[]; @@ -28,7 +12,7 @@ interface ReportOptionRule { } interface ReportMeta { - marketplaces: string[]; + marketplaces: readonly string[]; schedulable: boolean; reportOptions?: Record; requiresDateRange?: boolean; @@ -36,15 +20,15 @@ interface ReportMeta { export const REPORT_META: Record = { GET_AFN_INVENTORY_DATA: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: false, }, GET_AFN_INVENTORY_DATA_BY_COUNTRY: { - marketplaces: EU, + marketplaces: MARKETPLACE_IDS_EU, schedulable: false, }, GET_LEDGER_SUMMARY_VIEW_DATA: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: false, requiresDateRange: true, reportOptions: { @@ -56,7 +40,7 @@ export const REPORT_META: Record = { }, }, GET_LEDGER_DETAIL_VIEW_DATA: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: false, requiresDateRange: true, reportOptions: { @@ -67,43 +51,43 @@ export const REPORT_META: Record = { }, }, GET_RESERVED_INVENTORY_DATA: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: false, }, GET_FBA_MYI_UNSUPPRESSED_INVENTORY_DATA: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: false, }, GET_FBA_MYI_ALL_INVENTORY_DATA: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: false, }, GET_RESTOCK_INVENTORY_RECOMMENDATIONS_REPORT: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: false, }, GET_FBA_FULFILLMENT_INBOUND_NONCOMPLIANCE_DATA: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: false, }, GET_STRANDED_INVENTORY_UI_DATA: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: false, }, GET_STRANDED_INVENTORY_LOADER_DATA: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: false, }, GET_FBA_STORAGE_FEE_CHARGES_DATA: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: true, }, GET_FBA_INVENTORY_PLANNING_DATA: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: false, }, GET_FBA_OVERAGE_FEE_CHARGES_DATA: { - marketplaces: ALL, + marketplaces: MARKETPLACE_IDS_ALL, schedulable: false, }, }; diff --git a/local-ai-sandbox/src/service/telemetryService.ts b/local-ai-sandbox/src/service/telemetryService.ts deleted file mode 100644 index 87f0f8721..000000000 --- a/local-ai-sandbox/src/service/telemetryService.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { MeterProvider, PeriodicExportingMetricReader, AggregationTemporality } from "@opentelemetry/sdk-metrics"; -import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; -import { resourceFromAttributes } from "@opentelemetry/resources"; -import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"; -import type { Counter, Histogram } from "@opentelemetry/api"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { randomUUID } from "node:crypto"; - -const SERVICE_NAME = "sp-api-sandbox"; -const SERVICE_VERSION = "1.0.0"; -const OTLP_ENDPOINT = "https://us-east-1.prod.mcp-telemetry.dev-tools.aws.dev/v1/metrics"; -const EXPORT_INTERVAL_MS = 60_000; -const EXPORT_TIMEOUT_MS = 10_000; -const INSTALL_DIR = join(homedir(), ".sp-api-sandbox"); -const INSTALL_FILE = join(INSTALL_DIR, "installation_id"); - -const enabled = () => process.env.SP_API_SANDBOX_TELEMETRY_ENABLED !== "false"; - -let cachedInstallId: string | null = null; -let sessionId: string | null = null; - -function getInstallationId(): string { - if (cachedInstallId) return cachedInstallId; - try { - if (!existsSync(INSTALL_DIR)) mkdirSync(INSTALL_DIR, { recursive: true }); - if (existsSync(INSTALL_FILE)) { - const id = readFileSync(INSTALL_FILE, "utf-8").trim(); - if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { - cachedInstallId = id; - return id; - } - } - const newId = randomUUID(); - writeFileSync(INSTALL_FILE, newId, "utf-8"); - cachedInstallId = newId; - return newId; - } catch { - cachedInstallId = randomUUID(); - return cachedInstallId; - } -} - -function getSessionId(): string { - sessionId ??= randomUUID(); - return sessionId; -} - -let meterProvider: MeterProvider | null = null; -let requestCounter: Counter | null = null; -let responseTimeHistogram: Histogram | null = null; -let errorCounter: Counter | null = null; -let newUserCounter: Counter | null = null; -const seenInstallations = new Set(); - -function init() { - if (!enabled()) return; - try { - const resource = resourceFromAttributes({ - [ATTR_SERVICE_NAME]: SERVICE_NAME, - [ATTR_SERVICE_VERSION]: SERVICE_VERSION, - "mcp.installation.id": getInstallationId(), - }); - const exporter = new OTLPMetricExporter({ - url: OTLP_ENDPOINT, - timeoutMillis: EXPORT_TIMEOUT_MS, - temporalityPreference: AggregationTemporality.DELTA, - }); - const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: EXPORT_INTERVAL_MS }); - meterProvider = new MeterProvider({ resource, readers: [reader] }); - const meter = meterProvider.getMeter(SERVICE_NAME, SERVICE_VERSION); - requestCounter = meter.createCounter("mcp.tool.invocations", { description: "Total sandbox requests" }); - responseTimeHistogram = meter.createHistogram("mcp.tool.duration", { description: "Response time in ms", unit: "ms" }); - errorCounter = meter.createCounter("mcp.errors", { description: "Server errors" }); - newUserCounter = meter.createCounter("mcp.new_installations", { description: "New unique installations" }); - } catch { - /* fail-safe */ - } -} - -export function trackRequest(apiSection: string, responseTime: number, serverResponse: number) { - if (!enabled() || !requestCounter) return; - try { - const installId = getInstallationId(); - const attrs = { "mcp.tool.name": apiSection, "mcp.installation.id": installId, "mcp.session.id": getSessionId() }; - requestCounter.add(1, { ...attrs, "mcp.tool.status": serverResponse < 500 ? "success" : "error" }); - responseTimeHistogram!.record(responseTime, attrs); - if (serverResponse >= 500) errorCounter!.add(1, { ...attrs, "mcp.error.type": `HTTP_${serverResponse}` }); - if (!seenInstallations.has(installId)) { - seenInstallations.add(installId); - newUserCounter!.add(1, { new_user: true, "mcp.installation.id": installId }); - } - } catch { - /* fail-safe */ - } -} - -export function identifyApiSection(path: string): string { - if (path.includes("/listings/")) return "listings"; - if (path.includes("/orders/")) return "orders"; - if (path.includes("/catalog/")) return "catalog"; - if (path.includes("/fba/inventory/")) return "fba_inventory"; - if (path.includes("/externalFulfillment/")) return "external_fulfillment"; - if (path.includes("/reports/")) return "reports"; - if (path.includes("/products/pricing/") || path.includes("/batches/products/pricing/")) return "pricing"; - if (path.includes("/definitions/")) return "definitions"; - return "other"; -} - -export async function shutdownTelemetry(): Promise { - if (meterProvider) { - await meterProvider.shutdown(); - meterProvider = null; - } -} - -init(); diff --git a/local-ai-sandbox/src/service/validationEngine.ts b/local-ai-sandbox/src/service/validationEngine.ts new file mode 100644 index 000000000..550a32038 --- /dev/null +++ b/local-ai-sandbox/src/service/validationEngine.ts @@ -0,0 +1,1265 @@ +import { + ArrayItemFieldValueRule, + AtLeastOneRequiredRule, + AtMostOneAllowedRule, + BatchSizeLimitRule, + BusinessRuleCheck, + ConditionalExclusionRule, + ConditionalRequirementRule, + DateComparisonRule, + EntityExistenceRule, + EntityFieldCheckRule, + MarketplaceIdValidationRule, + ModeRestrictionRule, + MutualExclusivityRule, + OrderItemExistenceRule, + QuantityLimitRule, + ReportMetaValidationRule, + ReportSchedulableRule, + ReportTypeSupportedRule, + RequestContext, + RequiredTogetherRule, + RuleHandler, + StringLengthLimitRule, UnifiedValidationResult, + ValidationFail, + ValidationResult, + ValidationRule, +} from "../validation/validationTypes.js"; +import { VALIDATION_REGISTRY } from "../validation/validationRegistry.js"; +import { Context } from "../database/Context.js"; +import { buildEntityKey } from "../database/types.js"; +import { identifyApiModel, identifyApiName, identifyApiVersion } from "./apiSchemaIdentificationService.js"; +import { getAllowedMarketplaceIds } from "../marketplaceIds.js"; +import Enforcer from "openapi-enforcer"; +import { Request } from "express"; +import { CURRENT_MODE } from "../registry/operationRegistry.js"; +import { buildKey } from "../registry/operationRegistry.js"; +import { REPORT_GENERATORS } from "./reportGeneratorService.js"; +import { validateReport, REPORT_META } from "./reportValidationService.js"; + +// --- Schema Validation Stage Types --- + +interface SchemaValidationSuccess { + pass: true; + operationId: string; + apiName: string; + apiVersion: string; + pathParams: Record; + queryParams: Record; + operation: any; +} + +interface SchemaValidationFailure { + pass: false; + statusCode: number; + errors?: { errors: { code: string; message: string; details?: string }[] }; +} + +type SchemaValidationResult = SchemaValidationSuccess | SchemaValidationFailure; + +/** + * Internal function. Validates the raw request against the OpenAPI spec. + * - Returns 404 (no body) if path doesn't match any known API model + * - Returns 400 (with error details) if request violates the schema + * - Returns success with extracted operationId, apiName, apiVersion, parsed params + */ +async function performSchemaValidation(request: Request): Promise { + const model = identifyApiModel(request.path); + + if (!model) { + return { pass: false, statusCode: 404 }; + } + + const openapiEnforcer = await Enforcer("./res/models/" + model, { + componentOptions: { + exceptionSkipCodes: ["WSCH006"], + }, + }); + + let result; + if (["GET", "DELETE"].includes(request.method)) { + result = openapiEnforcer.request({ + method: request.method, + path: request.path, + query: Object.assign({}, request.query), + headers: Object.assign({}, request.headers), + }); + } else { + result = openapiEnforcer.request({ + method: request.method, + path: request.path, + body: Object.assign({}, request.body), + query: Object.assign({}, request.query), + headers: Object.assign({}, request.headers), + }); + } + + const [value, error] = result; + + if (value) { + const operation = value.operation; + const operationId = operation.operationId as string; + const pathParams = value.path as Record; + const queryParams = value.query as Record; + const apiName = identifyApiName(request.path) ?? ""; + const apiVersion = identifyApiVersion(request.path) ?? ""; + + return { pass: true, operationId, apiName, apiVersion, pathParams, queryParams, operation }; + } else { + return { + pass: false, + statusCode: 400, + errors: { + errors: [ + { + code: "SchemaValidationError", + message: error.toString().replace(/\s+/g, " ").trim(), + }, + ], + }, + }; + } +} + +/** + * Unified validation entry point. Performs: + * 1. Schema_Validation_Stage (openapi-enforcer against SP-API OpenAPI specs) + * 2. Validation_Key lookup (apiName:apiVersion:operationId) + * 3. Pipeline rule execution (if pipeline registered) + * + * Returns either a pass result with all extracted context data, or a fail + * result with HTTP status code and error body. + */ +export async function validateRequest(request: Request): Promise { + const schemaResult = await performSchemaValidation(request); + + if (!schemaResult.pass) { + return { + pass: false, + statusCode: schemaResult.statusCode, + body: schemaResult.errors, + }; + } + + const requestContext: RequestContext = { + operationId: schemaResult.operationId, + apiName: schemaResult.apiName, + apiVersion: schemaResult.apiVersion, + method: request.method, + pathParams: schemaResult.pathParams, + queryParams: schemaResult.queryParams, + body: request.body as Record | undefined, + }; + + const pipelineResult = await executeValidation(requestContext); + + if (!pipelineResult.pass) { + return { + pass: false, + statusCode: pipelineResult.statusCode, + body: pipelineResult.body, + }; + } + + return { + pass: true, + operationId: schemaResult.operationId, + apiName: schemaResult.apiName, + apiVersion: schemaResult.apiVersion, + pathParams: schemaResult.pathParams, + queryParams: schemaResult.queryParams, + body: request.body as Record | undefined, + resolvedEntities: pipelineResult.resolvedEntities, + operation: schemaResult.operation, + }; +} + +/** + * Resolves a parameter value from the request context based on its name and source. + * Returns the value if found, or undefined if absent — never throws. + */ +export function resolveParam(context: RequestContext, name: string, source: "path" | "query" | "body"): unknown { + switch (source) { + case "path": + return Object.hasOwn(context.pathParams, name) ? context.pathParams[name] : undefined; + case "query": + return Object.hasOwn(context.queryParams, name) ? context.queryParams[name] : undefined; + case "body": + return context.body && Object.hasOwn(context.body, name) ? context.body[name] : undefined; + } +} + +/** True when a resolved parameter value counts as supplied by the caller. */ +function isParamPresent(value: unknown): boolean { + return value !== undefined && value !== null && value !== ""; +} + +/** + * Internal registry of rule handlers keyed by check type identifier. + * New rule types can be added via registerRuleHandler without modifying engine code. + */ +const ruleHandlers = new Map(); + +/** + * Registers a handler function for a given check type. + * This allows extending the validation engine with new rule types. + */ +export function registerRuleHandler(checkType: string, handler: RuleHandler): void { + ruleHandlers.set(checkType, handler); +} + +/** + * Builds a ValidationFail result from a rule definition and optional custom message. + * Uses failAction.code if specified, otherwise falls back to rule.checkType as the error code. + */ +export function buildFailResult(rule: ValidationRule, message?: string): ValidationFail { + return { + pass: false, + statusCode: rule.failAction.statusCode, + body: { + errors: [ + { + code: rule.failAction.code ?? rule.checkType, + message: message ?? rule.failAction.message, + ...(rule.failAction.details ? { details: rule.failAction.details } : {}), + }, + ], + }, + }; +} + +/** + * Executes the validation pipeline for the given request context. + * + * 1. Builds a composite Validation_Key from apiName, apiVersion, and operationId. + * 2. Looks up the pipeline by Validation_Key from the registry. + * 3. If no pipeline is registered, returns a 400 failure indicating the operation has no validation pipeline. + * 4. If the pipeline is empty, returns { pass: true }. + * 5. Iterates rules in array-index order. + * 6. For each rule, finds the corresponding handler in ruleHandlers and invokes it. + * 7. If a rule fails, returns immediately (short-circuit). + * 8. If all rules pass, returns { pass: true }. + */ +export async function executeValidation(context: RequestContext): Promise { + const validationKey = buildKey(context.apiName, context.apiVersion, context.operationId); + const pipeline = VALIDATION_REGISTRY.get(validationKey); + + if (!pipeline) { + return { + pass: false, + statusCode: 501, + body: { + errors: [ + { + code: "NoValidationPipeline", + message: `No validation pipeline registered for '${validationKey}'`, + }, + ], + }, + }; + } + + if (pipeline.length === 0) { + return { pass: true, resolvedEntities: {} }; + } + + const resolvedEntities: Record> = {}; + + for (const rule of pipeline) { + const handler = ruleHandlers.get(rule.checkType); + if (!handler) { + // If no handler is registered for this check type, skip the rule (pass) + continue; + } + + const result = await handler(rule, context, resolvedEntities); + if (!result.pass) { + return result; + } + + // Merge any resolvedEntities from the handler's result into the accumulated map + if (result.resolvedEntities) { + for (const [key, value] of Object.entries(result.resolvedEntities)) { + resolvedEntities[key] = value; + } + } + } + + return { pass: true, resolvedEntities }; +} + +// --- entityExistence Rule Handler --- + +/** + * Key used to look the entity up: the single identifier param by default, or + * `keyParams` joined in key order. Undefined when a key part is missing. + */ +function resolveEntityKey(rule: EntityExistenceRule, context: RequestContext, idValue: unknown): string | undefined { + const keyParams = rule.entity.keyParams; + if (!keyParams) return String(idValue); + + const parts: string[] = []; + for (const param of keyParams) { + const value = resolveParam(context, param.name, param.source); + // Key parts come from the request line, so anything non-scalar means the + // key cannot be formed and the entity cannot be resolved. + if (typeof value !== "string" && typeof value !== "number") return undefined; + const part = String(value); + if (part === "") return undefined; + parts.push(part); + } + return buildEntityKey(parts); +} + +/** + * Handler for the "entityExistence" check type. Resolves an entity by + * `paramName`, or by the composite `keyParams` key; a `nested` rule then + * searches the parent's child collection. + * + * Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5 + */ +const entityExistenceHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as EntityExistenceRule; + + const idValue = resolveParam(context, typedRule.entity.paramName, typedRule.entity.paramSource); + + // If the entity ID is not provided, pass validation (don't fail if no ID given) + if (idValue === undefined || idValue === null || idValue === "") { + return { pass: true, resolvedEntities: {} }; + } + + const entityKey = resolveEntityKey(typedRule, context, idValue); + + // Key-based lookup (the database stores entities keyed by their identifier) + let parentEntity = entityKey === undefined ? null : Context.instance.engine.get(typedRule.entity.api, entityKey); + + // Some partitions store more than one record shape under the same keyspace + // (e.g. Notifications destinations and subscriptions, both keyed by their + // own UUID within Api.NOTIFICATIONS). If `expectedType` is set, a record + // found under a different `_type` is treated as not found, so an ID that + // belongs to the other record type never resolves here. + if (parentEntity && typedRule.entity.expectedType !== undefined && (parentEntity as Record)._type !== typedRule.entity.expectedType) { + parentEntity = null; + } + + if (!typedRule.nested) { + // Flat lookup + if (!parentEntity) { + return buildFailResult(rule, `${typedRule.entity.entityLabel} with id '${String(idValue)}' not found`); + } + return { pass: true, resolvedEntities: { [typedRule.entity.entityLabel]: parentEntity } }; + } + + // Nested lookup: verify parent exists first + if (!parentEntity) { + return buildFailResult(rule, `${typedRule.entity.entityLabel} with id '${String(idValue)}' not found`); + } + + // Parent exists — now look for child + const childIdValue = resolveParam(context, typedRule.nested.childParamName, typedRule.nested.childParamSource); + + // If the child ID is not provided, pass validation + if (childIdValue === undefined || childIdValue === null || childIdValue === "") { + return { pass: true, resolvedEntities: {} }; + } + + const childCollection = (parentEntity as Record)[typedRule.nested.childCollection]; + + if (!Array.isArray(childCollection)) { + // If the child collection doesn't exist or isn't an array, child is not found + return buildFailResult(rule, `${typedRule.nested.childLabel} with id '${String(childIdValue)}' not found`); + } + + const childEntity = childCollection.find((child: Record) => child[typedRule.nested!.childIdField] === childIdValue); + + if (!childEntity) { + return buildFailResult(rule, `${typedRule.nested.childLabel} with id '${String(childIdValue)}' not found`); + } + + return { pass: true, resolvedEntities: { [typedRule.entity.entityLabel]: parentEntity, [typedRule.nested.childLabel]: childEntity as Record } }; +}; + +registerRuleHandler("entityExistence", entityExistenceHandler); + +// --- atLeastOneRequired Rule Handler --- + +/** + * Handler for the "atLeastOneRequired" check type. + * Verifies that at least one parameter from the group is present and non-empty. + * If none are present, returns HTTP 400 listing acceptable param names. + * + * Validates: Requirements 3.4, 3.5 + */ +const atLeastOneRequiredHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as AtLeastOneRequiredRule; + + for (const param of typedRule.params) { + const value = resolveParam(context, param.name, param.source); + if (value !== undefined && value !== null && value !== "") { + return { pass: true, resolvedEntities: {} }; + } + } + + // None of the params are present and non-empty + const paramNames = typedRule.params.map((p) => p.name).join("', '"); + return buildFailResult(rule, `At least one of '${paramNames}' must be provided`); +}; + +registerRuleHandler("atLeastOneRequired", atLeastOneRequiredHandler); + +// --- mutualExclusivity Rule Handler --- + +/** + * Handles mutualExclusivity rules. + * Verifies that exactly one parameter from the group is present and non-empty. + * - If exactly one is present: pass + * - If zero are present: fail with message listing all param names as required options + * - If more than one are present: fail identifying the conflicting param names + */ +const mutualExclusivityHandler: RuleHandler = async (rule: ValidationRule, context: RequestContext, _resolvedEntities: Record>): Promise => { + const mutualRule = rule as MutualExclusivityRule; + + const presentParams: string[] = []; + + for (const param of mutualRule.params) { + const value = resolveParam(context, param.name, param.source); + if (value !== undefined && value !== null && value !== "") { + presentParams.push(param.name); + } + } + + if (presentParams.length === 1) { + return { pass: true, resolvedEntities: {} }; + } + + if (presentParams.length === 0) { + const paramNames = mutualRule.params.map((p) => p.name).join(", "); + return buildFailResult(rule, `Exactly one of '${paramNames}' must be provided`); + } + + // More than one present — conflicting + const conflicting = presentParams.join(", "); + return buildFailResult(rule, `Parameters '${conflicting}' are mutually exclusive`); +}; + +registerRuleHandler("mutualExclusivity", mutualExclusivityHandler); + +// --- atMostOneAllowed Rule Handler --- + +/** + * Handler for the "atMostOneAllowed" check type. + * Verifies that no more than one parameter from the group is present. Unlike + * mutualExclusivity, none being present is valid — for groups of optional + * filters that conflict with each other. + */ +const atMostOneAllowedHandler: RuleHandler = (rule: ValidationRule, context: RequestContext): Promise => { + const typedRule = rule as AtMostOneAllowedRule; + const present = typedRule.params.filter((param) => isParamPresent(resolveParam(context, param.name, param.source))).map((param) => param.name); + + if (present.length > 1) { + return Promise.resolve(buildFailResult(rule, `Parameters '${present.join(", ")}' cannot be used together`)); + } + return Promise.resolve({ pass: true, resolvedEntities: {} }); +}; + +registerRuleHandler("atMostOneAllowed", atMostOneAllowedHandler); + +// --- requiredTogether Rule Handler --- + +/** + * Handler for the "requiredTogether" check type. + * Verifies the parameters are supplied as a set: either all present or all + * absent. Reports the missing members when only some were provided. + */ +const requiredTogetherHandler: RuleHandler = (rule: ValidationRule, context: RequestContext): Promise => { + const typedRule = rule as RequiredTogetherRule; + const present: string[] = []; + const missing: string[] = []; + for (const param of typedRule.params) { + if (isParamPresent(resolveParam(context, param.name, param.source))) present.push(param.name); + else missing.push(param.name); + } + + if (present.length > 0 && missing.length > 0) { + return Promise.resolve(buildFailResult(rule, `Parameter(s) '${missing.join(", ")}' are required when '${present.join(", ")}' are provided`)); + } + return Promise.resolve({ pass: true, resolvedEntities: {} }); +}; + +registerRuleHandler("requiredTogether", requiredTogetherHandler); + +// --- conditionalExclusion Rule Handler --- + +/** + * Handler for the "conditionalExclusion" check type. + * Verifies that when a trigger parameter is present, none of the forbidden parameters are also present. + * - If trigger is absent/empty: pass immediately + * - If trigger is present and no forbidden params are present: pass + * - If trigger is present and any forbidden param is present and non-empty: fail with HTTP 400 + * + * Validates: Requirements 3.6, 3.7 + */ +const conditionalExclusionHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as ConditionalExclusionRule; + + const triggerValue = resolveParam(context, typedRule.trigger.name, typedRule.trigger.source); + + // If trigger is absent or empty, pass immediately + if (triggerValue === undefined || triggerValue === null || triggerValue === "") { + return { pass: true, resolvedEntities: {} }; + } + + // Trigger is present — check all forbidden params + const presentForbidden: string[] = []; + + for (const forbidden of typedRule.forbidden) { + const value = resolveParam(context, forbidden.name, forbidden.source); + if (value !== undefined && value !== null && value !== "") { + presentForbidden.push(forbidden.name); + } + } + + if (presentForbidden.length === 0) { + return { pass: true, resolvedEntities: {} }; + } + + const forbiddenNames = presentForbidden.join("', '"); + return buildFailResult(rule, `Parameter '${forbiddenNames}' cannot be provided when '${typedRule.trigger.name}' is present`); +}; + +registerRuleHandler("conditionalExclusion", conditionalExclusionHandler); + +// --- conditionalRequirement Rule Handler --- + +/** + * Handler for the "conditionalRequirement" check type. + * When a trigger parameter is present (optionally matching a specific value), + * a dependent parameter becomes required. + * + * - If trigger has a `value` field: only fires when trigger param equals that value + * - If trigger has no `value` field: fires when trigger param is present (non-empty/non-null) + * - When fired: checks if the required parameter is present; if absent/empty/null, returns fail + * - When not fired: pass + * + * Validates: Requirements 2.6, 2.7 + */ +const conditionalRequirementHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as ConditionalRequirementRule; + + const triggerValue = resolveParam(context, typedRule.trigger.name, typedRule.trigger.source); + + // Determine if the trigger fires + let triggerFired = false; + + if (typedRule.trigger.value !== undefined) { + // Trigger has a specific value — only fire when trigger param equals that value + triggerFired = triggerValue === typedRule.trigger.value; + } else { + // Trigger has no value — fire when trigger param is present (non-empty/non-null) + triggerFired = triggerValue !== undefined && triggerValue !== null && triggerValue !== ""; + } + + if (!triggerFired) { + return { pass: true, resolvedEntities: {} }; + } + + // Trigger fired — check if the required parameter is present + const requiredValue = resolveParam(context, typedRule.required.name, typedRule.required.source); + + if (requiredValue === undefined || requiredValue === null || requiredValue === "") { + return buildFailResult(rule); + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("conditionalRequirement", conditionalRequirementHandler); + +// --- businessRule Rule Handler --- + +/** + * Handler for the "businessRule" check type. + * Retrieves an entity from the database and evaluates a condition against its field value. + * If the condition is satisfied, the business constraint is violated and the rule fails. + * If the entity is not found, the rule is skipped (passes). + * + * Before querying the database, checks if the entity is already available in resolvedEntities + * from a prior entityExistence rule, avoiding redundant database queries. + * + * Validates: Requirements 4.1, 4.2, 4.3, 4.4, 10.6 + */ +const businessRuleHandler: RuleHandler = async (rule, context, resolvedEntities) => { + const typedRule = rule as BusinessRuleCheck; + + // Resolve entity identifier from request context + const identifier = resolveParam(context, typedRule.entity.paramName, typedRule.entity.paramSource); + + // If identifier is undefined/null/empty, skip evaluation + if (identifier === undefined || identifier === null || identifier === "") { + return { pass: true, resolvedEntities: {} }; + } + + // Try to find entity in resolvedEntities first (from prior entityExistence rules) + let entity: unknown = undefined; + for (const resolved of Object.values(resolvedEntities)) { + if (resolved[typedRule.entity.paramName] === identifier) { + entity = resolved; + break; + } + } + + // Fall back to database query if not found in resolvedEntities + if (!entity) { + const key = identifier as string; + const found = Context.instance.engine.get(typedRule.entity.api, key); + + // If entity not found, skip evaluation (Requirement 4.4) + if (!found) { + return { pass: true, resolvedEntities: {} }; + } + + entity = found; + } + + // Extract field value using dot-notation path + const fieldParts = typedRule.condition.field.split("."); + let fieldValue: unknown = entity; + for (const part of fieldParts) { + if (fieldValue === undefined || fieldValue === null || typeof fieldValue !== "object") { + fieldValue = undefined; + break; + } + fieldValue = (fieldValue as Record)[part]; + } + + // Evaluate condition based on operator + const { operator, value } = typedRule.condition; + let conditionSatisfied = false; + + switch (operator) { + case "eq": + conditionSatisfied = fieldValue === value; + break; + case "neq": + conditionSatisfied = fieldValue !== value; + break; + case "in": + conditionSatisfied = Array.isArray(value) && value.includes(fieldValue); + break; + case "notIn": + conditionSatisfied = Array.isArray(value) && !value.includes(fieldValue); + break; + } + + // If condition is satisfied, the business constraint is violated + if (conditionSatisfied) { + return buildFailResult(rule); + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("businessRule", businessRuleHandler); + +// --- dateComparison Rule Handler --- + +/** + * Handler for the "dateComparison" check type. + * Compares two date values extracted from the request context (or one date against "now") + * using a relational operator (before, after, beforeOrEqual, afterOrEqual). + * + * - If the first operand is absent/empty: skip (pass) + * - If the second operand is a param reference that is absent/empty: skip (pass) + * - If either date string cannot be parsed as valid ISO 8601: return HTTP 400 + * - Otherwise: evaluate the operator and return pass/fail + * + * Validates: Requirements 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7 + */ +const dateComparisonHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as DateComparisonRule; + + // Extract first operand value + const firstValue = resolveParam(context, typedRule.firstOperand.name, typedRule.firstOperand.source); + + // If first operand is absent, skip the rule + if (firstValue === undefined || firstValue === null || firstValue === "") { + return { pass: true, resolvedEntities: {} }; + } + + // Determine the second date value + let secondDateStr: string | undefined; + let secondDate: Date; + + if (typedRule.secondOperand.kind === "now") { + const offsetMs = (typedRule.secondOperand).offsetMs ?? 0; + secondDate = new Date(Date.now() + offsetMs); + } else { + // kind === "param" + const secondValue = resolveParam(context, typedRule.secondOperand.name, typedRule.secondOperand.source); + + // If second operand param is absent, skip the rule + if (secondValue === undefined || secondValue === null || secondValue === "") { + return { pass: true, resolvedEntities: {} }; + } + + secondDateStr = String(secondValue); + secondDate = new Date(secondDateStr); + + // Validate second date is parseable + if (isNaN(secondDate.getTime())) { + return { + pass: false, + statusCode: 400, + body: { + errors: [ + { + code: typedRule.failAction.code ?? typedRule.checkType, + message: `Parameter '${typedRule.secondOperand.name}' contains an unparseable date value; expected ISO 8601 format`, + }, + ], + }, + }; + } + } + + // Parse first date + const firstDateStr = String(firstValue); + const firstDate = new Date(firstDateStr); + + // Validate first date is parseable + if (isNaN(firstDate.getTime())) { + return { + pass: false, + statusCode: 400, + body: { + errors: [ + { + code: typedRule.failAction.code ?? typedRule.checkType, + message: `Parameter '${typedRule.firstOperand.name}' contains an unparseable date value; expected ISO 8601 format`, + }, + ], + }, + }; + } + + // Evaluate the comparison operator + let comparisonPasses = false; + + switch (typedRule.operator) { + case "before": + comparisonPasses = firstDate < secondDate; + break; + case "after": + comparisonPasses = firstDate > secondDate; + break; + case "beforeOrEqual": + comparisonPasses = firstDate <= secondDate; + break; + case "afterOrEqual": + comparisonPasses = firstDate >= secondDate; + break; + } + + if (comparisonPasses) { + return { pass: true, resolvedEntities: {} }; + } + + // Comparison failed — return the failAction + return buildFailResult(rule); +}; + +registerRuleHandler("dateComparison", dateComparisonHandler); + +// --- orderItemExistence Rule Handler --- + +/** + * Handler for the "orderItemExistence" check type. + * Iterates over `packageDetail.orderItems[]` in the request body and verifies each + * `orderItemId` exists in the resolved order entity's `orderItems[]` array. + * + * Uses the resolved order entity from `resolvedEntities[entityLabel]` (resolved by + * a prior entityExistence check) — no redundant database queries. + * + * Validates: Requirements 15.6 + */ +const orderItemExistenceHandler: RuleHandler = async (rule, context, resolvedEntities) => { + const typedRule = rule as OrderItemExistenceRule; + + // Get the resolved order entity + const resolvedOrder = resolvedEntities[typedRule.entityLabel]; + if (!resolvedOrder) { + return { pass: true, resolvedEntities: {} }; + } + + // Get orderItems from the request body's packageDetail + const packageDetail = context.body?.packageDetail as Record | undefined; + if (!packageDetail) { + return { pass: true, resolvedEntities: {} }; + } + + const requestOrderItems = packageDetail.orderItems as Record[] | undefined; + if (!Array.isArray(requestOrderItems) || requestOrderItems.length === 0) { + return { pass: true, resolvedEntities: {} }; + } + + // Get the order's orderItems array + const orderOrderItems = resolvedOrder.orderItems as Record[] | undefined; + if (!Array.isArray(orderOrderItems)) { + // If the order has no orderItems, any request orderItemId will fail + const firstItemId = requestOrderItems[0]?.orderItemId; + return buildFailResult(rule, `Order item '${String(firstItemId)}' not found in the order`); + } + + // Build a set of valid order item IDs for O(1) lookup + const validOrderItemIds = new Set(orderOrderItems.map((item) => String(item.orderItemId))); + + // Check each request orderItemId + for (const requestItem of requestOrderItems) { + const orderItemId = requestItem.orderItemId; + if (orderItemId === undefined || orderItemId === null) { + continue; + } + if (!validOrderItemIds.has(String(orderItemId))) { + return buildFailResult(rule, `Order item '${String(orderItemId)}' not found in the order`); + } + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("orderItemExistence", orderItemExistenceHandler); + +// --- quantityLimit Rule Handler --- + +/** + * Handler for the "quantityLimit" check type. + * Iterates over `packageDetail.orderItems[]` in the request body and verifies each + * `quantity` is ≤ the corresponding order item's `quantityOrdered` in the resolved order entity. + * + * Uses the resolved order entity from `resolvedEntities[entityLabel]` (resolved by + * a prior entityExistence check) — no redundant database queries. + * + * Validates: Requirements 15.7 + */ +const quantityLimitHandler: RuleHandler = async (rule, context, resolvedEntities) => { + const typedRule = rule as QuantityLimitRule; + + // Get the resolved order entity + const resolvedOrder = resolvedEntities[typedRule.entityLabel]; + if (!resolvedOrder) { + return { pass: true, resolvedEntities: {} }; + } + + // Get orderItems from the request body's packageDetail + const packageDetail = context.body?.packageDetail as Record | undefined; + if (!packageDetail) { + return { pass: true, resolvedEntities: {} }; + } + + const requestOrderItems = packageDetail.orderItems as Record[] | undefined; + if (!Array.isArray(requestOrderItems) || requestOrderItems.length === 0) { + return { pass: true, resolvedEntities: {} }; + } + + // Get the order's orderItems array + const orderOrderItems = resolvedOrder.orderItems as Record[] | undefined; + if (!Array.isArray(orderOrderItems)) { + return { pass: true, resolvedEntities: {} }; + } + + // Build a map of orderItemId → quantityOrdered for O(1) lookup + const quantityByItemId = new Map(); + for (const orderItem of orderOrderItems) { + const itemId = String(orderItem.orderItemId); + const quantityOrdered = orderItem.quantityOrdered as number; + quantityByItemId.set(itemId, quantityOrdered); + } + + // Check each request item's quantity against quantityOrdered + for (const requestItem of requestOrderItems) { + const orderItemId = requestItem.orderItemId; + const quantity = requestItem.quantity as number; + + if (orderItemId === undefined || orderItemId === null || quantity === undefined || quantity === null) { + continue; + } + + const quantityOrdered = quantityByItemId.get(String(orderItemId)); + if (quantityOrdered === undefined) { + // If the order item doesn't exist, skip (orderItemExistence handler handles this) + continue; + } + + if (quantity > quantityOrdered) { + return buildFailResult(rule, `Quantity ${quantity} for order item '${String(orderItemId)}' exceeds the ordered quantity of ${quantityOrdered}`); + } + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("quantityLimit", quantityLimitHandler); + +// --- batchSizeLimit Rule Handler --- + +/** + * Handler for the "batchSizeLimit" check type. + * Validates that an array in the request body does not exceed the configured maximum number of items. + * + * - If the value at the specified path is not an array: pass (schema validation handles type issues) + * - If the array length exceeds maxItems: fail with the configured failAction + * - Otherwise: pass + * + * Validates: Requirements 2.2 + */ +const batchSizeLimitHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as BatchSizeLimitRule; + + // Resolve the array from the body using path (dot-notation) or top-level name + let value: unknown; + + if (typedRule.arrayParam.path) { + // Use dot-path to navigate into the body + const parts = typedRule.arrayParam.path.split("."); + value = context.body; + for (const part of parts) { + if (value === undefined || value === null || typeof value !== "object") { + value = undefined; + break; + } + value = (value as Record)[part]; + } + } else { + // Use name as a top-level key + value = context.body?.[typedRule.arrayParam.name]; + } + + // If it's not an array, pass (schema validation handles missing/wrong types) + if (!Array.isArray(value)) { + return { pass: true, resolvedEntities: {} }; + } + + // Check if the array length exceeds maxItems + if (value.length > typedRule.maxItems) { + return buildFailResult(rule); + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("batchSizeLimit", batchSizeLimitHandler); + +// --- reportTypeSupported Rule Handler --- + +/** + * Handler for the "reportTypeSupported" check type. + * Checks that the reportType specified in the request body is a supported report type + * (i.e., has a registered generator in REPORT_GENERATORS). + * + * - If reportType is absent/empty: skip (pass) + * - If reportType has a registered generator: pass + * - If reportType has no registered generator: fail with HTTP 400 + */ +const reportTypeSupportedHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as ReportTypeSupportedRule; + + const reportType = resolveParam(context, typedRule.reportTypeParam.name, typedRule.reportTypeParam.source); + + if (reportType === undefined || reportType === null || reportType === "") { + return { pass: true, resolvedEntities: {} }; + } + + const generator = REPORT_GENERATORS[reportType as string]; + if (!generator) { + return buildFailResult(rule, `Unsupported reportType: ${reportType as string}. Supported: ${Object.keys(REPORT_GENERATORS).join(", ")}`); + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("reportTypeSupported", reportTypeSupportedHandler); + +// --- reportMetaValidation Rule Handler --- + +/** + * Handler for the "reportMetaValidation" check type. + * Validates the report request against report type metadata: + * - Marketplace availability: checks marketplaceIds are valid for the report type + * - Date range required: checks dataStartTime and dataEndTime are provided when required + * - Report options: validates option keys and allowed values + * + * - If reportType is absent/empty: skip (pass) + * - If reportType has no metadata: skip (pass) + * - If validation fails: fail with HTTP 400 and descriptive message + */ +const reportMetaValidationHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as ReportMetaValidationRule; + + const reportType = resolveParam(context, typedRule.reportTypeParam.name, typedRule.reportTypeParam.source); + + if (reportType === undefined || reportType === null || reportType === "") { + return { pass: true, resolvedEntities: {} }; + } + + const marketplaceIds = resolveParam(context, typedRule.marketplaceIdsParam.name, typedRule.marketplaceIdsParam.source) as string[] | undefined; + const reportOptions = resolveParam(context, typedRule.reportOptionsParam.name, typedRule.reportOptionsParam.source) as Record | undefined; + const dataStartTime = resolveParam(context, typedRule.dataStartTimeParam.name, typedRule.dataStartTimeParam.source) as string | undefined; + const dataEndTime = resolveParam(context, typedRule.dataEndTimeParam.name, typedRule.dataEndTimeParam.source) as string | undefined; + + const validationError = validateReport(reportType as string, marketplaceIds ?? [], reportOptions, dataStartTime, dataEndTime); + + if (validationError) { + return buildFailResult(rule, validationError); + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("reportMetaValidation", reportMetaValidationHandler); + + +// --- entityFieldCheck Rule Handler --- + +/** + * Handler for the "entityFieldCheck" check type. + * Checks whether a field on a previously resolved entity exists or does not exist. + * + * - If the entity is not found in resolvedEntities: skip (pass) + * - operator "exists": fails if the field is undefined + * - operator "notExists": fails if the field is NOT undefined + * + * Used to distinguish between different record types stored in the same collection + * (e.g., reports vs report documents in the REPORTS partition). + */ +const entityFieldCheckHandler: RuleHandler = async (rule, _context, resolvedEntities) => { + const typedRule = rule as EntityFieldCheckRule; + + const entity = resolvedEntities[typedRule.entityLabel]; + if (!entity) { + return { pass: true, resolvedEntities: {} }; + } + + const fieldValue = entity[typedRule.field]; + + if (typedRule.operator === "exists") { + if (fieldValue === undefined) { + return buildFailResult(rule); + } + } else { + // "notExists" + if (fieldValue !== undefined) { + return buildFailResult(rule); + } + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("entityFieldCheck", entityFieldCheckHandler); + +// --- reportSchedulable Rule Handler --- + +/** + * Handler for the "reportSchedulable" check type. + * Checks that the specified report type is schedulable according to REPORT_META. + * + * - If reportType is absent/empty: skip (pass) + * - If reportType has no metadata: skip (pass) — unknown types pass through + * - If meta.schedulable is false: fail with HTTP 400 + * - Otherwise: pass + */ +const reportSchedulableHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as ReportSchedulableRule; + + const reportType = resolveParam(context, typedRule.reportTypeParam.name, typedRule.reportTypeParam.source); + + if (reportType === undefined || reportType === null || reportType === "") { + return { pass: true, resolvedEntities: {} }; + } + + const meta = REPORT_META[reportType as string]; + if (meta && !meta.schedulable) { + return buildFailResult(rule, `reportType ${reportType as string} can only be requested, not scheduled`); + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("reportSchedulable", reportSchedulableHandler); + +// --- marketplaceIdValidation Rule Handler --- + +// Re-export for backward compatibility (consumers that import from this module) +export { MARKETPLACE_IDS_BY_REGION, getAllowedMarketplaceIds } from "../marketplaceIds.js"; + +/** + * Handler for the "marketplaceIdValidation" check type. + * Validates that the marketplaceId(s) provided in the request (query or body) are + * within the allowed set for the configured REGION. + * + * Supports both single string and array-of-strings values. + * + * - If the param is absent/empty: skip (pass) + * - If all marketplace IDs are in the allowed set: pass + * - If any marketplace ID is not in the allowed set: fail with HTTP 400 + */ +const marketplaceIdValidationHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as MarketplaceIdValidationRule; + + const rawValue = resolveParam(context, typedRule.marketplaceIdsParam.name, typedRule.marketplaceIdsParam.source); + + // If param is absent, skip validation + if (rawValue === undefined || rawValue === null || rawValue === "") { + return { pass: true, resolvedEntities: {} }; + } + + // Normalize to an array of strings + const marketplaceIds: string[] = Array.isArray(rawValue) ? rawValue.map(String) : [String(rawValue)]; + + if (marketplaceIds.length === 0) { + return { pass: true, resolvedEntities: {} }; + } + + const allowed = getAllowedMarketplaceIds(); + const allowedSet = new Set(allowed); + + const invalid = marketplaceIds.filter((id) => !allowedSet.has(id)); + + if (invalid.length > 0) { + const region = process.env.REGION && ["NA", "EU", "FE"].includes(process.env.REGION) ? process.env.REGION : "NA"; + return buildFailResult(rule, `Invalid marketplace ID(s): ${invalid.join(", ")}. Allowed for region ${region}: ${allowed.join(", ")}`); + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("marketplaceIdValidation", marketplaceIdValidationHandler); + + +// --- modeRestriction Rule Handler --- + +/** + * Handler for the "modeRestriction" check type. + * When a parameter contains a restricted value, validates that the sandbox is running + * in the required mode (checked via process.env.MODE). + * + * - If param is absent/empty: pass + * - If param is an array: check if any element equals restrictedValue + * - If param is a string: check if it equals restrictedValue + * - If restricted value found and process.env.MODE does not match requiredMode: fail with HTTP 400 + * - Otherwise: pass + * + * Validates: Requirements 1.5, 2.8 + */ +const modeRestrictionHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as ModeRestrictionRule; + + const paramValue = resolveParam(context, typedRule.param.name, typedRule.param.source); + + // If param is absent/empty, skip validation + if (paramValue === undefined || paramValue === null || paramValue === "") { + return { pass: true, resolvedEntities: {} }; + } + + // Check if the restricted value is present + let hasRestrictedValue = false; + + if (Array.isArray(paramValue)) { + hasRestrictedValue = paramValue.some((element) => element === typedRule.restrictedValue); + } else if (typeof paramValue === "string") { + hasRestrictedValue = paramValue === typedRule.restrictedValue; + } + + if (!hasRestrictedValue) { + return { pass: true, resolvedEntities: {} }; + } + + // Restricted value found — check if current mode matches the required mode + if (CURRENT_MODE !== typedRule.requiredMode) { + return buildFailResult(rule); + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("modeRestriction", modeRestrictionHandler); + +// --- arrayItemFieldValue Rule Handler --- + +/** + * Handler for the "arrayItemFieldValue" check type. + * Validates that every item in a body array has a specific field matching the expected value. + * + * - If the value at the specified path is not an array: pass (schema validation handles type issues) + * - If any item's field does not match expectedValue: fail with the configured failAction + * - Otherwise: pass + */ +const arrayItemFieldValueHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as ArrayItemFieldValueRule; + + // Resolve the array from the body using path (dot-notation) or top-level name + let value: unknown; + + if (typedRule.arrayParam.path) { + const parts = typedRule.arrayParam.path.split("."); + value = context.body; + for (const part of parts) { + if (value === undefined || value === null || typeof value !== "object") { + value = undefined; + break; + } + value = (value as Record)[part]; + } + } else { + value = context.body?.[typedRule.arrayParam.name]; + } + + // If it's not an array, pass (schema validation handles missing/wrong types) + if (!Array.isArray(value)) { + return { pass: true, resolvedEntities: {} }; + } + + // Check each item's field against the expected value + for (const item of value) { + if (typeof item !== "object" || item === null) { + continue; + } + const fieldValue = (item as Record)[typedRule.itemField]; + if (fieldValue !== typedRule.expectedValue) { + return buildFailResult(rule); + } + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("arrayItemFieldValue", arrayItemFieldValueHandler); + +// --- stringLengthLimit Rule Handler --- + +/** + * Handler for the "stringLengthLimit" check type. + * Fails when a string parameter's length exceeds `max`. When `normalizeWhitespace` + * is true, runs of whitespace are collapsed to a single space and the value is + * trimmed before measuring (SP-API "after unnecessary whitespace is removed"). + * + * - If the value is absent/empty or not a string: skip (pass) — this is a length + * check, not a presence/type check (schema validation covers those). + */ +const stringLengthLimitHandler: RuleHandler = async (rule, context, _resolvedEntities) => { + const typedRule = rule as StringLengthLimitRule; + + const value = resolveParam(context, typedRule.param.name, typedRule.param.source); + if (typeof value !== "string" || value === "") { + return { pass: true, resolvedEntities: {} }; + } + + const measured = typedRule.normalizeWhitespace ? value.replace(/\s+/g, " ").trim() : value; + if (measured.length > typedRule.max) { + return buildFailResult(rule); + } + + return { pass: true, resolvedEntities: {} }; +}; + +registerRuleHandler("stringLengthLimit", stringLengthLimitHandler); diff --git a/local-ai-sandbox/src/tool/callSellingPartnerApiTool.ts b/local-ai-sandbox/src/tool/callSellingPartnerApiTool.ts deleted file mode 100644 index 6a0d25301..000000000 --- a/local-ai-sandbox/src/tool/callSellingPartnerApiTool.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { z } from "zod"; -import { tool } from "@strands-agents/sdk"; -import { asyncLocalStorage } from "../index.js"; - -export const callSellingPartnerApiTool = tool({ - name: "http_request", - description: - "Makes HTTP requests to Amazons Selling Partner API. Supports GET, POST, PUT, DELETE, PATCH, HEAD, and OPTIONS methods. Returns response with status, headers, and body.", - inputSchema: z.object({ - method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]), - url: z.string().url(), - headers: z.record(z.string(), z.string()).optional(), - body: z.string().optional(), - timeout: z.number().positive().optional(), - }), - callback: async (input) => { - const { method, url, headers, body, timeout = 30 } = input; - console.warn(`Call Selling Partner API with url ${url} and method ${method}`); - - // Create AbortController for timeout - const controller = new AbortController(); - const timeoutId = globalThis.setTimeout(() => { - controller.abort(); - }, timeout * 1000); - - try { - // Build fetch options - const fetchOptions: RequestInit = { - method, - signal: controller.signal, - }; - - // Only add headers and body if they are defined - if (headers !== undefined) { - const store: any = asyncLocalStorage.getStore(); - headers["x-amz-access-token"] = store.accessToken; - fetchOptions.headers = headers; - } - if (body !== undefined) { - fetchOptions.body = body; - } - - // Make the fetch request - const response = await globalThis.fetch(url, fetchOptions); - - // Clear the timeout - globalThis.clearTimeout(timeoutId); - - // Get response body as text - const responseBody = await response.text(); - - // Convert headers to plain object - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - - // Check if response was successful - if (!response.ok) { - throw new Error(`HTTP ${response.status} ${response.statusText}: ${method} ${url}`); - } - - // Return successful response as JSON-serializable object - return { - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - body: responseBody, - }; - } catch (error) { - // Clear timeout on error - globalThis.clearTimeout(timeoutId); - - // Handle abort/timeout error - if (error instanceof Error && error.name === "AbortError") { - throw new Error(`Request timed out after ${timeout} seconds: ${method} ${url}`); - } - - // Re-throw other errors (network errors, HTTP errors, etc.) - throw error; - } - }, -}); diff --git a/local-ai-sandbox/src/tool/databaseInsertionTool.ts b/local-ai-sandbox/src/tool/databaseInsertionTool.ts index 81f714e61..098221fec 100644 --- a/local-ai-sandbox/src/tool/databaseInsertionTool.ts +++ b/local-ai-sandbox/src/tool/databaseInsertionTool.ts @@ -1,26 +1,53 @@ import { tool } from "@strands-agents/sdk"; import { Api, Context } from "../database/Context.js"; +import { listingKey } from "../operation/listingsItemModel.js"; import z from "zod"; +/** + * Resolves the storage key for generated data. Listings are keyed by seller + * and SKU, so a generated listing must carry both or it would be written + * somewhere getListingsItem cannot find it. + */ +function resolveKey(api: Api, id: string, entity: Record | undefined): { key: string; sku?: string } | { error: string } { + if (api !== Api.LISTINGS) return { key: id }; + + // Returned alongside the key so the stored document cannot disagree with the + // key it is stored under. + const sku = typeof entity?.sku === "string" && entity.sku !== "" ? entity.sku : id; + const sellerId = entity?.sellerId; + if (typeof sellerId !== "string" || sellerId === "") { + return { error: "A listing needs a non-empty 'sellerId' in the entity (a SKU is only unique per seller). Add sellerId and retry." }; + } + + return { key: listingKey(sellerId, sku), sku }; +} + export const databaseInsertionTool = tool({ name: "database_insertion", - description: "Inserts data into the database", + description: + "Inserts data into the database. For listings, the entity must include 'sellerId' and 'sku'; the storage key is derived from both, so 'id' is ignored there.", inputSchema: z.object({ api: z.enum(Api), id: z.string(), entity: z.any(), }), - callback: async (input) => { - console.warn(`Database insertion for api ${input.api} id ${input.id}`); - await Context.instance.db.read(); - const apiData = Context.instance.db.data[input.api]; - if (apiData) { - apiData[input.id] = input.entity; - await Context.instance.db.write(); - - return "Success"; + callback: (input) => { + const entity = input.entity as Record | undefined; + const resolved = resolveKey(input.api, input.id, entity); + + if ("error" in resolved) { + console.warn(`Database insertion rejected for api ${input.api} id ${input.id}: ${resolved.error}`); + return Promise.resolve(resolved.error); } - return `Invalid api specified`; + console.warn(`Database insertion for api ${input.api} key ${resolved.key}`); + + // Listings carry their SKU explicitly, since the key is no longer the SKU. + // Assigned after the spread so a malformed `entity.sku` cannot override the + // value the key was built from. + const document = resolved.sku === undefined ? (entity ?? {}) : { ...entity, sku: resolved.sku }; + Context.instance.engine.put(input.api, resolved.key, document); + + return Promise.resolve("Success"); }, }); diff --git a/local-ai-sandbox/src/tool/databaseLookupTool.ts b/local-ai-sandbox/src/tool/databaseLookupTool.ts deleted file mode 100644 index 48ae8ca18..000000000 --- a/local-ai-sandbox/src/tool/databaseLookupTool.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { tool } from "@strands-agents/sdk"; -import z from "zod"; -import { Api, Context } from "../database/Context.js"; - -export async function databaseLookupCallback(input: { api: Api; id?: string; ids?: string[]; asin?: string; asins?: string[]; fields?: string[] }) { - const isCatalog = input.api === Api.CATALOG; - const identifier = isCatalog ? input.asin : input.id; - const identifiers = isCatalog ? input.asins : input.ids; - console.warn(`Database lookup for ${input.api} and ${isCatalog ? "asin" : "id"} ${identifier} ${isCatalog ? "asins" : "ids"} ${identifiers}`); - await Context.instance.db.read(); - const apiData = Context.instance.db.data[input.api]; - if (apiData) { - const pick = (obj: any) => { - if (!obj || !input.fields) return obj; - const picked: Record = {}; - for (const f of input.fields) picked[f] = obj[f] ?? null; - return picked; - }; - if (identifiers) { - const results: Record = {}; - for (const id of identifiers) { - results[id] = apiData[id] ? pick(apiData[id]) : null; - } - return JSON.stringify(results); - } - const result = identifier - ? pick(apiData[identifier]) - : input.fields - ? Object.fromEntries(Object.entries(apiData).map(([k, v]) => [k, pick(v)])) - : apiData; - return result !== undefined ? JSON.stringify(result) : `No data found`; - } - - return `No data found`; -} - -export const databaseLookupTool = tool({ - name: "database_lookup", - description: - "Performs a lookup in the database to retrieve data. Supports single id, multiple ids (batch), or no id (returns all). Use fields parameter to return only specific fields per item. For catalog API, use asin/asins instead of id/ids.", - inputSchema: z.object({ - api: z.enum(Api), - id: z.string().optional(), - ids: z.array(z.string()).optional(), - asin: z.string().optional(), - asins: z.array(z.string()).optional(), - fields: z.array(z.string()).optional(), - }), - callback: databaseLookupCallback, -}); diff --git a/local-ai-sandbox/src/tool/databaseRemovalTool.ts b/local-ai-sandbox/src/tool/databaseRemovalTool.ts deleted file mode 100644 index 6024e1390..000000000 --- a/local-ai-sandbox/src/tool/databaseRemovalTool.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { tool } from "@strands-agents/sdk"; -import { Api, Context } from "../database/Context.js"; -import z from "zod"; - -export const databaseRemovalTool = tool({ - name: "database_removal", - description: "Removes data from the database", - inputSchema: z.object({ - api: z.enum(Api), - id: z.string(), - }), - callback: async (input) => { - console.warn(`Database removal for api ${input.api} id ${input.id}`); - await Context.instance.db.read(); - const apiData = Context.instance.db.data[input.api]; - if (apiData?.[input.id]) { - delete apiData[input.id]; - await Context.instance.db.write(); - - return "Success"; - } - - return `No data to remove`; - }, -}); diff --git a/local-ai-sandbox/src/tool/resourceRetrievalTool.ts b/local-ai-sandbox/src/tool/resourceRetrievalTool.ts index 6a4012ee8..5a8de392b 100644 --- a/local-ai-sandbox/src/tool/resourceRetrievalTool.ts +++ b/local-ai-sandbox/src/tool/resourceRetrievalTool.ts @@ -2,6 +2,7 @@ import { tool } from "@strands-agents/sdk"; import { Api } from "../database/Context.js"; import z from "zod"; import * as fs from "node:fs"; +import { getModelPath } from "../registry/operationRegistry.js"; export const resourceRetrievalTool = tool({ name: "resource_retrieval", @@ -11,18 +12,7 @@ export const resourceRetrievalTool = tool({ }), callback: (input) => { console.warn(`Model retrieval for api ${input.api}`); - const modelMap: Record = { - [Api.ORDERS]: "./res/models/orders_2026-01-01.json", - [Api.INVENTORY]: "./res/models/fbaInventory_v1.json", - [Api.EXT_FULFILLMENT_INVENTORY]: "./res/models/externalFulfillmentInventory_2024-09-11.json", - [Api.EXT_FULFILLMENT_RETURNS]: "./res/models/externalFulfillmentReturns_2024-09-11.json", - [Api.EXT_FULFILLMENT_SHIPMENTS]: "./res/models/externalFulfillmentShipments_2024-09-11.json", - [Api.CATALOG]: "./res/models/catalogItems_2022-04-01.json", - [Api.PRICING]: "./res/models/productPricing_2022-05-01.json", - // Add additional resource mappings here - // [Api.LISTINGS]: "./res/pt-definitions/PRODUCT.json", - }; - const path = modelMap[input.api]; + const path = getModelPath(input.api); return path ? fs.readFileSync(path, "utf8") : "No model found"; }, }); diff --git a/local-ai-sandbox/src/trigger/DataEvent.ts b/local-ai-sandbox/src/trigger/DataEvent.ts new file mode 100644 index 000000000..946701fc2 --- /dev/null +++ b/local-ai-sandbox/src/trigger/DataEvent.ts @@ -0,0 +1,11 @@ +import { Api } from "../database/Context.js"; + +export type DataEventType = "INSERT" | "UPDATE" | "DELETE"; + +export interface DataEvent { + type: DataEventType; + api: Api; + id: string; + entity?: any; + previousEntity?: any; +} diff --git a/local-ai-sandbox/src/trigger/TriggerProcessor.ts b/local-ai-sandbox/src/trigger/TriggerProcessor.ts new file mode 100644 index 000000000..072064a23 --- /dev/null +++ b/local-ai-sandbox/src/trigger/TriggerProcessor.ts @@ -0,0 +1,32 @@ +import { Api } from "../database/Context.js"; +import { DataEvent, DataEventType } from "./DataEvent.js"; +import { triggerRegistry } from "./triggerRegistry.js"; + +export class TriggerProcessor { + /** + * Emit a data event and fire any matching triggers. + * Call this after any successful database write operation. + */ + static async emit(type: DataEventType, api: Api, id: string, entity?: any, previousEntity?: any): Promise { + const event: DataEvent = { type, api, id, entity, previousEntity }; + await this.process(event); + } + + private static async process(event: DataEvent): Promise { + const triggers = triggerRegistry.filter( + (trigger) => trigger.on.api === event.api && trigger.on.event.includes(event.type), + ); + + for (const trigger of triggers) { + try { + if (trigger.on.condition && !trigger.on.condition(event)) { + continue; + } + console.info(`[Trigger] Executing: ${trigger.name}`); + await trigger.handler(event); + } catch (error) { + console.error(`[Trigger] Failed: ${trigger.name}`, error); + } + } + } +} diff --git a/local-ai-sandbox/src/trigger/handlers/processListingSubmission.ts b/local-ai-sandbox/src/trigger/handlers/processListingSubmission.ts new file mode 100644 index 000000000..40a2fa5d1 --- /dev/null +++ b/local-ai-sandbox/src/trigger/handlers/processListingSubmission.ts @@ -0,0 +1,158 @@ +/** + * Catalog matching — the sandbox analogue of the downstream processing + * production runs asynchronously after a submission passes validation: + * + * - Full submission not yet in the catalog: create the catalog item. + * - Offer-only matching a catalog item: nothing to do. + * - Offer-only matching nothing: report a matching issue. The sandbox catalog + * is the only authority, so an offer against a real production ASIN the + * sandbox was never told about is still unmatched; seed it via /chat. + * + * Listing attributes are never modified, so a read returns only what the + * seller submitted. Issues are reconciled against OWNED_CODES rather than + * appended, which keeps this idempotent and unable to disturb validation + * issues. Writes are silent, so they cannot re-enter the trigger. + * + * Re-runs on listing writes only: a later catalog change does not re-evaluate + * matching (see the spec's scheduled-reconciliation follow-up). + */ +import { DataEvent } from "../DataEvent.js"; +import { Api, Context } from "../../database/Context.js"; +import { + asListingDoc, + ISSUE_CODE_ASIN_MISMATCH, + ISSUE_CODE_UNMATCHABLE, + SALES_TERM_ATTRIBUTES, + type ListingDoc, + type ListingIssue, +} from "../../operation/listingsItemModel.js"; + +/** Every issue code this handler owns. Nothing else may be emitted here. */ +const OWNED_CODES = new Set([ISSUE_CODE_ASIN_MISMATCH, ISSUE_CODE_UNMATCHABLE]); + +// --- Catalog matching --- + +/** External product identifier values (EAN/UPC/GTIN/...) on a submission. */ +function externalIdentifiers(attributes: Record): { type?: unknown; value?: unknown }[] { + const instances = attributes.externally_assigned_product_identifier; + return Array.isArray(instances) ? (instances as { type?: unknown; value?: unknown }[]) : []; +} + +/** Finds a catalog item whose identifiers include one of the given values. */ +function findByIdentifier(values: string[]): Record | undefined { + if (values.length === 0) return undefined; + + for (const item of Context.instance.engine.find(Api.CATALOG, {})) { + const groups = item.identifiers; + if (!Array.isArray(groups)) continue; + const matched = (groups as { identifiers?: { identifier?: unknown }[] }[]).some((group) => + (group.identifiers ?? []).some((id) => typeof id.identifier === "string" && values.includes(id.identifier)), + ); + if (matched) return item; + } + return undefined; +} + +/** The catalog item a submission resolves to, by ASIN or external identifier. */ +function matchCatalogItem(doc: ListingDoc): Record | undefined { + const byAsin = doc.asin ? Context.instance.engine.get(Api.CATALOG, doc.asin) : null; + if (byAsin) return byAsin; + + const values = externalIdentifiers(doc.attributes) + .map((inst) => inst.value) + .filter((value): value is string => typeof value === "string"); + return findByIdentifier(values); +} + +/** + * Creates a catalog item for a net-new ASIN from the submission's product + * facts. The keys match the Catalog Items `includedData` categories, so + * getCatalogItem and searchCatalogItems serve the item as-is. Sales terms + * belong to the seller's offer, not to the shared catalog item, so they are + * left out. + */ +function createCatalogItem(doc: ListingDoc, asin: string): void { + const attributes = Object.fromEntries(Object.entries(doc.attributes).filter(([name]) => !SALES_TERM_ATTRIBUTES.has(name))); + + const firstValue = (name: string): unknown => (doc.attributes[name] as { value?: unknown }[] | undefined)?.[0]?.value; + const itemName = firstValue("item_name"); + const brand = firstValue("brand"); + + const summary: Record = { marketplaceId: doc.marketplaceId }; + if (typeof itemName === "string") summary.itemName = itemName; + if (typeof brand === "string") summary.brand = brand; + + const identifiers = externalIdentifiers(doc.attributes).flatMap((inst) => + typeof inst.value === "string" + ? [{ identifierType: (typeof inst.type === "string" ? inst.type : "UPC").toUpperCase(), identifier: inst.value }] + : [], + ); + + Context.instance.engine.put(Api.CATALOG, asin, { + asin, + attributes, + productTypes: [{ marketplaceId: doc.marketplaceId, productType: doc.productType }], + summaries: [summary], + ...(identifiers.length > 0 ? { identifiers: [{ marketplaceId: doc.marketplaceId, identifiers }] } : {}), + }); + console.info(`[Trigger] Created catalog item ${asin} (${doc.productType}) for listing ${doc.sku} (${doc.sellerId})`); +} + +/** + * Issue for an offer-only submission that matched nothing: production + * distinguishes a suggested ASIN that does not match (4005015) from a + * submission it cannot match or create at all (8560). + */ +function matchingIssue(doc: ListingDoc): ListingIssue { + const suggestedAsin = (doc.attributes.merchant_suggested_asin as { value?: string }[] | undefined)?.[0]?.value; + + if (suggestedAsin) { + return { + code: ISSUE_CODE_ASIN_MISMATCH, + message: `The ASIN provided ('${suggestedAsin}') does not match the existing item in the Amazon catalog.`, + severity: "ERROR", + attributeNames: ["merchant_suggested_asin"], + categories: ["INVALID_ATTRIBUTE"], + }; + } + + return { + code: ISSUE_CODE_UNMATCHABLE, + message: + "Your product details are not complete enough to find a matching ASIN or create a new one. Check that your product identifiers are correct and that all required product information is included.", + severity: "ERROR", + attributeNames: ["product_type"], + categories: ["INVALID_ATTRIBUTE", "MISSING_ATTRIBUTE"], + }; +} + +/** Replaces this handler's owned issues with the ones that currently apply. */ +function reconcileIssues(doc: ListingDoc, computed: ListingIssue[]): void { + const preserved = doc.issues.filter((issue) => !OWNED_CODES.has(issue.code)); + const nextIssues = [...preserved, ...computed]; + if (JSON.stringify(nextIssues) === JSON.stringify(doc.issues)) return; + + const record = Context.instance.engine.get(Api.LISTINGS, doc._key); + if (!record) return; + Context.instance.engine.put(Api.LISTINGS, doc._key, { ...record, issues: nextIssues }, { silent: true }); + console.info(`[Trigger] Reconciled matching issues for listing ${doc.sku} (${doc.sellerId}): ${String(computed.length)} issue(s)`); +} + +/** Trigger handler: runs catalog matching for the written listing. */ +export function processListingSubmission(event: DataEvent): void { + const record = Context.instance.engine.get(Api.LISTINGS, event.id); + if (!record) return; // deleted between event and processing + + const doc = asListingDoc(record); + const matched = matchCatalogItem(doc); + + if (doc.requirements === "LISTING_OFFER_ONLY") { + reconcileIssues(doc, matched ? [] : [matchingIssue(doc)]); + return; + } + + // Full submission: the product is contributed to the catalog when the + // sandbox does not hold it yet. + reconcileIssues(doc, []); + if (!matched && doc.asin) createCatalogItem(doc, doc.asin); +} diff --git a/local-ai-sandbox/src/trigger/handlers/reduceInventoryOnOrderPlaced.ts b/local-ai-sandbox/src/trigger/handlers/reduceInventoryOnOrderPlaced.ts new file mode 100644 index 000000000..affaff963 --- /dev/null +++ b/local-ai-sandbox/src/trigger/handlers/reduceInventoryOnOrderPlaced.ts @@ -0,0 +1,84 @@ +import { DataEvent } from "../DataEvent.js"; +import { Api, Context } from "../../database/Context.js"; + +/** + * When a new order is placed with status PENDING, reduce inventory + * for each order item's SKU by the quantity ordered. + * + * Channel logic: + * - fulfilledBy "MERCHANT" → reduce listing's fulfillment_availability (channel DEFAULT) + * - fulfilledBy "AMAZON" → reduce FBA inventory in the inventory partition + */ +export function reduceInventoryOnOrderPlaced(event: DataEvent): void { + const order = event.entity; + const orderItems = order?.orderItems ?? []; + const fulfilledBy = order?.fulfillment?.fulfilledBy; + + for (const item of orderItems) { + const sku = item.product?.sellerSku; + const quantity = item.quantityOrdered ?? 0; + + if (!sku || quantity <= 0) continue; + + if (fulfilledBy === "MERCHANT") { + reduceMfnInventory(sku, quantity); + } else { + reduceFbaInventory(sku, quantity); + } + } +} + +/** + * Resolves the listing an order line refers to. Listings are keyed by seller + * and SKU, but an order carries no selling partner, so the SKU is matched on + * its own. When several sellers use the same SKU the line cannot be + * attributed to one of them, and guessing would deduct from the wrong + * seller's ledger — so nothing is reduced. + */ +function findListingBySku(sku: string): Record | undefined { + const matches = Context.instance.engine.find(Api.LISTINGS, { sku }); + if (matches.length === 1) return matches[0]; + + if (matches.length > 1) { + console.warn(`[Trigger] SKU ${sku} belongs to ${String(matches.length)} sellers; the order does not say which, so MFN inventory is unchanged`); + } + return undefined; +} + +function reduceMfnInventory(sku: string, quantity: number): void { + const engine = Context.instance.engine; + const listing = findListingBySku(sku); + if (!listing) return; + + // Live MFN quantities are kept in the listing's mfnAvailability ledger + // (system-managed). The attributes layer is never touched: the seller's + // submitted fulfillment_availability keeps showing the submitted value + // while the ledger reflects live inventory. + const ledger = listing.mfnAvailability as { fulfillmentChannelCode?: string; quantity?: number }[] | undefined; + if (!Array.isArray(ledger)) return; + + const mfnChannel = ledger.find((entry) => entry.fulfillmentChannelCode === "DEFAULT"); + + if (mfnChannel?.quantity !== undefined) { + mfnChannel.quantity = Math.max(0, mfnChannel.quantity - quantity); + // Written back under the listing's own composite key, never a bare SKU. + engine.put(Api.LISTINGS, String(listing._key), listing, { silent: true }); + console.info(`[Trigger] Reduced MFN inventory for SKU ${sku} by ${String(quantity)} (channel DEFAULT)`); + } +} + +function reduceFbaInventory(sku: string, quantity: number): void { + const engine = Context.instance.engine; + const inventoryEntry = engine.get(Api.INVENTORY, sku); + if (!inventoryEntry) return; + + if (inventoryEntry.totalQuantity !== undefined) { + inventoryEntry.totalQuantity = Math.max(0, (inventoryEntry.totalQuantity as number) - quantity); + } + if (inventoryEntry.fulfillableQuantity !== undefined) { + inventoryEntry.fulfillableQuantity = Math.max(0, (inventoryEntry.fulfillableQuantity as number) - quantity); + } + + engine.put(Api.INVENTORY, sku, inventoryEntry, { silent: true }); + console.info(`[Trigger] Reduced FBA inventory for SKU ${sku} by ${quantity}`); +} diff --git a/local-ai-sandbox/src/trigger/handlers/sendOrderChangeNotification.ts b/local-ai-sandbox/src/trigger/handlers/sendOrderChangeNotification.ts new file mode 100644 index 000000000..a6b3de924 --- /dev/null +++ b/local-ai-sandbox/src/trigger/handlers/sendOrderChangeNotification.ts @@ -0,0 +1,231 @@ +import { randomUUID } from "node:crypto"; +import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; +import { DataEvent } from "../DataEvent.js"; +import { Api, Context } from "../../database/Context.js"; +import { getAllowedMarketplaceIds } from "../../marketplaceIds.js"; + +/** Lazily instantiated SQS client — shared across invocations within this handler. */ +let sqsClient: SQSClient | null = null; + +function getSqsClient(): SQSClient { + if (!sqsClient) { + sqsClient = new SQSClient({}); + } + return sqsClient; +} + +/** + * Extracts the SQS queue URL from an ARN. + * ARN format: arn:aws:sqs::: + */ +function queueUrlFromArn(arn: string): string { + const parts = arn.split(":"); + const region = parts[3]; + const accountId = parts[4]; + const queueName = parts[5]; + return `https://sqs.${region}.amazonaws.com/${accountId}/${queueName}`; +} + +/** Sandbox stand-in for the selling partner identifier, used when there is no per-order seller concept to read from. */ +const SANDBOX_SELLER_ID = "sandbox-seller"; + +/** + * Maps the internal Orders `fulfillmentStatus` (FulfillmentStatus enum in + * res/models/orders_2026-01-01.json, e.g. "PARTIALLY_SHIPPED") to the + * OrderChangeNotification schema's `Summary.OrderStatus` enum (e.g. + * "PartiallyShipped"). Falls back to "Pending" — a schema-valid value — for + * any status not in the map, so the notification never carries a value the + * schema rejects. + */ +const ORDER_STATUS_MAP: Record = { + PENDING_AVAILABILITY: "PendingAvailability", + PENDING: "Pending", + UNSHIPPED: "Unshipped", + PARTIALLY_SHIPPED: "PartiallyShipped", + SHIPPED: "Shipped", + CANCELLED: "Canceled", + UNFULFILLABLE: "Unfulfillable", +}; + +function mapOrderStatus(rawFulfillmentStatus: string | undefined): string { + if (rawFulfillmentStatus && rawFulfillmentStatus in ORDER_STATUS_MAP) { + return ORDER_STATUS_MAP[rawFulfillmentStatus]; + } + return "Pending"; +} + +/** + * The set of values the schema's `Summary.OrderType` enum accepts. The internal + * Orders entity has no order-type concept, so we default to "StandardOrder" and + * only pass through an entity-supplied value when it is a schema-valid enum + * member — keeping the emitted notification schema-valid (mirroring mapOrderStatus). + */ +const VALID_ORDER_TYPES = new Set(["StandardOrder", "LongLeadTimeOrder", "Preorder", "BackOrder", "SourcingOnDemandOrder"]); + +function mapOrderType(rawOrderType: string | undefined): string { + if (rawOrderType && VALID_ORDER_TYPES.has(rawOrderType)) { + return rawOrderType; + } + return "StandardOrder"; +} + +/** + * Builds an ORDER_CHANGE notification payload matching the SP-API notification schema. + */ +function buildNotificationPayload(event: DataEvent): Record { + const order = event.entity ?? {}; + const previousOrder = event.previousEntity ?? {}; + + const now = new Date().toISOString(); + const orderId = (order.orderId as string) ?? event.id; + const rawOrderStatus = order.fulfillment?.fulfillmentStatus as string | undefined; + const rawPreviousStatus = previousOrder.fulfillment?.fulfillmentStatus as string | undefined; + const orderStatus = mapOrderStatus(rawOrderStatus); + const previousStatus = mapOrderStatus(rawPreviousStatus); + + const orderItems = (order.orderItems as Record[] | undefined) ?? []; + + // Summary.MarketplaceId is a required, non-nullable string. Prefer the order's + // own marketplace, falling back to the first marketplace allowed for the + // configured region so the field is never empty. + const marketplaceId = (order.salesChannel?.marketplaceId as string | undefined) ?? getAllowedMarketplaceIds()[0]; + + // Summary.PurchaseDate is required but nullable. The internal entity has no + // dedicated purchase date, so createdTime is the closest equivalent. + const purchaseDate = (order.createdTime as string | undefined) ?? null; + + // Summary.DestinationPostalCode is required but nullable. The recipient's + // address may be stored under `shippingAddress` (scenario/DB data) or + // `deliveryAddress` (Orders UI); support both, defaulting to null. + const recipientAddress = (order.recipient?.shippingAddress ?? order.recipient?.deliveryAddress) as Record | undefined; + const destinationPostalCode = (recipientAddress?.postalCode as string | undefined) ?? null; + + const orderType = mapOrderType(order.orderType as string | undefined); + + return { + NotificationVersion: "1.0", + NotificationType: "ORDER_CHANGE", + PayloadVersion: "1.0", + EventTime: now, + Payload: { + OrderChangeNotification: { + NotificationLevel: "OrderLevel", + SellerId: SANDBOX_SELLER_ID, + AmazonOrderId: orderId, + OrderChangeType: "OrderStatusChange", + OrderChangeTrigger: { + TimeOfOrderChange: now, + ChangeReason: `Status changed from ${previousStatus} to ${orderStatus}`, + }, + Summary: { + MarketplaceId: marketplaceId, + OrderStatus: orderStatus, + PurchaseDate: purchaseDate, + DestinationPostalCode: destinationPostalCode, + FulfillmentType: (order.fulfillment?.fulfilledBy as string) === "AMAZON" ? "AFN" : "MFN", + OrderType: orderType, + OrderItems: orderItems.map((item: Record) => ({ + OrderItemId: item.orderItemId, + SellerSKU: (item.product as Record | undefined)?.sellerSku, + // Required by the schema but nullable; the internal item has no + // supply source concept, so fall back to null when absent. + SupplySourceId: (item.supplySourceId as string | undefined) ?? null, + Quantity: item.quantityOrdered, + })), + }, + }, + }, + NotificationMetadata: { + ApplicationId: "sandbox-app", + SubscriptionId: "", // Populated below when subscription is found + PublishTime: now, + NotificationId: randomUUID(), + }, + }; +} + +/** + * Trigger handler: when an order's status changes (UPDATE event), + * looks for an active ORDER_CHANGE subscription. If one exists and has + * a valid SQS destination, sends the notification to the queue. + * + * The trigger fires on every order UPDATE (not just fulfillment-status + * changes), so this handler guards against spurious notifications by + * comparing the new and previous fulfillment status and returning early + * when they are unchanged. + * + * Does nothing if no subscription exists (no error — subscriptions are optional). + */ +export async function sendOrderChangeNotification(event: DataEvent): Promise { + const newStatus = event.entity?.fulfillment?.fulfillmentStatus; + const prevStatus = event.previousEntity?.fulfillment?.fulfillmentStatus; + if (newStatus === prevStatus) { + console.info("[Trigger] Order fulfillment status unchanged — skipping ORDER_CHANGE notification"); + return; + } + + const engine = Context.instance.engine; + + // Find an active subscription for ORDER_CHANGE notifications + const subscriptions = engine.find(Api.NOTIFICATIONS, { + _type: "subscription", + notificationType: "ORDER_CHANGE", + }); + + if (subscriptions.length === 0) { + console.info("[Trigger] No ORDER_CHANGE subscription found — skipping notification"); + return; + } + + const subscription = subscriptions[0]; + const destinationId = subscription.destinationId as string; + + // Resolve the destination + const destinations = engine.find(Api.NOTIFICATIONS, { + _type: "destination", + destinationId, + }); + + if (destinations.length === 0) { + console.warn("[Trigger] ORDER_CHANGE subscription references unknown destination — skipping"); + return; + } + + const destination = destinations[0]; + const resource = destination.resource as { sqs?: { arn: string } } | undefined; + + if (!resource?.sqs) { + console.warn("[Trigger] ORDER_CHANGE destination has no SQS resource configured — skipping"); + return; + } + + // Build and send the notification + const payload = buildNotificationPayload(event); + (payload.NotificationMetadata as Record).SubscriptionId = subscription.subscriptionId ?? subscription._key; + + const queueUrl = queueUrlFromArn(resource.sqs.arn); + const client = getSqsClient(); + + const command = new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: JSON.stringify(payload), + }); + + try { + await client.send(command); + console.info(`[Trigger] ORDER_CHANGE notification sent to ${queueUrl} for order ${event.id}`); + } catch (error) { + console.error(`[Trigger] Failed to send ORDER_CHANGE notification for order ${event.id}:`, error); + } + + // Also persist the notification in the database for local inspection + engine.put( + Api.NOTIFICATIONS, + `notification-${(payload.NotificationMetadata as Record).NotificationId as string}`, + { + _type: "notification", + ...payload, + }, + { silent: true }, + ); +} diff --git a/local-ai-sandbox/src/trigger/triggerRegistry.ts b/local-ai-sandbox/src/trigger/triggerRegistry.ts new file mode 100644 index 000000000..f48484f45 --- /dev/null +++ b/local-ai-sandbox/src/trigger/triggerRegistry.ts @@ -0,0 +1,95 @@ +import { readFileSync } from "node:fs"; +import { parse } from "yaml"; +import { JSONPath } from "jsonpath-plus"; +import { Api } from "../database/Context.js"; +import { DataEvent, DataEventType } from "./DataEvent.js"; +import { reduceInventoryOnOrderPlaced } from "./handlers/reduceInventoryOnOrderPlaced.js"; +import { processListingSubmission } from "./handlers/processListingSubmission.js"; +import { sendOrderChangeNotification } from "./handlers/sendOrderChangeNotification.js"; + +export interface Trigger { + name: string; + description: string; + on: { + api: Api; + event: DataEventType[]; + condition?: (event: DataEvent) => boolean; + }; + handler: (event: DataEvent) => void | Promise; +} + +/** + * Handler map: resolves handler names from the YAML spec to actual functions. + * Add new handlers here when creating new triggers. + */ +const handlers: Record void | Promise> = { + reduceInventoryOnOrderPlaced, + processListingSubmission, + sendOrderChangeNotification, +}; + +/** + * Condition parser using JSONPath. + * + * Condition format in YAML: + * condition: + * path: "$.fulfillment.fulfillmentStatus" # JSONPath expression + * equals: "SHIPPED" # Expected value (supports: equals, notEquals, exists) + * + * Supports: + * - equals: value at path must equal the given value + * - notEquals: value at path must not equal the given value + * - exists: true/false — whether the path resolves to a value + */ +function parseCondition(condition: any): ((event: DataEvent) => boolean) | undefined { + if (!condition) return undefined; + + const { path, equals, notEquals, exists } = condition; + if (!path) return undefined; + + return (event: DataEvent) => { + const target = event.type === "DELETE" ? event.previousEntity : event.entity; + const results = JSONPath({ path, json: target ?? {} }); + const value = results.length > 0 ? results[0] : undefined; + + if (equals !== undefined) return value === equals; + if (notEquals !== undefined) return value !== notEquals; + if (exists === true) return value != null; + if (exists === false) return value == null; + + return results.length > 0; + }; +} + +/** + * Load trigger rules from the YAML spec file. + */ +function loadTriggers(): Trigger[] { + const content = readFileSync("./res/triggers.yaml", "utf-8"); + const spec = parse(content); + const triggers: Trigger[] = []; + + for (const [, items] of Object.entries(spec.domains) as [string, any][]) { + for (const trigger of items) { + const handler = handlers[trigger.handler]; + if (!handler) { + console.error(`[Trigger] Unknown handler: ${trigger.handler}`); + continue; + } + triggers.push({ + name: trigger.name, + description: trigger.description, + on: { + api: trigger.on.api as Api, + event: trigger.on.event as DataEventType[], + condition: parseCondition(trigger.on.condition), + }, + handler, + }); + } + } + + return triggers; +} + +export const triggerRegistry: Trigger[] = loadTriggers(); diff --git a/local-ai-sandbox/src/validation/validationRegistry.ts b/local-ai-sandbox/src/validation/validationRegistry.ts new file mode 100644 index 000000000..327506f25 --- /dev/null +++ b/local-ai-sandbox/src/validation/validationRegistry.ts @@ -0,0 +1,1340 @@ +import { ValidationPipeline } from "./validationTypes.js"; +import { Api } from "../database/Context.js"; + +/** + * Shared marketplace ID validation rule definition for query-sourced marketplaceIds (plural). + */ +const marketplaceIdsQueryRule = { + checkType: "marketplaceIdValidation" as const, + marketplaceIdsParam: { name: "marketplaceIds", source: "query" as const }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "One or more marketplace IDs are not valid for the configured region", + }, +}; + +/** + * Listings Items `includedData` sections that belong to one selling partner + * type only. Both selling partner types call the same operations, but the + * datasets differ: `offers` and `fulfillmentAvailability` describe a merchant + * offer, while `procurement` describes the cost Amazon pays a vendor. Asking + * for the other type's dataset is rejected rather than answered empty, so the + * caller learns the section does not apply to them. + */ +const listingsDatasetModeRules = [ + { + checkType: "modeRestriction" as const, + param: { name: "includedData", source: "query" as const }, + restrictedValue: "offers", + requiredMode: "Seller", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "The 'offers' includedData section is only available to sellers", + }, + }, + { + checkType: "modeRestriction" as const, + param: { name: "includedData", source: "query" as const }, + restrictedValue: "fulfillmentAvailability", + requiredMode: "Seller", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "The 'fulfillmentAvailability' includedData section is only available to sellers", + }, + }, + { + checkType: "modeRestriction" as const, + param: { name: "includedData", source: "query" as const }, + restrictedValue: "procurement", + requiredMode: "Vendor", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "The 'procurement' includedData section is only available to vendors", + }, + }, +]; + +/** + * Shared marketplace ID validation rule definition for body-sourced marketplaceIds (plural). + */ +const marketplaceIdsBodyRule = { + checkType: "marketplaceIdValidation" as const, + marketplaceIdsParam: { name: "marketplaceIds", source: "body" as const }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "One or more marketplace IDs are not valid for the configured region", + }, +}; + +/** + * Shared marketplace ID validation rule definition for query-sourced marketplaceId (singular). + */ +const marketplaceIdQueryRule = { + checkType: "marketplaceIdValidation" as const, + marketplaceIdsParam: { name: "marketplaceId", source: "query" as const }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "The marketplace ID is not valid for the configured region", + }, +}; + + +/** + * Maps Validation_Key (apiName:apiVersion:operationId) to its corresponding ValidationPipeline. + * Each pipeline is an ordered array of validation rules executed sequentially. + * The first rule to fail short-circuits the pipeline and returns an error response. + */ +export const VALIDATION_REGISTRY = new Map([ + // --- Orders --- + [ + "Orders:v0:confirmShipment", + [ + { + checkType: "entityExistence", + entity: { + api: Api.ORDERS, + paramName: "orderId", + paramSource: "path", + entityLabel: "order", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Order not found", + }, + }, + { + checkType: "businessRule", + entity: { + api: Api.ORDERS, + paramName: "orderId", + paramSource: "path", + }, + condition: { + field: "fulfillment.fulfilledBy", + operator: "eq", + value: "AMAZON", + }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Confirm shipment not allowed for FBA orders", + }, + }, + { + checkType: "businessRule", + entity: { + api: Api.ORDERS, + paramName: "orderId", + paramSource: "path", + }, + condition: { + field: "fulfillment.fulfillmentStatus", + operator: "notIn", + value: ["UNSHIPPED", "PARTIALLY_SHIPPED"], + }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Confirm shipment is only allowed for orders with status UNSHIPPED or PARTIALLY_SHIPPED", + }, + }, + { + checkType: "orderItemExistence", + entityLabel: "order", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Order item not found in the order", + }, + }, + { + checkType: "quantityLimit", + entityLabel: "order", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Quantity exceeds the ordered quantity", + }, + }, + ], + ], + [ + "Orders:2026-01-01:getOrder", + [ + { + checkType: "entityExistence", + entity: { + api: Api.ORDERS, + paramName: "orderId", + paramSource: "path", + entityLabel: "order", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Order not found", + }, + }, + ], + ], + [ + "Orders:2026-01-01:searchOrders", + [ + marketplaceIdsQueryRule, + { + checkType: "mutualExclusivity", + params: [ + { name: "createdAfter", source: "query" }, + { name: "lastUpdatedAfter", source: "query" }, + ], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameters 'createdAfter' and 'lastUpdatedAfter' are mutually exclusive", + }, + }, + { + checkType: "atLeastOneRequired", + params: [ + { name: "createdAfter", source: "query" }, + { name: "lastUpdatedAfter", source: "query" }, + ], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "At least one of 'createdAfter', 'lastUpdatedAfter' must be provided", + }, + }, + { + checkType: "dateComparison", + firstOperand: { name: "createdAfter", source: "query" }, + secondOperand: { kind: "param", name: "createdBefore", source: "query" }, + operator: "beforeOrEqual", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameter 'createdAfter' must be before or equal to 'createdBefore'", + }, + }, + { + checkType: "dateComparison", + firstOperand: { name: "lastUpdatedAfter", source: "query" }, + secondOperand: { kind: "param", name: "lastUpdatedBefore", source: "query" }, + operator: "beforeOrEqual", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameter 'lastUpdatedAfter' must be before or equal to 'lastUpdatedBefore'", + }, + }, + ], + ], + + // --- Listings --- + [ + "Listings:2021-08-01:getListingsItem", + [ + marketplaceIdsQueryRule, + ...listingsDatasetModeRules, + { + checkType: "entityExistence", + entity: { + api: Api.LISTINGS, + paramName: "sku", + paramSource: "path", + entityLabel: "listing", + // A SKU is unique per seller, not globally, so a listing is keyed by + // both. Resolving by SKU alone would return a different seller's + // listing that happens to reuse the SKU. + keyParams: [ + { name: "sellerId", source: "path" }, + { name: "sku", source: "path" }, + ], + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Listing not found", + }, + }, + ], + ], + [ + // No existence rule: patchListingsItem is an upsert in production, so an + // unknown SKU is created rather than rejected. + "Listings:2021-08-01:patchListingsItem", + [marketplaceIdsQueryRule], + ], + [ + "Listings:2021-08-01:deleteListingsItem", + [ + marketplaceIdsQueryRule, + { + checkType: "entityExistence", + entity: { + api: Api.LISTINGS, + paramName: "sku", + paramSource: "path", + entityLabel: "listing", + // Same composite key as the read: deleting by SKU alone would remove + // whichever seller's listing happened to occupy that SKU. + keyParams: [ + { name: "sellerId", source: "path" }, + { name: "sku", source: "path" }, + ], + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Listing not found", + }, + }, + ], + ], + + [ + "Listings:2021-08-01:putListingsItem", + [ + marketplaceIdsQueryRule, + // Offer-only submissions list against an ASIN a merchant does not own. + // A vendor supplies the product itself, so the requirement set does not + // apply to them. + { + checkType: "modeRestriction", + param: { name: "requirements", source: "body" }, + restrictedValue: "LISTING_OFFER_ONLY", + requiredMode: "Seller", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "The 'LISTING_OFFER_ONLY' requirements value is only available to sellers", + }, + }, + ], + ], + [ + "Listings:2021-08-01:searchListingsItems", + [ + marketplaceIdsQueryRule, + ...listingsDatasetModeRules, + // identifiers and identifiersType are documented as required together. + { + checkType: "requiredTogether", + params: [ + { name: "identifiers", source: "query" }, + { name: "identifiersType", source: "query" }, + ], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "The 'identifiers' and 'identifiersType' parameters must be provided together", + }, + }, + // These three filters cannot be combined with one another. + { + checkType: "atMostOneAllowed", + params: [ + { name: "identifiers", source: "query" }, + { name: "variationParentSku", source: "query" }, + { name: "packageHierarchySku", source: "query" }, + ], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "The 'identifiers', 'variationParentSku', and 'packageHierarchySku' parameters cannot be used together", + }, + }, + ], + ], + + // --- Catalog Items --- + [ + "Catalog Items:2022-04-01:getCatalogItem", + [ + marketplaceIdsQueryRule, + { + checkType: "modeRestriction", + param: { name: "includedData", source: "query" }, + restrictedValue: "vendorDetails", + requiredMode: "Vendor", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "The includedData requested requires vendor access", + }, + }, + { + checkType: "entityExistence", + entity: { + api: Api.CATALOG, + paramName: "asin", + paramSource: "path", + entityLabel: "catalog item", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Catalog item not found", + }, + }, + ], + ], + [ + "Catalog Items:2022-04-01:searchCatalogItems", + [ + marketplaceIdsQueryRule, + { + checkType: "mutualExclusivity", + params: [ + { name: "keywords", source: "query" }, + { name: "identifiers", source: "query" }, + ], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameters 'keywords' and 'identifiers' are mutually exclusive", + }, + }, + { + checkType: "atLeastOneRequired", + params: [ + { name: "keywords", source: "query" }, + { name: "identifiers", source: "query" }, + ], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "At least one of 'keywords' or 'identifiers' must be provided", + }, + }, + { + checkType: "conditionalExclusion", + trigger: { name: "identifiers", source: "query" }, + forbidden: [ + { name: "brandNames", source: "query" }, + { name: "classificationIds", source: "query" }, + { name: "keywordsLocale", source: "query" }, + ], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameters 'brandNames', 'classificationIds', and 'keywordsLocale' cannot be used when 'identifiers' is provided", + }, + }, + { + checkType: "conditionalRequirement", + trigger: { name: "identifiers", source: "query" }, + required: { name: "identifiersType", source: "query" }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameter 'identifiersType' is required when 'identifiers' is provided", + }, + }, + { + checkType: "conditionalRequirement", + trigger: { name: "identifiersType", source: "query", value: "SKU" }, + required: { name: "sellerId", source: "query" }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameter 'sellerId' is required when 'identifiersType' is 'SKU'", + }, + }, + { + checkType: "modeRestriction", + param: { name: "includedData", source: "query" }, + restrictedValue: "vendorDetails", + requiredMode: "Vendor", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "The includedData requested requires vendor access", + }, + }, + ], + ], + + // --- External Fulfillment Shipments --- + ["External Fulfillment Shipments:2024-09-11:getShipments", [ + marketplaceIdQueryRule + ]], + [ + "External Fulfillment Shipments:2024-09-11:getShipment", + [ + { + checkType: "entityExistence", + entity: { + api: Api.EXT_FULFILLMENT_SHIPMENTS, + paramName: "shipmentId", + paramSource: "path", + entityLabel: "shipment", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Shipment not found", + }, + }, + ], + ], + [ + "External Fulfillment Shipments:2024-09-11:processShipment", + [ + { + checkType: "entityExistence", + entity: { + api: Api.EXT_FULFILLMENT_SHIPMENTS, + paramName: "shipmentId", + paramSource: "path", + entityLabel: "shipment", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Shipment not found", + }, + }, + ], + ], + [ + "External Fulfillment Shipments:2024-09-11:createPackages", + [ + { + checkType: "entityExistence", + entity: { + api: Api.EXT_FULFILLMENT_SHIPMENTS, + paramName: "shipmentId", + paramSource: "path", + entityLabel: "shipment", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Shipment not found", + }, + }, + ], + ], + [ + "External Fulfillment Shipments:2024-09-11:updatePackage", + [ + { + checkType: "entityExistence", + entity: { + api: Api.EXT_FULFILLMENT_SHIPMENTS, + paramName: "shipmentId", + paramSource: "path", + entityLabel: "shipment", + }, + nested: { + childParamName: "packageId", + childParamSource: "path", + childCollection: "packages", + childIdField: "id", + childLabel: "package", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Shipment or package not found", + }, + }, + ], + ], + [ + "External Fulfillment Shipments:2024-09-11:updatePackageStatus", + [ + { + checkType: "entityExistence", + entity: { + api: Api.EXT_FULFILLMENT_SHIPMENTS, + paramName: "shipmentId", + paramSource: "path", + entityLabel: "shipment", + }, + nested: { + childParamName: "packageId", + childParamSource: "path", + childCollection: "packages", + childIdField: "id", + childLabel: "package", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Shipment or package not found", + }, + }, + ], + ], + [ + "External Fulfillment Shipments:2024-09-11:retrieveShippingOptions", + [ + { + checkType: "entityExistence", + entity: { + api: Api.EXT_FULFILLMENT_SHIPMENTS, + paramName: "shipmentId", + paramSource: "path", + entityLabel: "shipment", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Shipment not found", + }, + }, + ], + ], + [ + "External Fulfillment Shipments:2024-09-11:retrieveInvoice", + [ + { + checkType: "entityExistence", + entity: { + api: Api.EXT_FULFILLMENT_SHIPMENTS, + paramName: "shipmentId", + paramSource: "path", + entityLabel: "shipment", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Shipment not found", + }, + }, + ], + ], + [ + "External Fulfillment Shipments:2024-09-11:generateInvoice", + [ + { + checkType: "entityExistence", + entity: { + api: Api.EXT_FULFILLMENT_SHIPMENTS, + paramName: "shipmentId", + paramSource: "path", + entityLabel: "shipment", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Shipment not found", + }, + }, + ], + ], + [ + "External Fulfillment Shipments:2024-09-11:generateShipLabels", + [ + { + checkType: "entityExistence", + entity: { + api: Api.EXT_FULFILLMENT_SHIPMENTS, + paramName: "shipmentId", + paramSource: "path", + entityLabel: "shipment", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Shipment not found", + }, + }, + ], + ], + + // --- External Fulfillment Inventory --- + ["External Fulfillment Inventory:2024-09-11:batchInventory", [ + { + checkType: "batchSizeLimit", + arrayParam: { name: "requests", source: "body" }, + maxItems: 10, + failAction: { statusCode: 400, code: "InvalidInput", message: "Batch size exceeds maximum of 10 items" }, + }, + ]], + + // --- External Fulfillment Returns --- + [ + "External Fulfillment Returns:2024-09-11:listReturns", + [ + { + checkType: "conditionalRequirement", + trigger: { name: "lastUpdatedSince", source: "query" }, + required: { name: "returnLocationId", source: "query" }, + failAction: { statusCode: 400, code: "InvalidInput", message: "Parameter 'returnLocationId' is required when 'lastUpdatedSince' is provided" }, + }, + { + checkType: "conditionalRequirement", + trigger: { name: "lastUpdatedSince", source: "query" }, + required: { name: "status", source: "query" }, + failAction: { statusCode: 400, code: "InvalidInput", message: "Parameter 'status' is required when 'lastUpdatedSince' is provided" }, + }, + { + checkType: "conditionalRequirement", + trigger: { name: "lastUpdatedUntil", source: "query" }, + required: { name: "returnLocationId", source: "query" }, + failAction: { statusCode: 400, code: "InvalidInput", message: "Parameter 'returnLocationId' is required when 'lastUpdatedUntil' is provided" }, + }, + { + checkType: "conditionalRequirement", + trigger: { name: "lastUpdatedUntil", source: "query" }, + required: { name: "status", source: "query" }, + failAction: { statusCode: 400, code: "InvalidInput", message: "Parameter 'status' is required when 'lastUpdatedUntil' is provided" }, + }, + { + checkType: "dateComparison", + firstOperand: { name: "createdSince", source: "query" }, + secondOperand: { kind: "param", name: "createdUntil", source: "query" }, + operator: "beforeOrEqual", + failAction: { statusCode: 400, code: "InvalidInput", message: "Parameter 'createdSince' must be before or equal to 'createdUntil'" }, + }, + { + checkType: "dateComparison", + firstOperand: { name: "lastUpdatedSince", source: "query" }, + secondOperand: { kind: "param", name: "lastUpdatedUntil", source: "query" }, + operator: "beforeOrEqual", + failAction: { statusCode: 400, code: "InvalidInput", message: "Parameter 'lastUpdatedSince' must be before or equal to 'lastUpdatedUntil'" }, + }, + ], + ], + [ + "External Fulfillment Returns:2024-09-11:getReturn", + [ + { + checkType: "entityExistence", + entity: { + api: Api.EXT_FULFILLMENT_RETURNS, + paramName: "returnId", + paramSource: "path", + entityLabel: "return", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Return not found", + }, + }, + ], + ], + + // --- Reports --- + [ + "Reports:2021-06-30:createReport", + [ + marketplaceIdsBodyRule, + { + checkType: "reportTypeSupported", + reportTypeParam: { name: "reportType", source: "body" }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Unsupported reportType", + }, + }, + { + checkType: "reportMetaValidation", + reportTypeParam: { name: "reportType", source: "body" }, + marketplaceIdsParam: { name: "marketplaceIds", source: "body" }, + reportOptionsParam: { name: "reportOptions", source: "body" }, + dataStartTimeParam: { name: "dataStartTime", source: "body" }, + dataEndTimeParam: { name: "dataEndTime", source: "body" }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Report metadata validation failed", + }, + }, + { + checkType: "dateComparison", + firstOperand: { name: "dataStartTime", source: "body" }, + secondOperand: { kind: "param", name: "dataEndTime", source: "body" }, + operator: "beforeOrEqual", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameter 'dataStartTime' must be before or equal to 'dataEndTime'", + }, + }, + { + checkType: "dateComparison", + firstOperand: { name: "dataStartTime", source: "body" }, + secondOperand: { kind: "now" }, + operator: "beforeOrEqual", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameter 'dataStartTime' must be prior to or equal to the current date and time", + }, + }, + { + checkType: "dateComparison", + firstOperand: { name: "dataEndTime", source: "body" }, + secondOperand: { kind: "now" }, + operator: "beforeOrEqual", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameter 'dataEndTime' must be prior to or equal to the current date and time", + }, + }, + ], + ], + [ + "Reports:2021-06-30:getReport", + [ + { + checkType: "entityExistence", + entity: { + api: Api.REPORTS, + paramName: "reportId", + paramSource: "path", + entityLabel: "report", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Report not found", + }, + }, + { + checkType: "entityFieldCheck", + entityLabel: "report", + field: "content", + operator: "notExists", + failAction: { + statusCode: 404, + code: "NotFound", + message: "Report not found", + }, + }, + ], + ], + [ + "Reports:2021-06-30:getReports", + [ + marketplaceIdsQueryRule, + { + checkType: "atLeastOneRequired", + params: [ + { name: "reportTypes", source: "query" }, + { name: "nextToken", source: "query" }, + ], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "At least one of 'reportTypes' or 'nextToken' must be provided", + }, + }, + { + checkType: "conditionalExclusion", + trigger: { name: "nextToken", source: "query" }, + forbidden: [ + { name: "reportTypes", source: "query" }, + { name: "processingStatuses", source: "query" }, + { name: "marketplaceIds", source: "query" }, + { name: "pageSize", source: "query" }, + { name: "createdSince", source: "query" }, + { name: "createdUntil", source: "query" }, + ], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "When 'nextToken' is provided, no other parameters may be specified", + }, + }, + { + checkType: "dateComparison", + firstOperand: { name: "createdSince", source: "query" }, + secondOperand: { kind: "param", name: "createdUntil", source: "query" }, + operator: "beforeOrEqual", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameter 'createdSince' must be before or equal to 'createdUntil'", + }, + }, + ], + ], + [ + "Reports:2021-06-30:cancelReport", + [ + { + checkType: "entityExistence", + entity: { + api: Api.REPORTS, + paramName: "reportId", + paramSource: "path", + entityLabel: "report", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Report not found", + }, + }, + { + checkType: "entityFieldCheck", + entityLabel: "report", + field: "content", + operator: "notExists", + failAction: { + statusCode: 404, + code: "NotFound", + message: "Report not found", + }, + }, + { + checkType: "businessRule", + entity: { + api: Api.REPORTS, + paramName: "reportId", + paramSource: "path", + }, + condition: { + field: "processingStatus", + operator: "neq", + value: "IN_QUEUE", + }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Only reports with processingStatus 'IN_QUEUE' can be cancelled", + }, + }, + ], + ], + [ + "Reports:2021-06-30:getReportDocument", + [ + { + checkType: "entityExistence", + entity: { + api: Api.REPORTS, + paramName: "reportDocumentId", + paramSource: "path", + entityLabel: "report document", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Report document not found", + }, + }, + { + checkType: "entityFieldCheck", + entityLabel: "report document", + field: "content", + operator: "exists", + failAction: { + statusCode: 404, + code: "NotFound", + message: "Report document not found", + }, + }, + ], + ], + [ + "Reports:2021-06-30:createReportSchedule", + [ + marketplaceIdsBodyRule, + { + checkType: "reportSchedulable", + reportTypeParam: { name: "reportType", source: "body" }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "reportType can only be requested, not scheduled", + }, + }, + { + checkType: "dateComparison", + firstOperand: { name: "nextReportCreationTime", source: "body" }, + secondOperand: { kind: "now" }, + operator: "afterOrEqual", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameter 'nextReportCreationTime' must be in the future", + }, + }, + ], + ], + [ + "Reports:2021-06-30:getReportSchedule", + [ + { + checkType: "entityExistence", + entity: { + api: Api.REPORTS, + paramName: "reportScheduleId", + paramSource: "path", + entityLabel: "report schedule", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Report schedule not found", + }, + }, + { + checkType: "entityFieldCheck", + entityLabel: "report schedule", + field: "reportScheduleId", + operator: "exists", + failAction: { + statusCode: 404, + code: "NotFound", + message: "Report schedule not found", + }, + }, + ], + ], + ["Reports:2021-06-30:getReportSchedules", []], + [ + "Reports:2021-06-30:cancelReportSchedule", + [ + { + checkType: "entityExistence", + entity: { + api: Api.REPORTS, + paramName: "reportScheduleId", + paramSource: "path", + entityLabel: "report schedule", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Report schedule not found", + }, + }, + { + checkType: "entityFieldCheck", + entityLabel: "report schedule", + field: "reportScheduleId", + operator: "exists", + failAction: { + statusCode: 404, + code: "NotFound", + message: "Report schedule not found", + }, + }, + ], + ], + + // --- Product Type Definitions --- + [ + "Product Type Definitions:2020-09-01:searchDefinitionsProductTypes", + [ + marketplaceIdsQueryRule, + { + checkType: "mutualExclusivity", + params: [ + { name: "keywords", source: "query" }, + { name: "itemName", source: "query" }, + ], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameters 'keywords' and 'itemName' are mutually exclusive", + }, + }, + ], + ], + [ + "Product Type Definitions:2020-09-01:getDefinitionsProductType", + [ + marketplaceIdsQueryRule, + { + checkType: "entityExistence", + entity: { + api: Api.PRODUCT_TYPE_DEFINITIONS, + paramName: "productType", + paramSource: "path", + entityLabel: "product type definition", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Product type definition not found", + }, + }, + ], + ], + + // --- Listings Restrictions --- + [ + "Listings Restrictions:2021-08-01:getListingsRestrictions", + [ + marketplaceIdsQueryRule, + { + checkType: "atLeastOneRequired", + params: [{ name: "marketplaceIds", source: "query" }], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "At least one 'marketplaceIds' value must be provided", + }, + }, + ], + ], + + // --- Product Pricing --- + ["Product Pricing:2022-05-01:getCompetitiveSummary", [ + { + checkType: "arrayItemFieldValue", + arrayParam: { name: "requests", source: "body" }, + itemField: "uri", + expectedValue: "/products/pricing/2022-05-01/offer/featuredOfferExpectedPrice", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Each request uri must be '/products/pricing/2022-05-01/offer/featuredOfferExpectedPrice'", + }, + }, + ]], + [ + "Product Pricing:2022-05-01:getFeaturedOfferExpectedPriceBatch", + [ + { + checkType: "batchSizeLimit", + arrayParam: { name: "requests", source: "body" }, + maxItems: 40, + failAction: { statusCode: 400, code: "InvalidInput", message: "Batch size exceeds maximum of 40 items" }, + }, + ], + ], + + // --- FBA Inventory --- + [ + "FBA Inventory:v1:getInventorySummaries", + [ + marketplaceIdsQueryRule, + { + checkType: "dateComparison", + firstOperand: { name: "startDateTime", source: "query" }, + secondOperand: { kind: "now", offsetMs: -(18 * 30 * 24 * 60 * 60 * 1000) }, + operator: "afterOrEqual", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "startDateTime must be no earlier than 18 months prior to the current date and time", + }, + }, + ], + ], + + // --- Notifications --- + ["Notifications:v1:getDestinations", []], + ["Notifications:v1:getSubscription", []], + ["Notifications:v1:getSubscriptions", []], + ["Notifications:v1:createDestination", []], + [ + "Notifications:v1:getDestination", + [ + { + checkType: "entityExistence", + entity: { + api: Api.NOTIFICATIONS, + paramName: "destinationId", + paramSource: "path", + entityLabel: "destination", + // Destinations and subscriptions share the Api.NOTIFICATIONS + // keyspace (each keyed by its own UUID), so a subscriptionId + // passed here must not resolve as a destination. + expectedType: "destination", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Destination not found", + }, + }, + ], + ], + [ + "Notifications:v1:deleteDestination", + [ + { + checkType: "entityExistence", + entity: { + api: Api.NOTIFICATIONS, + paramName: "destinationId", + paramSource: "path", + entityLabel: "destination", + expectedType: "destination", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Destination not found", + }, + }, + ], + ], + [ + "Notifications:v1:createSubscription", + [ + { + checkType: "entityExistence", + entity: { + api: Api.NOTIFICATIONS, + paramName: "destinationId", + paramSource: "body", + entityLabel: "destination", + // Reject a subscriptionId supplied as destinationId in the body. + expectedType: "destination", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Destination not found", + }, + }, + ], + ], + [ + "Notifications:v1:getSubscriptionById", + [ + { + checkType: "entityExistence", + entity: { + api: Api.NOTIFICATIONS, + paramName: "subscriptionId", + paramSource: "path", + entityLabel: "subscription", + // Reject a destinationId passed as subscriptionId. + expectedType: "subscription", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Subscription not found", + }, + }, + ], + ], + [ + "Notifications:v1:deleteSubscriptionById", + [ + { + checkType: "entityExistence", + entity: { + api: Api.NOTIFICATIONS, + paramName: "subscriptionId", + paramSource: "path", + entityLabel: "subscription", + expectedType: "subscription", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Subscription not found", + }, + }, + ], + ], + + // --- Data Kiosk --- + [ + "Data Kiosk:2023-11-15:createQuery", + [ + { + checkType: "stringLengthLimit", + param: { name: "query", source: "body" }, + max: 8000, + normalizeWhitespace: true, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "The query must be at most 8000 characters after unnecessary whitespace is removed.", + }, + }, + ], + ], + [ + "Data Kiosk:2023-11-15:getQueries", + [ + { + checkType: "dateComparison", + firstOperand: { name: "createdSince", source: "query" }, + secondOperand: { kind: "param", name: "createdUntil", source: "query" }, + operator: "beforeOrEqual", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Parameter 'createdSince' must be before or equal to 'createdUntil'", + }, + }, + ], + ], + [ + "Data Kiosk:2023-11-15:getQuery", + [ + { + checkType: "entityExistence", + entity: { api: Api.DATA_KIOSK, paramName: "queryId", paramSource: "path", entityLabel: "query" }, + failAction: { statusCode: 404, code: "NotFound", message: "Query not found" }, + }, + { + checkType: "entityFieldCheck", + entityLabel: "query", + field: "content", + operator: "notExists", + failAction: { statusCode: 404, code: "NotFound", message: "Query not found" }, + }, + ], + ], + [ + "Data Kiosk:2023-11-15:cancelQuery", + [ + { + checkType: "entityExistence", + entity: { api: Api.DATA_KIOSK, paramName: "queryId", paramSource: "path", entityLabel: "query" }, + failAction: { statusCode: 404, code: "NotFound", message: "Query not found" }, + }, + { + checkType: "entityFieldCheck", + entityLabel: "query", + field: "content", + operator: "notExists", + failAction: { statusCode: 404, code: "NotFound", message: "Query not found" }, + }, + { + checkType: "businessRule", + entity: { api: Api.DATA_KIOSK, paramName: "queryId", paramSource: "path" }, + condition: { field: "processingStatus", operator: "in", value: ["DONE", "FATAL"] }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Only queries with a non-terminal processingStatus (IN_QUEUE, IN_PROGRESS) can be cancelled", + }, + }, + ], + ], + [ + "Data Kiosk:2023-11-15:getDocument", + [ + { + checkType: "entityExistence", + entity: { api: Api.DATA_KIOSK, paramName: "documentId", paramSource: "path", entityLabel: "document" }, + failAction: { statusCode: 404, code: "NotFound", message: "Document not found" }, + }, + { + checkType: "entityFieldCheck", + entityLabel: "document", + field: "content", + operator: "exists", + failAction: { statusCode: 404, code: "NotFound", message: "Document not found" }, + }, + ], + ], +]); \ No newline at end of file diff --git a/local-ai-sandbox/src/validation/validationTypes.ts b/local-ai-sandbox/src/validation/validationTypes.ts new file mode 100644 index 000000000..cf80ba777 --- /dev/null +++ b/local-ai-sandbox/src/validation/validationTypes.ts @@ -0,0 +1,525 @@ +import { z } from "zod"; +import { Api } from "../database/Context.js"; + +// --- Request Context --- + +export interface RequestContext { + apiName: string; + apiVersion: string; + operationId: string; + method: string; + pathParams: Record; + queryParams: Record; + body: Record | undefined; +} + +// --- Validation Results --- + +export interface ValidationPass { + pass: true; + resolvedEntities: Record>; +} + +export interface ValidationFail { + pass: false; + statusCode: number; + body: { + errors: { + code: string; + message: string; + details?: string; + }[]; + }; +} + +export type ValidationResult = ValidationPass | ValidationFail; + +// --- Unified Validation Results (for unified entry point) --- + +export interface UnifiedValidationPass { + pass: true; + operationId: string; + apiName: string; + apiVersion: string; + pathParams: Record; + queryParams: Record; + body: Record | undefined; + resolvedEntities: Record>; + operation: any; // Full OpenAPI operation object from openapi-enforcer +} + +export interface UnifiedValidationFail { + pass: false; + statusCode: number; + body?: { errors: { code: string; message: string; details?: string }[] }; +} + +export type UnifiedValidationResult = UnifiedValidationPass | UnifiedValidationFail; + +// --- Validation Rule Interfaces --- + +export interface BaseValidationRule { + checkType: string; + failAction: { + statusCode: number; + code?: string; + message: string; + details?: string; + }; +} + +export interface EntityExistenceRule extends BaseValidationRule { + checkType: "entityExistence"; + entity: { + api: Api; + paramName: string; + paramSource: "path" | "query" | "body"; + entityLabel: string; + /** + * Params forming a composite primary key, in key order, for partitions + * whose records are only unique within a scope (a listing's SKU is unique + * per seller, not globally). When omitted, `paramName` alone is the key. + * `paramName` still names the identifier reported in a not-found message, + * so the composite key format never leaks to callers. + */ + keyParams?: { name: string; source: "path" | "query" | "body" }[]; + /** + * Required `_type` discriminator for partitions that store more than one + * record shape under the same keyspace (e.g. Notifications destinations + * and subscriptions both live in Api.NOTIFICATIONS, keyed by their own + * UUID). When set, a record found by key whose `_type` does not match is + * treated the same as not found, so a subscriptionId cannot resolve as a + * destination (or vice versa). Omit for partitions with a single record + * shape. + */ + expectedType?: string; + }; + nested?: { + childParamName: string; + childParamSource: "path" | "query" | "body"; + childCollection: string; + childIdField: string; + childLabel: string; + }; +} + +export interface MutualExclusivityRule extends BaseValidationRule { + checkType: "mutualExclusivity"; + params: { + name: string; + source: "path" | "query" | "body"; + }[]; +} + +/** + * At most one of the params may be present. Unlike mutualExclusivity, all of + * them being absent is valid — for groups of optional, conflicting filters. + */ +export interface AtMostOneAllowedRule extends BaseValidationRule { + checkType: "atMostOneAllowed"; + params: { name: string; source: "path" | "query" | "body" }[]; +} + +/** + * The params must be supplied together: either all present or all absent. + */ +export interface RequiredTogetherRule extends BaseValidationRule { + checkType: "requiredTogether"; + params: { name: string; source: "path" | "query" | "body" }[]; +} + +export interface AtLeastOneRequiredRule extends BaseValidationRule { + checkType: "atLeastOneRequired"; + params: { + name: string; + source: "path" | "query" | "body"; + }[]; +} + +export interface ConditionalExclusionRule extends BaseValidationRule { + checkType: "conditionalExclusion"; + trigger: { + name: string; + source: "path" | "query" | "body"; + }; + forbidden: { + name: string; + source: "path" | "query" | "body"; + }[]; +} + +export interface BusinessRuleCheck extends BaseValidationRule { + checkType: "businessRule"; + entity: { + api: Api; + paramName: string; + paramSource: "path" | "query" | "body"; + }; + condition: { + field: string; + operator: "eq" | "neq" | "in" | "notIn"; + value: unknown; + }; +} + +export interface DateComparisonRule extends BaseValidationRule { + checkType: "dateComparison"; + firstOperand: { + name: string; + source: "path" | "query" | "body"; + }; + secondOperand: { kind: "param"; name: string; source: "path" | "query" | "body" } | { kind: "now"; offsetMs?: number }; + operator: "before" | "after" | "beforeOrEqual" | "afterOrEqual"; +} + +export interface OrderItemExistenceRule extends BaseValidationRule { + checkType: "orderItemExistence"; + entityLabel: string; +} + +export interface QuantityLimitRule extends BaseValidationRule { + checkType: "quantityLimit"; + entityLabel: string; +} + +export interface ReportTypeSupportedRule extends BaseValidationRule { + checkType: "reportTypeSupported"; + reportTypeParam: { + name: string; + source: "path" | "query" | "body"; + }; +} + +export interface ReportMetaValidationRule extends BaseValidationRule { + checkType: "reportMetaValidation"; + reportTypeParam: { + name: string; + source: "path" | "query" | "body"; + }; + marketplaceIdsParam: { + name: string; + source: "path" | "query" | "body"; + }; + reportOptionsParam: { + name: string; + source: "path" | "query" | "body"; + }; + dataStartTimeParam: { + name: string; + source: "path" | "query" | "body"; + }; + dataEndTimeParam: { + name: string; + source: "path" | "query" | "body"; + }; +} + +export interface EntityFieldCheckRule extends BaseValidationRule { + checkType: "entityFieldCheck"; + entityLabel: string; + field: string; + operator: "exists" | "notExists"; +} + +export interface ReportSchedulableRule extends BaseValidationRule { + checkType: "reportSchedulable"; + reportTypeParam: { + name: string; + source: "path" | "query" | "body"; + }; +} + +export interface MarketplaceIdValidationRule extends BaseValidationRule { + checkType: "marketplaceIdValidation"; + marketplaceIdsParam: { + name: string; + source: "path" | "query" | "body"; + }; +} + +export interface ConditionalRequirementRule extends BaseValidationRule { + checkType: "conditionalRequirement"; + trigger: { + name: string; + source: "path" | "query" | "body"; + value?: unknown; + }; + required: { + name: string; + source: "path" | "query" | "body"; + }; +} + +export interface ModeRestrictionRule extends BaseValidationRule { + checkType: "modeRestriction"; + param: { + name: string; + source: "path" | "query" | "body"; + }; + restrictedValue: string; + requiredMode: string; +} + +export interface BatchSizeLimitRule extends BaseValidationRule { + checkType: "batchSizeLimit"; + arrayParam: { + name: string; + source: "body"; + path?: string; + }; + maxItems: number; +} + +export interface ArrayItemFieldValueRule extends BaseValidationRule { + checkType: "arrayItemFieldValue"; + arrayParam: { + name: string; + source: "body"; + path?: string; + }; + itemField: string; + expectedValue: string; +} + +/** + * Validates that a string parameter does not exceed a maximum length. + * When `normalizeWhitespace` is true, insignificant whitespace is collapsed + * (runs of whitespace → single space, trimmed) before measuring length — + * matching SP-API "at most N characters after unnecessary whitespace is removed" + * semantics. A missing/empty value passes (length checks are not presence checks). + */ +export interface StringLengthLimitRule extends BaseValidationRule { + checkType: "stringLengthLimit"; + param: { + name: string; + source: "path" | "query" | "body"; + }; + max: number; + normalizeWhitespace?: boolean; +} + +// --- Discriminated Union and Pipeline --- + +export type ValidationRule = EntityExistenceRule | MutualExclusivityRule | AtMostOneAllowedRule | RequiredTogetherRule | AtLeastOneRequiredRule | ConditionalExclusionRule | BusinessRuleCheck | DateComparisonRule | OrderItemExistenceRule | QuantityLimitRule | ReportTypeSupportedRule | ReportMetaValidationRule | EntityFieldCheckRule | ReportSchedulableRule | MarketplaceIdValidationRule | ConditionalRequirementRule | ModeRestrictionRule | BatchSizeLimitRule | ArrayItemFieldValueRule | StringLengthLimitRule; + +export type ValidationPipeline = ValidationRule[]; + +// --- Rule Handler Type --- + +export type RuleHandler = (rule: ValidationRule, context: RequestContext, resolvedEntities: Record>) => Promise; + +// --- Zod Schemas for Runtime Validation --- + +export const ParamRefSchema = z.object({ + name: z.string(), + source: z.enum(["path", "query", "body"]), +}); + +export const FailActionSchema = z.object({ + statusCode: z.number().int().min(400).max(599), + code: z.string().optional(), + message: z.string(), + details: z.string().optional(), +}); + +export const EntityExistenceRuleSchema = z.object({ + checkType: z.literal("entityExistence"), + entity: z.object({ + api: z.nativeEnum(Api), + paramName: z.string(), + paramSource: z.enum(["path", "query", "body"]), + entityLabel: z.string(), + expectedType: z.string().optional(), + }), + nested: z + .object({ + childParamName: z.string(), + childParamSource: z.enum(["path", "query", "body"]), + childCollection: z.string(), + childIdField: z.string(), + childLabel: z.string(), + }) + .optional(), + failAction: FailActionSchema, +}); + +export const MutualExclusivityRuleSchema = z.object({ + checkType: z.literal("mutualExclusivity"), + params: z.array(ParamRefSchema).min(2), + failAction: FailActionSchema, +}); + +export const AtMostOneAllowedRuleSchema = z.object({ + checkType: z.literal("atMostOneAllowed"), + params: z.array(ParamRefSchema).min(2), + failAction: FailActionSchema, +}); + +export const RequiredTogetherRuleSchema = z.object({ + checkType: z.literal("requiredTogether"), + params: z.array(ParamRefSchema).min(2), + failAction: FailActionSchema, +}); + +export const AtLeastOneRequiredRuleSchema = z.object({ + checkType: z.literal("atLeastOneRequired"), + params: z.array(ParamRefSchema).min(1), + failAction: FailActionSchema, +}); + +export const ConditionalExclusionRuleSchema = z.object({ + checkType: z.literal("conditionalExclusion"), + trigger: ParamRefSchema, + forbidden: z.array(ParamRefSchema).min(1), + failAction: FailActionSchema, +}); + +export const BusinessRuleCheckSchema = z.object({ + checkType: z.literal("businessRule"), + entity: z.object({ + api: z.nativeEnum(Api), + paramName: z.string(), + paramSource: z.enum(["path", "query", "body"]), + }), + condition: z.object({ + field: z.string(), + operator: z.enum(["eq", "neq", "in", "notIn"]), + value: z.unknown(), + }), + failAction: FailActionSchema, +}); + +export const DateComparisonRuleSchema = z.object({ + checkType: z.literal("dateComparison"), + firstOperand: ParamRefSchema, + secondOperand: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("param"), name: z.string(), source: z.enum(["path", "query", "body"]) }), + z.object({ kind: z.literal("now"), offsetMs: z.number().optional() }), + ]), + operator: z.enum(["before", "after", "beforeOrEqual", "afterOrEqual"]), + failAction: FailActionSchema, +}); + +export const OrderItemExistenceRuleSchema = z.object({ + checkType: z.literal("orderItemExistence"), + entityLabel: z.string(), + failAction: FailActionSchema, +}); + +export const QuantityLimitRuleSchema = z.object({ + checkType: z.literal("quantityLimit"), + entityLabel: z.string(), + failAction: FailActionSchema, +}); + +export const ReportTypeSupportedRuleSchema = z.object({ + checkType: z.literal("reportTypeSupported"), + reportTypeParam: ParamRefSchema, + failAction: FailActionSchema, +}); + +export const ReportMetaValidationRuleSchema = z.object({ + checkType: z.literal("reportMetaValidation"), + reportTypeParam: ParamRefSchema, + marketplaceIdsParam: ParamRefSchema, + reportOptionsParam: ParamRefSchema, + dataStartTimeParam: ParamRefSchema, + dataEndTimeParam: ParamRefSchema, + failAction: FailActionSchema, +}); + +export const EntityFieldCheckRuleSchema = z.object({ + checkType: z.literal("entityFieldCheck"), + entityLabel: z.string(), + field: z.string(), + operator: z.enum(["exists", "notExists"]), + failAction: FailActionSchema, +}); + +export const ReportSchedulableRuleSchema = z.object({ + checkType: z.literal("reportSchedulable"), + reportTypeParam: ParamRefSchema, + failAction: FailActionSchema, +}); + +export const MarketplaceIdValidationRuleSchema = z.object({ + checkType: z.literal("marketplaceIdValidation"), + marketplaceIdsParam: ParamRefSchema, + failAction: FailActionSchema, +}); + +export const ConditionalRequirementRuleSchema = z.object({ + checkType: z.literal("conditionalRequirement"), + trigger: z.object({ + name: z.string(), + source: z.enum(["path", "query", "body"]), + value: z.unknown().optional(), + }), + required: ParamRefSchema, + failAction: FailActionSchema, +}); + +export const ModeRestrictionRuleSchema = z.object({ + checkType: z.literal("modeRestriction"), + param: ParamRefSchema, + restrictedValue: z.string(), + requiredMode: z.string(), + failAction: FailActionSchema, +}); + +export const BatchSizeLimitRuleSchema = z.object({ + checkType: z.literal("batchSizeLimit"), + arrayParam: z.object({ + name: z.string(), + source: z.literal("body"), + path: z.string().optional(), + }), + maxItems: z.number().int().min(1), + failAction: FailActionSchema, +}); + +export const ArrayItemFieldValueRuleSchema = z.object({ + checkType: z.literal("arrayItemFieldValue"), + arrayParam: z.object({ + name: z.string(), + source: z.literal("body"), + path: z.string().optional(), + }), + itemField: z.string(), + expectedValue: z.string(), + failAction: FailActionSchema, +}); + +export const StringLengthLimitRuleSchema = z.object({ + checkType: z.literal("stringLengthLimit"), + param: z.object({ + name: z.string(), + source: z.enum(["path", "query", "body"]), + }), + max: z.number().int().min(0), + normalizeWhitespace: z.boolean().optional(), + failAction: FailActionSchema, +}); + +export const ValidationRuleSchema = z.discriminatedUnion("checkType", [ + EntityExistenceRuleSchema, + MutualExclusivityRuleSchema, + AtMostOneAllowedRuleSchema, + RequiredTogetherRuleSchema, + AtLeastOneRequiredRuleSchema, + ConditionalExclusionRuleSchema, + BusinessRuleCheckSchema, + DateComparisonRuleSchema, + OrderItemExistenceRuleSchema, + QuantityLimitRuleSchema, + ReportTypeSupportedRuleSchema, + ReportMetaValidationRuleSchema, + EntityFieldCheckRuleSchema, + ReportSchedulableRuleSchema, + MarketplaceIdValidationRuleSchema, + ConditionalRequirementRuleSchema, + ModeRestrictionRuleSchema, + BatchSizeLimitRuleSchema, + ArrayItemFieldValueRuleSchema, + StringLengthLimitRuleSchema, +]); diff --git a/local-ai-sandbox/test/controller/deriveNotificationType.property.test.ts b/local-ai-sandbox/test/controller/deriveNotificationType.property.test.ts new file mode 100644 index 000000000..b95d8205c --- /dev/null +++ b/local-ai-sandbox/test/controller/deriveNotificationType.property.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import fc from "fast-check"; +import { deriveNotificationType } from "../../src/controller/notificationsManagementController.js"; + +/** + * Arbitrary that generates a single PascalCase word (starts with uppercase, followed by lowercase letters). + */ +const arbPascalWord = fc + .tuple( + fc.constantFrom(..."ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("")), + fc.array(fc.constantFrom(..."abcdefghijklmnopqrstuvwxyz".split("")), { minLength: 1, maxLength: 8 }), + ) + .map(([first, rest]) => first + rest.join("")); + +/** + * Arbitrary that generates a PascalCase string with "Notification" suffix. + * E.g. "OrderChange" + "Notification" = "OrderChangeNotification" + */ +const arbPascalCaseWithNotificationSuffix = fc.array(arbPascalWord, { minLength: 1, maxLength: 4 }).map((words) => words.join("") + "Notification"); + +describe("deriveNotificationType Property Tests", () => { + /** + * Property 7: PascalCase filename to UPPER_SNAKE_CASE conversion + * **Validates: Requirements 7.2** + * + * For any PascalCase string with a "Notification" suffix, the deriveNotificationType function + * shall strip the suffix, split on word boundaries, join with underscores, and uppercase the result, + * producing a valid UPPER_SNAKE_CASE notification type. + */ + + it("Property 7a: Output format contains only uppercase letters, digits, and underscores", () => { + fc.assert( + fc.property(arbPascalCaseWithNotificationSuffix, (input) => { + const result = deriveNotificationType(input); + expect(result).toMatch(/^[A-Z0-9_]+$/); + }), + { numRuns: 100 }, + ); + }); + + it("Property 7b: The word NOTIFICATION does not appear in the output (suffix is stripped)", () => { + fc.assert( + fc.property(arbPascalCaseWithNotificationSuffix, (input) => { + const result = deriveNotificationType(input); + expect(result).not.toContain("NOTIFICATION"); + }), + { numRuns: 100 }, + ); + }); + + it("Property 7c: Output is not empty for valid PascalCase input with at least one word before Notification", () => { + fc.assert( + fc.property(arbPascalCaseWithNotificationSuffix, (input) => { + const result = deriveNotificationType(input); + expect(result.length).toBeGreaterThan(0); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/controller/notificationsManagementController.test.ts b/local-ai-sandbox/test/controller/notificationsManagementController.test.ts new file mode 100644 index 000000000..0fcc990fd --- /dev/null +++ b/local-ai-sandbox/test/controller/notificationsManagementController.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Request, Response } from "express"; + +// Mock fs/promises +vi.mock("node:fs/promises", () => ({ + readdir: vi.fn(), + readFile: vi.fn(), +})); + +// Mock @aws-sdk/client-sqs +const mockSqsSend = vi.fn(); +vi.mock("@aws-sdk/client-sqs", () => { + return { + SQSClient: class MockSQSClient { + send = mockSqsSend; + }, + SendMessageCommand: class MockSendMessageCommand { + input: unknown; + constructor(input: unknown) { + this.input = input; + } + }, + }; +}); + +// Mock database Context +const mockFind = vi.fn(); +vi.mock("../../src/database/Context.js", () => ({ + Api: { NOTIFICATIONS: "notifications" }, + Context: { + instance: { + engine: { + find: (...args: unknown[]) => mockFind(...args), + }, + }, + }, +})); + +import { deriveNotificationType, getNotificationSchemas, sendNotification } from "../../src/controller/notificationsManagementController.js"; +import { readdir, readFile } from "node:fs/promises"; + +const mockReaddir = readdir as unknown as ReturnType; +const mockReadFile = readFile as unknown as ReturnType; + +function createMockResponse(): Response { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response; + return res; +} + +function createMockRequest(body: Record = {}): Request { + return { body } as unknown as Request; +} + +describe("notificationsManagementController", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("deriveNotificationType", () => { + it("converts PascalCase with Notification suffix to UPPER_SNAKE_CASE", () => { + expect(deriveNotificationType("OrderChangeNotification")).toBe("ORDER_CHANGE"); + }); + + it("handles filename with .json extension", () => { + expect(deriveNotificationType("OrderChangeNotification.json")).toBe("ORDER_CHANGE"); + }); + + it("handles single word before Notification suffix", () => { + expect(deriveNotificationType("ReportNotification")).toBe("REPORT"); + }); + + it("handles multi-word names", () => { + expect(deriveNotificationType("FulfillmentOrderStatusNotification")).toBe("FULFILLMENT_ORDER_STATUS"); + }); + + it("handles names without Notification suffix", () => { + expect(deriveNotificationType("OrderChange")).toBe("ORDER_CHANGE"); + }); + + it("handles names with consecutive uppercase letters", () => { + expect(deriveNotificationType("FBAInventoryNotification")).toBe("FBA_INVENTORY"); + }); + }); + + describe("getNotificationSchemas", () => { + it("returns schemas with correct notificationType derivation", async () => { + const schemaContent = JSON.stringify({ type: "object", properties: {} }); + mockReaddir.mockResolvedValue(["OrderChangeNotification.json"]); + mockReadFile.mockResolvedValue(schemaContent); + + const req = createMockRequest(); + const res = createMockResponse(); + + await getNotificationSchemas(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([{ notificationType: "ORDER_CHANGE", schema: { type: "object", properties: {} } }]); + }); + + it("returns empty array when directory does not exist", async () => { + mockReaddir.mockRejectedValue(new Error("ENOENT: no such file or directory")); + + const req = createMockRequest(); + const res = createMockResponse(); + + await getNotificationSchemas(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([]); + }); + + it("returns empty array when directory has no JSON files", async () => { + mockReaddir.mockResolvedValue(["readme.txt", ".DS_Store"]); + + const req = createMockRequest(); + const res = createMockResponse(); + + await getNotificationSchemas(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([]); + }); + + it("skips invalid JSON files with a warning", async () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + mockReaddir.mockResolvedValue(["ValidNotification.json", "InvalidNotification.json"]); + mockReadFile + .mockResolvedValueOnce(JSON.stringify({ valid: true })) + .mockResolvedValueOnce("not valid json {{{"); + + const req = createMockRequest(); + const res = createMockResponse(); + + await getNotificationSchemas(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([{ notificationType: "VALID", schema: { valid: true } }]); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Skipping invalid JSON file 'InvalidNotification.json'"), expect.anything()); + + consoleSpy.mockRestore(); + }); + + it("returns multiple schemas from multiple files", async () => { + mockReaddir.mockResolvedValue(["OrderChangeNotification.json", "ReportProcessingNotification.json"]); + mockReadFile + .mockResolvedValueOnce(JSON.stringify({ type: "order" })) + .mockResolvedValueOnce(JSON.stringify({ type: "report" })); + + const req = createMockRequest(); + const res = createMockResponse(); + + await getNotificationSchemas(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([ + { notificationType: "ORDER_CHANGE", schema: { type: "order" } }, + { notificationType: "REPORT_PROCESSING", schema: { type: "report" } }, + ]); + }); + }); + + describe("sendNotification", () => { + beforeEach(() => { + mockFind.mockReset(); + mockSqsSend.mockReset(); + }); + + it("returns 400 when NotificationType is missing from the payload", async () => { + const req = createMockRequest({}); + const res = createMockResponse(); + + await sendNotification(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "NotificationType field is required in the payload" }); + }); + + it("returns 404 when no subscription exists for the notification type", async () => { + mockFind.mockReturnValue([]); + + const req = createMockRequest({ NotificationType: "ORDER_CHANGE" }); + const res = createMockResponse(); + + await sendNotification(req, res); + + expect(mockFind).toHaveBeenCalledWith("notifications", { _type: "subscription", notificationType: "ORDER_CHANGE" }); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: "No subscription exists for notification type 'ORDER_CHANGE'" }); + }); + + it("returns 400 when no destination is found for the subscription", async () => { + mockFind + .mockReturnValueOnce([{ destinationId: "dest-1", notificationType: "ORDER_CHANGE" }]) // subscription found + .mockReturnValueOnce([]); // no destination found + + const req = createMockRequest({ NotificationType: "ORDER_CHANGE" }); + const res = createMockResponse(); + + await sendNotification(req, res); + + expect(mockFind).toHaveBeenCalledWith("notifications", { _type: "destination", destinationId: "dest-1" }); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "No valid SQS destination configured for this subscription" }); + }); + + it("returns 400 when destination has no SQS resource", async () => { + mockFind + .mockReturnValueOnce([{ destinationId: "dest-1", notificationType: "ORDER_CHANGE" }]) // subscription found + .mockReturnValueOnce([{ destinationId: "dest-1", resource: {} }]); // destination without sqs + + const req = createMockRequest({ NotificationType: "ORDER_CHANGE" }); + const res = createMockResponse(); + + await sendNotification(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "No valid SQS destination configured for this subscription" }); + }); + + it("returns 200 with messageId on successful SQS send", async () => { + mockFind + .mockReturnValueOnce([{ destinationId: "dest-1", notificationType: "ORDER_CHANGE" }]) + .mockReturnValueOnce([{ destinationId: "dest-1", resource: { sqs: { arn: "arn:aws:sqs:us-east-1:123456789012:my-queue" } } }]); + mockSqsSend.mockResolvedValue({ MessageId: "msg-123" }); + + const req = createMockRequest({ NotificationType: "ORDER_CHANGE", Payload: { orderId: "123" } }); + const res = createMockResponse(); + + await sendNotification(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ messageId: "msg-123" }); + }); + + it("returns 500 when SQS client throws an error", async () => { + mockFind + .mockReturnValueOnce([{ destinationId: "dest-1", notificationType: "ORDER_CHANGE" }]) + .mockReturnValueOnce([{ destinationId: "dest-1", resource: { sqs: { arn: "arn:aws:sqs:us-east-1:123456789012:my-queue" } } }]); + mockSqsSend.mockRejectedValue(new Error("SQS service unavailable")); + + // Suppress console.error for this test + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const req = createMockRequest({ NotificationType: "ORDER_CHANGE", Payload: { orderId: "123" } }); + const res = createMockResponse(); + + await sendNotification(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: "Failed to send message to SQS: SQS service unavailable" }); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/local-ai-sandbox/test/controller/ordersManagement.property.test.ts b/local-ai-sandbox/test/controller/ordersManagement.property.test.ts new file mode 100644 index 000000000..e4edcbc9a --- /dev/null +++ b/local-ai-sandbox/test/controller/ordersManagement.property.test.ts @@ -0,0 +1,351 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; +import { Request, Response } from "express"; + +// Mock Context before importing controller +vi.mock("../../src/database/Context.js", () => { + const mockEngine = { + get: vi.fn(), + put: vi.fn(), + remove: vi.fn(), + }; + return { + Api: { ORDERS: "orders" }, + Context: { + instance: { engine: mockEngine }, + }, + }; +}); + +import { createOrder, updateOrder, deleteOrder } from "../../src/controller/ordersManagementController.js"; +import { Context } from "../../src/database/Context.js"; + +const mockEngine = Context.instance.engine as unknown as { + get: ReturnType; + put: ReturnType; + remove: ReturnType; +}; + +function createMockResponse(): Response & { status: ReturnType; json: ReturnType } { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response & { status: ReturnType; json: ReturnType }; + return res; +} + +function createMockRequest(overrides: Partial = {}): Request { + return { + body: {}, + params: {}, + ...overrides, + } as unknown as Request; +} + +/** Arbitrary for orderId in the 3-7-7 digit format */ +const arbOrderId = fc.stringMatching(/^\d{3}-\d{7}-\d{7}$/); + +/** Arbitrary for order document fields (excluding orderId) */ +const arbOrderFields = fc.record({ + createdTime: fc.constantFrom("2024-01-01T00:00:00Z", "2024-06-15T12:30:00Z", "2025-03-20T08:45:00Z"), + lastUpdatedTime: fc.constantFrom("2024-01-02T00:00:00Z", "2024-06-16T12:30:00Z", "2025-03-21T08:45:00Z"), + salesChannel: fc.record({ + channelName: fc.constantFrom("AMAZON", "NON_AMAZON"), + marketplaceId: fc.constantFrom("ATVPDKIKX0DER", "A2EUQ1WTGCTBG2", "A1PA6795UKMFR9"), + }), + fulfillment: fc.record({ + fulfillmentStatus: fc.constantFrom("PENDING", "UNSHIPPED", "SHIPPED", "CANCELLED"), + fulfilledBy: fc.constantFrom("AMAZON", "MERCHANT"), + }), + buyer: fc.record({ + buyerName: fc.string({ minLength: 1, maxLength: 50 }), + }), +}); + +/** Arbitrary for a unique set of orderIds (at least 2) */ +const arbOrderIdSet = fc.uniqueArray(arbOrderId, { minLength: 2, maxLength: 10 }); + +describe("ordersManagementController Property Tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /** + * Property 1: Create-then-retrieve round trip + * **Validates: Requirements 6.2, 6.5, 8.2** + * + * For any valid order document with a unique orderId, after successfully creating it via POST, + * retrieving it from the database using `engine.get(Api.ORDERS, orderId)` should return a document + * whose fields match the original POST body. + */ + it("Property 1: Create-then-retrieve round trip", async () => { + await fc.assert( + fc.asyncProperty(arbOrderId, arbOrderFields, async (orderId, fields) => { + vi.clearAllMocks(); + + const orderBody = { orderId, ...fields }; + + // Mock engine.get to return null on first call (no duplicate) and the stored doc on second call + mockEngine.get.mockReturnValueOnce(null); + + const req = createMockRequest({ body: orderBody }); + const res = createMockResponse(); + + await createOrder(req, res); + + // Verify 201 response with orderId + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({ orderId }); + + // Verify engine.put was called with correct arguments (the round-trip write) + expect(mockEngine.put).toHaveBeenCalledWith("orders", orderId, orderBody); + }), + { numRuns: 100 }, + ); + }); + + /** + * Property 2: Duplicate orderId rejection preserves existing data + * **Validates: Requirements 6.4, 8.5** + * + * For any order already stored in the database, POSTing another order with the same orderId + * should return 409, and the stored document should remain unchanged from its original state. + */ + it("Property 2: Duplicate orderId rejection preserves existing data", async () => { + await fc.assert( + fc.asyncProperty(arbOrderId, arbOrderFields, arbOrderFields, async (orderId, existingFields, newFields) => { + vi.clearAllMocks(); + + // The existing order stored in the database + const existingOrder = { orderId, ...existingFields }; + + // Mock engine.get to return the existing order (simulating it's already stored) + mockEngine.get.mockReturnValue(existingOrder); + + // A different body attempting to use the same orderId + const duplicateBody = { orderId, ...newFields }; + const req = createMockRequest({ body: duplicateBody }); + const res = createMockResponse(); + + await createOrder(req, res); + + // Verify 409 response with correct error message + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ + error: `Order with orderId '${orderId}' already exists`, + }); + + // Verify engine.put was NOT called — original data is preserved + expect(mockEngine.put).not.toHaveBeenCalled(); + }), + { numRuns: 100 }, + ); + }); + + /** + * Property 3: Update overwrites document while preserving key + * **Validates: Requirements 7.2, 8.3** + * + * For any existing order and any valid update body containing the same orderId, + * after a successful PUT, the response should contain the updated document and + * engine.put should be called with the new body (full overwrite semantics). + */ + it("Property 3: Update overwrites document while preserving key", async () => { + await fc.assert( + fc.asyncProperty(arbOrderId, arbOrderFields, arbOrderFields, async (orderId, originalFields, updateFields) => { + vi.clearAllMocks(); + + const originalOrder = { orderId, ...originalFields }; + const updateBody = { orderId, ...updateFields }; + + // Only exercise runs where the update genuinely changes the document, so the + // overwrite is observable. (buyerName is random, so this virtually never discards.) + fc.pre(JSON.stringify(updateBody) !== JSON.stringify(originalOrder)); + + // Stateful mock store: get reflects the most recent write. Seeding with the + // original order lets the existence check pass, and re-reading after put + // returns whatever was actually stored. This means the response assertion + // below genuinely verifies re-read-after-write (overwrite) semantics rather + // than echoing a value the test hardcoded into the second get() call. + let stored: Record = originalOrder; + mockEngine.get.mockImplementation(() => stored); + mockEngine.put.mockImplementation((_api: string, _id: string, doc: Record) => { + stored = doc; + }); + + const req = createMockRequest({ body: updateBody }); + const res = createMockResponse(); + + await updateOrder(req, res); + + // Verify engine.put was called with the update body (full overwrite) + expect(mockEngine.put).toHaveBeenCalledWith("orders", orderId, updateBody); + + // Verify response is 200 with the document that resulted from the overwrite. + // Because the store reflects the write, this asserts the controller returns + // the newly written body — a controller that echoed the stale pre-update + // document would fail this whenever the update changes any field. + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ order: updateBody }); + + // Explicitly guard against returning stale pre-update state. Because fc.pre + // above ensures updateBody differs from originalOrder, this is always meaningful. + expect(res.json).not.toHaveBeenCalledWith({ order: originalOrder }); + }), + { numRuns: 100 }, + ); + }); + + /** + * Property 4: Delete removes and only removes the targeted order + * **Validates: Requirements 2.5, 8.4** + * + * Pre-insert multiple orders, delete one, verify only that one is gone. + * We mock engine.get to return an existing order for the picked orderId, + * then verify engine.remove is called exactly once with the correct orderId. + */ + it("Property 4: Delete removes and only removes the targeted order", async () => { + await fc.assert( + fc.asyncProperty(arbOrderIdSet, fc.nat(), async (orderIds, pickIndex) => { + vi.clearAllMocks(); + + // Pick one orderId to delete + const targetIndex = pickIndex % orderIds.length; + const targetOrderId = orderIds[targetIndex]; + + // Mock engine.get to return an existing order for the target orderId + mockEngine.get.mockImplementation((_api: string, id: string) => { + if (id === targetOrderId) { + return { orderId: targetOrderId, status: "UNSHIPPED" }; + } + return null; + }); + mockEngine.remove.mockResolvedValue(true); + + const req = createMockRequest({ params: { orderId: targetOrderId } }); + const res = createMockResponse(); + + await deleteOrder(req, res); + + // Verify response is 200 with the success message + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ message: `Order '${targetOrderId}' deleted successfully` }); + + // Verify engine.remove was called with the correct orderId + expect(mockEngine.remove).toHaveBeenCalledWith("orders", targetOrderId); + + // Verify engine.remove was called exactly once (only the targeted order removed) + expect(mockEngine.remove).toHaveBeenCalledTimes(1); + }), + { numRuns: 100 }, + ); + }); + + /** + * Property 5: Non-existent orderId returns 404 for PUT and DELETE + * **Validates: Requirements 7.4, 8.6** + * + * For any orderId that does not exist in the orders collection, both PUT and DELETE + * requests referencing that orderId should return 404 with an appropriate error message, + * and the database should remain unchanged. + */ + it("Property 5: PUT returns 404 with error message for any non-existent orderId", async () => { + await fc.assert( + fc.asyncProperty(arbOrderId, async (orderId) => { + vi.clearAllMocks(); + mockEngine.get.mockReturnValue(null); + + const req = createMockRequest({ body: { orderId } }); + const res = createMockResponse(); + + await updateOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: `Order with orderId '${orderId}' not found` }); + expect(mockEngine.put).not.toHaveBeenCalled(); + }), + { numRuns: 100 }, + ); + }); + + it("Property 5: DELETE returns 404 with error message for any non-existent orderId", async () => { + await fc.assert( + fc.asyncProperty(arbOrderId, async (orderId) => { + vi.clearAllMocks(); + mockEngine.get.mockReturnValue(null); + + const req = createMockRequest({ params: { orderId } }); + const res = createMockResponse(); + + await deleteOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: `Order with orderId '${orderId}' not found` }); + expect(mockEngine.remove).not.toHaveBeenCalled(); + }), + { numRuns: 100 }, + ); + }); + + /** + * Property 6: Missing orderId returns 400 + * **Validates: Requirements 8.7** + * + * For any JSON body that does not contain an `orderId` field (including empty objects + * and objects with other fields), POST and PUT requests should return 400 with an error + * indicating the missing field. + */ + it("Property 6: POST returns 400 when body has no orderId field", async () => { + await fc.assert( + fc.asyncProperty( + fc.dictionary( + fc.string({ minLength: 1, maxLength: 20 }).filter((key) => key !== "orderId"), + fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null)), + ), + async (body) => { + vi.clearAllMocks(); + + const req = createMockRequest({ body }); + const res = createMockResponse(); + + await createOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: "Request body must contain an orderId field", + }); + expect(mockEngine.get).not.toHaveBeenCalled(); + expect(mockEngine.put).not.toHaveBeenCalled(); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 6: PUT returns 400 when body has no orderId field", async () => { + await fc.assert( + fc.asyncProperty( + fc.dictionary( + fc.string({ minLength: 1, maxLength: 20 }).filter((key) => key !== "orderId"), + fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null)), + ), + async (body) => { + vi.clearAllMocks(); + + const req = createMockRequest({ body }); + const res = createMockResponse(); + + await updateOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: "Request body must contain an orderId field", + }); + expect(mockEngine.get).not.toHaveBeenCalled(); + expect(mockEngine.put).not.toHaveBeenCalled(); + }, + ), + { numRuns: 100 }, + ); + }); +}); \ No newline at end of file diff --git a/local-ai-sandbox/test/controller/ordersManagementController.test.ts b/local-ai-sandbox/test/controller/ordersManagementController.test.ts new file mode 100644 index 000000000..74fc4c0a3 --- /dev/null +++ b/local-ai-sandbox/test/controller/ordersManagementController.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Request, Response } from "express"; + +// Mock Context before importing controller +vi.mock("../../src/database/Context.js", () => { + const mockEngine = { + get: vi.fn(), + put: vi.fn(), + remove: vi.fn(), + }; + return { + Api: { ORDERS: "orders" }, + Context: { + instance: { engine: mockEngine }, + }, + }; +}); + +import { createOrder, updateOrder, deleteOrder } from "../../src/controller/ordersManagementController.js"; +import { Context } from "../../src/database/Context.js"; + +const mockEngine = Context.instance.engine as unknown as { + get: ReturnType; + put: ReturnType; + remove: ReturnType; +}; + +function createMockResponse(): Response { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response; + return res; +} + +function createMockRequest(overrides: Partial = {}): Request { + return { + body: {}, + params: {}, + ...overrides, + } as unknown as Request; +} + +describe("ordersManagementController", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createOrder", () => { + it("returns 400 when body is undefined (e.g. non-JSON content type)", async () => { + const req = createMockRequest({ body: undefined }); + const res = createMockResponse(); + + await createOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Request body must be a valid JSON object" }); + expect(mockEngine.get).not.toHaveBeenCalled(); + expect(mockEngine.put).not.toHaveBeenCalled(); + }); + + it("returns 400 when body is a non-object (string / array)", async () => { + const badBodies: unknown[] = ["raw string", 42, ["not", "an", "object"]]; + for (const badBody of badBodies) { + vi.clearAllMocks(); + const req = { body: badBody, params: {} } as unknown as Request; + const res = createMockResponse(); + + await createOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Request body must be a valid JSON object" }); + expect(mockEngine.put).not.toHaveBeenCalled(); + } + }); + + it("returns 400 when orderId is missing from body", async () => { + const req = createMockRequest({ body: { name: "test" } }); + const res = createMockResponse(); + + await createOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Request body must contain an orderId field" }); + }); + + it("returns 409 when orderId already exists", async () => { + mockEngine.get.mockReturnValue({ orderId: "111-2222222-3333333" }); + const req = createMockRequest({ body: { orderId: "111-2222222-3333333" } }); + const res = createMockResponse(); + + await createOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ error: "Order with orderId '111-2222222-3333333' already exists" }); + }); + + it("creates the order and returns 201 with orderId", async () => { + mockEngine.get.mockReturnValue(null); + const body = { orderId: "111-2222222-3333333", createdTime: "2024-01-01T00:00:00Z" }; + const req = createMockRequest({ body }); + const res = createMockResponse(); + + await createOrder(req, res); + + expect(mockEngine.put).toHaveBeenCalledWith("orders", "111-2222222-3333333", body); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({ orderId: "111-2222222-3333333" }); + }); + + it("returns 500 on unexpected error", async () => { + mockEngine.get.mockImplementation(() => { + throw new Error("DB failure"); + }); + const req = createMockRequest({ body: { orderId: "111-2222222-3333333" } }); + const res = createMockResponse(); + + await createOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" }); + }); + }); + + describe("updateOrder", () => { + it("returns 400 when body is undefined (e.g. non-JSON content type)", async () => { + const req = createMockRequest({ body: undefined }); + const res = createMockResponse(); + + await updateOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Request body must be a valid JSON object" }); + expect(mockEngine.get).not.toHaveBeenCalled(); + expect(mockEngine.put).not.toHaveBeenCalled(); + }); + + it("returns 400 when body is a non-object (string / array)", async () => { + const badBodies: unknown[] = ["raw string", 42, ["not", "an", "object"]]; + for (const badBody of badBodies) { + vi.clearAllMocks(); + const req = { body: badBody, params: {} } as unknown as Request; + const res = createMockResponse(); + + await updateOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Request body must be a valid JSON object" }); + expect(mockEngine.put).not.toHaveBeenCalled(); + } + }); + + it("returns 400 when orderId is missing from body", async () => { + const req = createMockRequest({ body: { name: "test" } }); + const res = createMockResponse(); + + await updateOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Request body must contain an orderId field" }); + }); + + it("returns 404 when order does not exist", async () => { + mockEngine.get.mockReturnValue(null); + const req = createMockRequest({ body: { orderId: "111-2222222-3333333" } }); + const res = createMockResponse(); + + await updateOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: "Order with orderId '111-2222222-3333333' not found" }); + }); + + it("updates the order and returns 200 with updated document", async () => { + const existingOrder = { orderId: "111-2222222-3333333", status: "old" }; + const updatedOrder = { orderId: "111-2222222-3333333", status: "new", _key: "111-2222222-3333333" }; + mockEngine.get.mockReturnValueOnce(existingOrder).mockReturnValueOnce(updatedOrder); + const body = { orderId: "111-2222222-3333333", status: "new" }; + const req = createMockRequest({ body }); + const res = createMockResponse(); + + await updateOrder(req, res); + + expect(mockEngine.put).toHaveBeenCalledWith("orders", "111-2222222-3333333", body); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ order: updatedOrder }); + }); + + it("returns 500 on unexpected error", async () => { + mockEngine.get.mockImplementation(() => { + throw new Error("DB failure"); + }); + const req = createMockRequest({ body: { orderId: "111-2222222-3333333" } }); + const res = createMockResponse(); + + await updateOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" }); + }); + }); + + describe("deleteOrder", () => { + it("returns 400 when orderId path param is missing", async () => { + const req = createMockRequest({ params: {} }); + const res = createMockResponse(); + + await deleteOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "orderId path parameter is required" }); + }); + + it("returns 404 when order does not exist", async () => { + mockEngine.get.mockReturnValue(null); + const req = createMockRequest({ params: { orderId: "111-2222222-3333333" } }); + const res = createMockResponse(); + + await deleteOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: "Order with orderId '111-2222222-3333333' not found" }); + }); + + it("deletes the order and returns 200 with success message", async () => { + mockEngine.get.mockReturnValue({ orderId: "111-2222222-3333333" }); + mockEngine.remove.mockResolvedValue(true); + const req = createMockRequest({ params: { orderId: "111-2222222-3333333" } }); + const res = createMockResponse(); + + await deleteOrder(req, res); + + expect(mockEngine.remove).toHaveBeenCalledWith("orders", "111-2222222-3333333"); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ message: "Order '111-2222222-3333333' deleted successfully" }); + }); + + it("returns 500 on unexpected error", async () => { + mockEngine.get.mockImplementation(() => { + throw new Error("DB failure"); + }); + const req = createMockRequest({ params: { orderId: "111-2222222-3333333" } }); + const res = createMockResponse(); + + await deleteOrder(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" }); + }); + }); +}); diff --git a/local-ai-sandbox/test/controller/scenariosController.test.ts b/local-ai-sandbox/test/controller/scenariosController.test.ts new file mode 100644 index 000000000..4ebfb42cf --- /dev/null +++ b/local-ai-sandbox/test/controller/scenariosController.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { Request, Response } from "express"; +import { Api, Context } from "../../src/database/Context.js"; +import { listScenarios, seedScenario, loadScenarios, __resetScenarioCacheForTests, type Scenario } from "../../src/controller/scenariosController.js"; +import { MARKETPLACE_IDS_ALL } from "../../src/marketplaceIds.js"; + +interface TrackSummary { + id: string; + title: string; + description: string; + steps: Scenario["tracks"][number]["steps"]; + runnableCount: number; +} + +interface ScenarioSummary { + id: string; + title: string; + tagline: string; + description: string; + seedCount: number; + tracks: TrackSummary[]; +} + +interface ListResponseBody { + scenarios: ScenarioSummary[]; +} + +interface SeedResponseBody { + scenarioId: string; + title: string; + seeded: Record; + seedCount: number; + message: string; +} + +interface MockRes { + statusCode: number; + body: unknown; + status(code: number): MockRes; + json(payload: unknown): MockRes; +} + +function mockResponse(): Response & MockRes { + const res: MockRes = { + statusCode: 0, + body: undefined, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: unknown) { + this.body = payload; + return this; + }, + }; + return res as Response & MockRes; +} + +function seedRequest(scenarioId: string): Request { + return { params: { scenarioId } } as unknown as Request; +} + +interface SeededOrder { + orderId: string; + fulfillment: { fulfillmentStatus: string; fulfilledBy: string }; + orderItems: { orderItemId: string; quantityOrdered: number }[]; +} + +beforeEach(() => { + Context.reset(); + __resetScenarioCacheForTests(); + vi.restoreAllMocks(); +}); + +describe("scenario fixtures", () => { + it("load and validate against the schema with unique ids", () => { + const scenarios = loadScenarios(); + expect(scenarios.size).toBeGreaterThanOrEqual(1); + expect(scenarios.has("launch-a-product")).toBe(true); + }); + + it("launch-a-product has 5 tracks", () => { + const scenario = loadScenarios().get("launch-a-product"); + expect(scenario).toBeDefined(); + if (!scenario) return; + expect(scenario.tracks).toHaveLength(5); + const trackIds = scenario.tracks.map((t) => t.id); + expect(trackIds).toContain("orders-mfn"); + expect(trackIds).toContain("orders-fba"); + expect(trackIds).toContain("repricing"); + expect(trackIds).toContain("returns"); + expect(trackIds).toContain("fba-inbound"); + }); + + it("only seeds into valid Api namespaces with non-empty ids", () => { + for (const scenario of loadScenarios().values()) { + for (const seed of scenario.seed) { + expect(Object.values(Api)).toContain(seed.api); + expect(seed.id.length).toBeGreaterThan(0); + } + } + }); + + it("uses marketplace IDs that pass region validation", () => { + for (const scenario of loadScenarios().values()) { + const text = JSON.stringify(scenario.seed); + const matches = text.matchAll(/"marketplaceId":\s*"([^"]+)"/g); + for (const match of matches) { + expect(MARKETPLACE_IDS_ALL as readonly string[]).toContain(match[1]); + } + } + }); + + it("seeded MFN order satisfies confirmShipment preconditions", () => { + const scenario = loadScenarios().get("launch-a-product"); + const mfnOrder = scenario?.seed.find((s) => s.id === "111-0000001-0000001")?.entity as unknown as SeededOrder; + expect(mfnOrder).toBeDefined(); + expect(mfnOrder.fulfillment.fulfilledBy).toBe("MERCHANT"); + expect(mfnOrder.fulfillment.fulfillmentStatus).toBe("UNSHIPPED"); + expect(mfnOrder.orderItems.length).toBeGreaterThan(0); + }); + + it("seeded FBA order is fulfilled by AMAZON", () => { + const scenario = loadScenarios().get("launch-a-product"); + const fbaOrder = scenario?.seed.find((s) => s.id === "111-0000002-0000002")?.entity as unknown as SeededOrder; + expect(fbaOrder).toBeDefined(); + expect(fbaOrder.fulfillment.fulfilledBy).toBe("AMAZON"); + }); +}); + +describe("GET /scenarios (listScenarios)", () => { + it("returns scenarios with tracks and runnable counts", () => { + const res = mockResponse(); + listScenarios({} as Request, res); + expect(res.statusCode).toBe(200); + const body = res.body as ListResponseBody; + expect(body.scenarios.length).toBeGreaterThanOrEqual(1); + const launch = body.scenarios.find((s) => s.id === "launch-a-product"); + expect(launch).toBeDefined(); + if (!launch) return; + expect(launch.seedCount).toBe(5); + expect(launch.tracks.length).toBe(5); + const mfnTrack = launch.tracks.find((t) => t.id === "orders-mfn"); + expect(mfnTrack).toBeDefined(); + if (!mfnTrack) return; + expect(mfnTrack.runnableCount).toBeGreaterThan(0); + }); +}); + +describe("POST /scenarios/:scenarioId/seed (seedScenario)", () => { + it("writes all fixture entities into the database", () => { + const res = mockResponse(); + seedScenario(seedRequest("launch-a-product"), res); + expect(res.statusCode).toBe(200); + expect((res.body as SeedResponseBody).seedCount).toBe(5); + + expect(Context.instance.engine.get(Api.ORDERS, "111-0000001-0000001")?.orderId).toBe("111-0000001-0000001"); + expect(Context.instance.engine.get(Api.ORDERS, "111-0000002-0000002")?.orderId).toBe("111-0000002-0000002"); + expect(Context.instance.engine.get(Api.LISTINGS, "LAUNCH-SKU-001")?.sku).toBe("LAUNCH-SKU-001"); + expect(Context.instance.engine.get(Api.CATALOG, "B0LAUNCH01")?.asin).toBe("B0LAUNCH01"); + expect(Context.instance.engine.get(Api.INVENTORY, "LAUNCH-SKU-001")?.sellerSku).toBe("LAUNCH-SKU-001"); + }); + + it("re-seeding resets entities to fixture state", () => { + seedScenario(seedRequest("launch-a-product"), mockResponse()); + + const engine = Context.instance.engine; + const mutated = engine.get(Api.ORDERS, "111-0000001-0000001") as unknown as SeededOrder; + mutated.fulfillment.fulfillmentStatus = "SHIPPED"; + engine.put(Api.ORDERS, "111-0000001-0000001", mutated as unknown as Record); + + seedScenario(seedRequest("launch-a-product"), mockResponse()); + const reset = engine.get(Api.ORDERS, "111-0000001-0000001") as unknown as SeededOrder; + expect(reset.fulfillment.fulfillmentStatus).toBe("UNSHIPPED"); + }); + + it("returns 404 for an unknown scenario", () => { + const res = mockResponse(); + seedScenario(seedRequest("does-not-exist"), res); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/local-ai-sandbox/test/controller/sendNotification.property.test.ts b/local-ai-sandbox/test/controller/sendNotification.property.test.ts new file mode 100644 index 000000000..90b243a2e --- /dev/null +++ b/local-ai-sandbox/test/controller/sendNotification.property.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Request, Response } from "express"; +import fc from "fast-check"; + +// Mock the database Context module so engine.find always returns an empty array +vi.mock("../../src/database/Context.js", () => ({ + Api: { NOTIFICATIONS: "notifications" }, + Context: { + instance: { + engine: { + find: vi.fn().mockReturnValue([]), + }, + }, + }, +})); + +import { sendNotification } from "../../src/controller/notificationsManagementController.js"; + +function createMockRequest(body: Record): Request { + return { body } as unknown as Request; +} + +function createMockResponse(): Response & { status: ReturnType; json: ReturnType } { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + return res as unknown as Response & { status: ReturnType; json: ReturnType }; +} + +describe("sendNotification Property Tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /** + * Property 8: Missing subscription returns 404 + * **Validates: Requirements 8.4** + * + * For any notification payload where the extracted NotificationType has no matching subscription + * in the notifications database partition, the send endpoint shall return a 404 status code + * with an appropriate error message. + */ + it("Property 8: Any notification type with no matching subscription returns 404", async () => { + // Generate random non-empty strings as notification types + const arbNotificationType = fc.string({ minLength: 1, maxLength: 50 }).filter((s) => s.trim().length > 0); + + await fc.assert( + fc.asyncProperty(arbNotificationType, async (notificationType) => { + const req = createMockRequest({ NotificationType: notificationType }); + const res = createMockResponse(); + + await sendNotification(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: `No subscription exists for notification type '${notificationType}'`, + }); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/controller/sendNotificationDestination.property.test.ts b/local-ai-sandbox/test/controller/sendNotificationDestination.property.test.ts new file mode 100644 index 000000000..9b1594ea3 --- /dev/null +++ b/local-ai-sandbox/test/controller/sendNotificationDestination.property.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; +import { Request, Response } from "express"; + +// Mock the database Context before importing the controller +vi.mock("../../src/database/Context.js", () => { + const mockEngine = { + find: vi.fn(), + }; + return { + Api: { NOTIFICATIONS: "notifications" }, + Context: { + instance: { engine: mockEngine }, + }, + }; +}); + +// Mock the SQS client to prevent real AWS calls +vi.mock("@aws-sdk/client-sqs", () => ({ + SQSClient: vi.fn(), + SendMessageCommand: vi.fn(), +})); + +import { sendNotification } from "../../src/controller/notificationsManagementController.js"; +import { Context } from "../../src/database/Context.js"; + +const mockFind = Context.instance.engine.find as unknown as ReturnType; + +function createMockResponse(): Response & { status: ReturnType; json: ReturnType } { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response & { status: ReturnType; json: ReturnType }; + return res; +} + +function createMockRequest(body: Record): Request { + return { body } as unknown as Request; +} + +/** Arbitrary for notification type strings (UPPER_SNAKE_CASE) */ +const arbNotificationType = fc + .array(fc.constantFrom("ORDER", "CHANGE", "REPORT", "ITEM", "LISTING", "FULFILLMENT", "INVENTORY", "PRICING", "STATUS"), { minLength: 1, maxLength: 3 }) + .map((words) => words.join("_")); + +/** Arbitrary for destination IDs (UUID-like strings) */ +const arbDestinationId = fc.uuid(); + +/** Arbitrary for a destination record that does NOT have an sqs resource */ +const arbInvalidDestination = fc.oneof( + // Destination with empty resource object + arbDestinationId.map((id) => ({ + _key: id, + _type: "destination" as const, + destinationId: id, + name: "test-destination", + resource: {}, + })), + // Destination with eventBridge resource (no sqs) + arbDestinationId.map((id) => ({ + _key: id, + _type: "destination" as const, + destinationId: id, + name: "test-destination", + resource: { eventBridge: { accountId: "123456789012", region: "us-east-1" } }, + })), + // Destination with undefined resource + arbDestinationId.map((id) => ({ + _key: id, + _type: "destination" as const, + destinationId: id, + name: "test-destination", + resource: undefined, + })), +); + +describe("sendNotification Property Tests — Missing/Invalid SQS Destination", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /** + * Property 9: Missing or invalid SQS destination returns 400 + * **Validates: Requirements 8.6** + * + * For any notification payload where the subscription's destinationId references a destination + * that does not exist in the database, the send endpoint shall return a 400 status code with + * an appropriate error message. + */ + it("Property 9a: Non-existent destination returns 400", async () => { + await fc.assert( + fc.asyncProperty(arbNotificationType, arbDestinationId, async (notificationType, destinationId) => { + vi.clearAllMocks(); + + // Mock find: return a subscription for the subscription query, empty array for destination query + mockFind.mockImplementation((_api: string, query: Record) => { + if (query._type === "subscription") { + return [ + { + _key: "sub-1", + _type: "subscription", + subscriptionId: "sub-1", + notificationType, + payloadVersion: "1.0", + destinationId, + }, + ]; + } + if (query._type === "destination") { + // No destination found + return []; + } + return []; + }); + + const req = createMockRequest({ + NotificationType: notificationType, + NotificationVersion: "1.0", + PayloadVersion: "1.0", + EventTime: "2024-01-01T00:00:00Z", + Payload: {}, + }); + const res = createMockResponse(); + + await sendNotification(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: "No valid SQS destination configured for this subscription", + }); + }), + { numRuns: 100 }, + ); + }); + + /** + * Property 9b: Destination without sqs resource returns 400 + * **Validates: Requirements 8.6** + * + * For any notification payload where the subscription's destinationId references a destination + * that exists but lacks an sqs resource specification, the send endpoint shall return a 400 + * status code with an appropriate error message. + */ + it("Property 9b: Destination without sqs resource returns 400", async () => { + await fc.assert( + fc.asyncProperty(arbNotificationType, arbInvalidDestination, async (notificationType, invalidDestination) => { + vi.clearAllMocks(); + + const destinationId = invalidDestination.destinationId; + + // Mock find: return subscription for subscription query, invalid destination for destination query + mockFind.mockImplementation((_api: string, query: Record) => { + if (query._type === "subscription") { + return [ + { + _key: "sub-1", + _type: "subscription", + subscriptionId: "sub-1", + notificationType, + payloadVersion: "1.0", + destinationId, + }, + ]; + } + if (query._type === "destination") { + return [invalidDestination]; + } + return []; + }); + + const req = createMockRequest({ + NotificationType: notificationType, + NotificationVersion: "1.0", + PayloadVersion: "1.0", + EventTime: "2024-01-01T00:00:00Z", + Payload: {}, + }); + const res = createMockResponse(); + + await sendNotification(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: "No valid SQS destination configured for this subscription", + }); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/controller/spapiController.validation.test.ts b/local-ai-sandbox/test/controller/spapiController.validation.test.ts new file mode 100644 index 000000000..94cfe2263 --- /dev/null +++ b/local-ai-sandbox/test/controller/spapiController.validation.test.ts @@ -0,0 +1,519 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Request, Response } from "express"; +import { readFileSync } from "fs"; +import { resolve } from "path"; + +// Mock dependencies before importing the controller +vi.mock("../../src/service/validationEngine.js", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("../../src/agent-definition/agenticResponseRegistry.js", () => ({})); + +vi.mock("../../src/registry/operationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + const handlers = new Map(); + const agenticKeys = new Set(); + return { + ...original, + buildAgenticResponsePrompt: vi.fn().mockReturnValue("mocked prompt"), + OPERATIONS_REGISTRY: { + get: (key: string) => handlers.get(key), + register: (apiName: string, apiVersion: string, operationId: string, handler: unknown, requiresAgentic = false) => { + const key = `${apiName}:${apiVersion}:${operationId}`; + handlers.set(key, handler); + if (requiresAgentic) agenticKeys.add(key); + }, + requiresAgenticResponse: (key: string) => agenticKeys.has(key), + isAllowedInCurrentMode: (_key: string) => true, + clear: () => { + handlers.clear(); + agenticKeys.clear(); + }, + set: (key: string, handler: unknown) => handlers.set(key, handler), + setAgentic: (key: string) => agenticKeys.add(key), + get size() { + return handlers.size; + }, + }, + }; +}); + +vi.mock("../../src/index.js", () => ({ + asyncLocalStorage: { + run: vi.fn((_store: unknown, fn: () => Promise) => fn()), + }, +})); + +vi.mock("../../src/modelProvider.js", () => ({ + model: {}, +})); + +const mockAgentInvoke = vi.fn(); +vi.mock("@strands-agents/sdk", () => { + return { + Agent: class MockAgent { + invoke = mockAgentInvoke; + }, + }; +}); + +vi.mock("../../src/util.js", () => ({ + printMetricsAndTraces: vi.fn(), +})); + +import { createResponse } from "../../src/controller/spapiController.js"; +import { validateRequest } from "../../src/service/validationEngine.js"; +import { OPERATIONS_REGISTRY } from "../../src/registry/operationRegistry.js"; +import { identifyApiName, identifyApiVersion } from "../../src/service/apiSchemaIdentificationService.js"; + +function createMockRequest(overrides: Partial = {}): Request { + return { + method: "GET", + path: "/orders/v0/orders/123-456", + url: "/orders/v0/orders/123-456", + originalUrl: "/orders/v0/orders/123-456", + body: { someField: "value" }, + query: { status: "active" }, + headers: {}, + header: vi.fn().mockReturnValue("mock-token"), + ...overrides, + } as unknown as Request; +} + +function createMockResponse(): Response { + const res: Partial = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + res.send = vi.fn().mockReturnValue(res); + return res as Response; +} + +describe("spapiController validation integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + (OPERATIONS_REGISTRY as unknown as { clear: () => void }).clear(); + + }); + + it("calls validateRequest (unified entry point) with the Express request", async () => { + const mockValidateRequest = vi.mocked(validateRequest); + + mockValidateRequest.mockResolvedValue({ + pass: true, + operationId: "testOp", + apiName: "Orders", + apiVersion: "v0", + pathParams: { orderId: "123" }, + queryParams: { status: "active" }, + body: undefined, + resolvedEntities: {}, + operation: { operationId: "testOp" }, + }); + + // Set up operation handler + const mockOperationHandler = vi.fn().mockResolvedValue({ + statusCode: 200, + operationId: "testOp", + apiName: "Orders", + apiVersion: "v0", + pathParams: { orderId: "123" }, + queryParams: { status: "active" }, + body: undefined, + operation: { operationId: "testOp" }, + resolvedEntities: {}, + data: {}, + }); + (OPERATIONS_REGISTRY as unknown as { set: (k: string, v: unknown) => void }).set("Orders:v0:testOp", mockOperationHandler); + + // Set up agent definition + (OPERATIONS_REGISTRY as unknown as { setAgentic: (k: string) => void }).setAgentic("Orders:v0:testOp"); + + mockAgentInvoke.mockResolvedValue({ + structuredOutput: { statusCode: 200, body: JSON.stringify({ message: "ok" }) }, + }); + + const req = createMockRequest(); + const res = createMockResponse(); + + await createResponse(req, res); + + expect(mockValidateRequest).toHaveBeenCalledOnce(); + expect(mockValidateRequest).toHaveBeenCalledWith(req); + }); + + it("returns error without agent invocation when validation fails", async () => { + const mockValidateRequest = vi.mocked(validateRequest); + + mockValidateRequest.mockResolvedValue({ + pass: false, + statusCode: 404, + body: { errors: [{ code: "NotFound", message: "Order not found" }] }, + }); + + const req = createMockRequest(); + const res = createMockResponse(); + + await createResponse(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + errors: [{ code: "NotFound", message: "Order not found" }], + }); + // Agent invoke should NOT have been called + expect(mockAgentInvoke).not.toHaveBeenCalled(); + }); + + it("proceeds to operation handler when validation passes", async () => { + const mockValidateRequest = vi.mocked(validateRequest); + + mockValidateRequest.mockResolvedValue({ + pass: true, + operationId: "testOp", + apiName: "Orders", + apiVersion: "v0", + pathParams: { orderId: "123" }, + queryParams: {}, + body: undefined, + resolvedEntities: {}, + operation: { operationId: "testOp" }, + }); + + const mockOperationHandler = vi.fn().mockResolvedValue({ + statusCode: 200, + operationId: "testOp", + apiName: "Orders", + apiVersion: "v0", + pathParams: { orderId: "123" }, + queryParams: {}, + body: undefined, + operation: { operationId: "testOp" }, + resolvedEntities: {}, + data: { body: { result: "ok" } }, + }); + (OPERATIONS_REGISTRY as unknown as { set: (k: string, v: unknown) => void }).set("Orders:v0:testOp", mockOperationHandler); + + const req = createMockRequest(); + const res = createMockResponse(); + + await createResponse(req, res); + + // Operation handler should have been called + expect(mockOperationHandler).toHaveBeenCalledWith(expect.objectContaining({ operationId: "testOp" }), req); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ result: "ok" }); + }); + + it("passes operationId, pathParams, queryParams, and resolvedEntities from unified result to operation handler", async () => { + const mockValidateRequest = vi.mocked(validateRequest); + + const validationResult = { + pass: true as const, + operationId: "getOrder", + apiName: "Orders", + apiVersion: "v0", + pathParams: { orderId: "ORD-789" }, + queryParams: { marketplace: "US" }, + body: { customField: "bodyValue" }, + resolvedEntities: { order: { orderId: "ORD-789", status: "Shipped" } }, + operation: { operationId: "getOrder", responses: { "200": {} } }, + }; + + mockValidateRequest.mockResolvedValue(validationResult); + + const mockOperationHandler = vi.fn().mockResolvedValue({ + statusCode: 200, + operationId: "getOrder", + apiName: "Orders", + apiVersion: "v0", + pathParams: { orderId: "ORD-789" }, + queryParams: { marketplace: "US" }, + body: { customField: "bodyValue" }, + operation: { operationId: "getOrder", responses: { "200": {} } }, + resolvedEntities: { order: { orderId: "ORD-789", status: "Shipped" } }, + data: { body: { orderId: "ORD-789" } }, + }); + (OPERATIONS_REGISTRY as unknown as { set: (k: string, v: unknown) => void }).set("Orders:v0:getOrder", mockOperationHandler); + + const req = createMockRequest({ + method: "GET", + path: "/orders/v0/orders/ORD-789", + url: "/orders/v0/orders/ORD-789", + originalUrl: "/orders/v0/orders/ORD-789", + body: { customField: "bodyValue" }, + }); + const res = createMockResponse(); + + await createResponse(req, res); + + // Verify operation handler received the validation result and express request + expect(mockOperationHandler).toHaveBeenCalledWith(validationResult, req); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ orderId: "ORD-789" }); + }); + + it("returns 404 with no body when unified result has no body", async () => { + const mockValidateRequest = vi.mocked(validateRequest); + + mockValidateRequest.mockResolvedValue({ + pass: false, + statusCode: 404, + }); + + const req = createMockRequest(); + const res = createMockResponse(); + + await createResponse(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).not.toHaveBeenCalled(); + expect(res.send).toHaveBeenCalled(); + }); + + it("returns 400 with error details when schema validation fails", async () => { + const mockValidateRequest = vi.mocked(validateRequest); + + mockValidateRequest.mockResolvedValue({ + pass: false, + statusCode: 400, + body: { errors: [{ code: "SchemaValidationError", message: "Invalid request: missing required parameter" }] }, + }); + + const req = createMockRequest(); + const res = createMockResponse(); + + await createResponse(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ errors: [{ code: "SchemaValidationError", message: "Invalid request: missing required parameter" }] }); + expect(mockAgentInvoke).not.toHaveBeenCalled(); + }); + + it("returns pipeline rule failure without invoking agent", async () => { + const mockValidateRequest = vi.mocked(validateRequest); + + mockValidateRequest.mockResolvedValue({ + pass: false, + statusCode: 400, + body: { errors: [{ code: "MutualExclusivity", message: "Parameters are mutually exclusive" }] }, + }); + + const req = createMockRequest(); + const res = createMockResponse(); + + await createResponse(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + errors: [{ code: "MutualExclusivity", message: "Parameters are mutually exclusive" }], + }); + expect(mockAgentInvoke).not.toHaveBeenCalled(); + }); + + it("returns pipeline rule failure with 404 without invoking agent", async () => { + const mockValidateRequest = vi.mocked(validateRequest); + + mockValidateRequest.mockResolvedValue({ + pass: false, + statusCode: 404, + body: { errors: [{ code: "NotFound", message: "Order with id 'ORD-999' not found" }] }, + }); + + const req = createMockRequest(); + const res = createMockResponse(); + + await createResponse(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + errors: [{ code: "NotFound", message: "Order with id 'ORD-999' not found" }], + }); + expect(mockAgentInvoke).not.toHaveBeenCalled(); + }); + + it("passes the full operation object from unified result to operation handler (not just { operationId })", async () => { + const mockValidateRequest = vi.mocked(validateRequest); + + // A rich operation object with response schemas, parameters, and metadata + const fullOperationObject = { + operationId: "getOrder", + summary: "Returns the order that you specify", + description: "Returns the order that you specify, including order items.", + tags: ["orders"], + parameters: [ + { + name: "orderId", + in: "path", + required: true, + schema: { type: "string" }, + description: "An Amazon-defined order identifier.", + }, + { + name: "marketplaceIds", + in: "query", + required: false, + schema: { type: "array", items: { type: "string" } }, + description: "A list of MarketplaceId values.", + }, + ], + responses: { + "200": { + description: "Success.", + content: { + "application/json": { + schema: { + type: "object", + properties: { + payload: { + type: "object", + properties: { + AmazonOrderId: { type: "string" }, + OrderStatus: { type: "string", enum: ["Pending", "Unshipped", "Shipped", "Canceled"] }, + OrderTotal: { + type: "object", + properties: { + CurrencyCode: { type: "string" }, + Amount: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "404": { + description: "The resource specified does not exist.", + content: { + "application/json": { + schema: { + type: "object", + properties: { + errors: { type: "array", items: { type: "object", properties: { code: { type: "string" }, message: { type: "string" } } } }, + }, + }, + }, + }, + }, + }, + }; + + const validationResult = { + pass: true as const, + operationId: "getOrder", + apiName: "Orders", + apiVersion: "v0", + pathParams: { orderId: "ORDER-555" }, + queryParams: { marketplaceIds: ["ATVPDKIKX0DER"] }, + body: undefined, + resolvedEntities: { order: { orderId: "ORDER-555", status: "Shipped" } }, + operation: fullOperationObject, + }; + + mockValidateRequest.mockResolvedValue(validationResult); + + const mockOperationHandler = vi.fn().mockResolvedValue({ + statusCode: 200, + operationId: "getOrder", + apiName: "Orders", + apiVersion: "v0", + pathParams: { orderId: "ORDER-555" }, + queryParams: { marketplaceIds: ["ATVPDKIKX0DER"] }, + body: undefined, + operation: fullOperationObject, + resolvedEntities: { order: { orderId: "ORDER-555", status: "Shipped" } }, + data: { body: { orderId: "ORDER-555" } }, + }); + (OPERATIONS_REGISTRY as unknown as { set: (k: string, v: unknown) => void }).set("Orders:v0:getOrder", mockOperationHandler); + + const req = createMockRequest({ method: "GET", path: "/orders/v0/orders/ORDER-555" }); + const res = createMockResponse(); + + await createResponse(req, res); + + // Verify operation handler was called with the full validation result + expect(mockOperationHandler).toHaveBeenCalledOnce(); + const [handlerValidationResult] = mockOperationHandler.mock.calls[0]; + + // The operation field must be the full operation object, not a simple wrapper + expect(handlerValidationResult.operation).toBe(fullOperationObject); + expect(handlerValidationResult.operation).toEqual(fullOperationObject); + + // Verify it contains response schemas + expect(handlerValidationResult.operation.responses).toBeDefined(); + expect(handlerValidationResult.operation.responses["200"]).toBeDefined(); + expect(handlerValidationResult.operation.responses["200"].content["application/json"].schema.properties.payload).toBeDefined(); + + // Verify it contains parameter definitions + expect(handlerValidationResult.operation.parameters).toBeDefined(); + expect(handlerValidationResult.operation.parameters).toHaveLength(2); + expect(handlerValidationResult.operation.parameters[0].name).toBe("orderId"); + expect(handlerValidationResult.operation.parameters[1].name).toBe("marketplaceIds"); + + // Verify the operationId is part of the operation object itself + expect(handlerValidationResult.operation.operationId).toBe("getOrder"); + + // Verify other context fields are still passed correctly alongside operation + expect(handlerValidationResult.pathParams).toEqual({ orderId: "ORDER-555" }); + expect(handlerValidationResult.queryParams).toEqual({ marketplaceIds: ["ATVPDKIKX0DER"] }); + expect(handlerValidationResult.resolvedEntities).toEqual({ order: { orderId: "ORDER-555", status: "Shipped" } }); + + // Verify correct response + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ orderId: "ORDER-555" }); + }); + + it("does not import or call requestValidationService.validateRequest or identifyApiName/identifyApiVersion from apiSchemaIdentificationService", () => { + const controllerSource = readFileSync(resolve(__dirname, "../../src/controller/spapiController.ts"), "utf-8"); + + // Controller should NOT import from requestValidationService + expect(controllerSource).not.toContain("requestValidationService"); + + // Controller should NOT directly import identifyApiName or identifyApiVersion from apiSchemaIdentificationService + expect(controllerSource).not.toContain("identifyApiName"); + expect(controllerSource).not.toContain("identifyApiVersion"); + expect(controllerSource).not.toContain("apiSchemaIdentificationService"); + + // Controller SHOULD import validateRequest from validationEngine + expect(controllerSource).toContain('import { validateRequest } from "../service/validationEngine.js"'); + }); +}); + +describe("identifyApiVersion model filename parsing", () => { + it('parses ordersV0.json model to version "v0"', () => { + expect(identifyApiVersion("/orders/v0/orders/123")).toBe("v0"); + }); + + it('parses orders_2026-01-01.json model to version "2026-01-01"', () => { + expect(identifyApiVersion("/orders/2026-01-01/orders/456")).toBe("2026-01-01"); + }); + + it('parses catalogItems_2022-04-01.json model to version "2022-04-01"', () => { + expect(identifyApiVersion("/catalog/2022-04-01/items")).toBe("2022-04-01"); + }); + + it('parses listingsItems_2021-08-01.json model to version "2021-08-01"', () => { + expect(identifyApiVersion("/listings/2021-08-01/items/SKU1")).toBe("2021-08-01"); + }); + + it('parses fbaInventory_v1.json model to version "v1"', () => { + expect(identifyApiVersion("/fba/inventory/v1/items/SKU1")).toBe("v1"); + }); + + it('parses externalFulfillmentShipments_2024-09-11.json model to version "2024-09-11"', () => { + expect(identifyApiVersion("/externalFulfillment/2024-09-11/shipments/SHIP1")).toBe("2024-09-11"); + }); + + it('parses externalFulfillmentReturns_2024-09-11.json model to version "2024-09-11"', () => { + expect(identifyApiVersion("/externalFulfillment/2024-09-11/returns/RET1")).toBe("2024-09-11"); + }); + + it('parses externalFulfillmentInventory_2024-09-11.json model to version "2024-09-11"', () => { + expect(identifyApiVersion("/externalFulfillment/inventory/2024-09-11/inventories")).toBe("2024-09-11"); + }); + + it("returns undefined for unknown paths with no matching model", () => { + expect(identifyApiVersion("/unknown/path")).toBeUndefined(); + }); +}); diff --git a/local-ai-sandbox/test/database/Context.test.ts b/local-ai-sandbox/test/database/Context.test.ts new file mode 100644 index 000000000..127cb5c4f --- /dev/null +++ b/local-ai-sandbox/test/database/Context.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Context, Api } from "../../src/database/Context.js"; + +describe("Context", () => { + beforeEach(() => { + Context.reset(); + }); + + describe("singleton instantiation", () => { + it("returns the same instance on multiple accesses", () => { + const first = Context.instance; + const second = Context.instance; + + expect(first).toBe(second); + }); + }); + + describe("defaults to in-memory mode", () => { + it("engine is accessible", () => { + const ctx = Context.instance; + expect(ctx.engine).toBeDefined(); + }); + }); + + describe("all Api domain collections are initialized", () => { + it.each(Object.values(Api))("collection for '%s' is accessible and initially empty", (domain) => { + const ctx = Context.instance; + const collection = ctx.engine.getCollection(domain); + + expect(collection).not.toBeNull(); + expect(collection!.count()).toBe(0); + }); + }); + + describe("clear()", () => { + it("empties all collections after data has been inserted", () => { + const ctx = Context.instance; + + ctx.engine.put(Api.LISTINGS, "SKU-1", { title: "Test Listing" }); + ctx.engine.put(Api.ORDERS, "ORD-1", { status: "pending" }); + ctx.engine.put(Api.CATALOG, "ASIN-1", { name: "Product" }); + + expect(ctx.engine.getCollection(Api.LISTINGS)!.count()).toBe(1); + expect(ctx.engine.getCollection(Api.ORDERS)!.count()).toBe(1); + expect(ctx.engine.getCollection(Api.CATALOG)!.count()).toBe(1); + + ctx.clear(); + + for (const domain of Object.values(Api)) { + expect(ctx.engine.getCollection(domain)!.count()).toBe(0); + } + }); + }); + + describe("reset()", () => { + it("creates a fresh instance with a different object reference", () => { + const original = Context.instance; + Context.reset(); + const fresh = Context.instance; + + expect(fresh).not.toBe(original); + }); + + it("fresh instance does not retain data from the previous instance", () => { + const ctx = Context.instance; + ctx.engine.put(Api.INVENTORY, "INV-1", { quantity: 100 }); + + Context.reset(); + + const freshCtx = Context.instance; + const result = freshCtx.engine.get(Api.INVENTORY, "INV-1"); + expect(result).toBeNull(); + }); + }); + + describe("engine.put/get through Context singleton", () => { + it("stores and retrieves a document", () => { + const ctx = Context.instance; + const doc = { asin: "B0F4X2K9LM", title: "Widget", price: 19.99 }; + + ctx.engine.put(Api.CATALOG, "B0F4X2K9LM", doc); + + const result = ctx.engine.get(Api.CATALOG, "B0F4X2K9LM"); + expect(result).toEqual({ _key: "B0F4X2K9LM", asin: "B0F4X2K9LM", title: "Widget", price: 19.99 }); + }); + + it("returns null for a key that does not exist", () => { + const ctx = Context.instance; + const result = ctx.engine.get(Api.ORDERS, "non-existent"); + expect(result).toBeNull(); + }); + }); + + +}); diff --git a/local-ai-sandbox/test/database/DatabaseEngine.prop.test.ts b/local-ai-sandbox/test/database/DatabaseEngine.prop.test.ts new file mode 100644 index 000000000..d9dfbdce6 --- /dev/null +++ b/local-ai-sandbox/test/database/DatabaseEngine.prop.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import fc from "fast-check"; +import { DatabaseEngine } from "../../src/database/DatabaseEngine.js"; +import { InvalidDomainError } from "../../src/database/types.js"; +import { Api } from "../../src/database/Context.js"; + +const arbKey = fc.string({ minLength: 1, maxLength: 50 }).filter((s) => s.trim().length > 0); +const arbDocument = fc.dictionary( + fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s !== "_key" && s !== "$loki" && s !== "meta" && s !== "__proto__"), + fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null)), +); +const arbDomain = fc.constantFrom(...Object.values(Api)); +const arbInvalidDomain = fc.string({ minLength: 1 }).filter((s) => !Object.values(Api).includes(s as Api)); + +describe("DatabaseEngine Property-Based Tests", () => { + let engine: DatabaseEngine; + + beforeEach(() => { + engine = new DatabaseEngine({ mode: "memory" }); + }); + + it("Property 1: Insert/Get Round-Trip", () => { + fc.assert( + fc.property(arbDomain, arbKey, arbDocument, (domain, key, document) => { + engine.put(domain, key, document); + const retrieved = engine.get(domain, key); + expect(retrieved).not.toBeNull(); + const expected = { ...document, _key: key }; + expect(retrieved).toEqual(expected); + }), + { numRuns: 100 }, + ); + }); + + it("Property 2: Get Non-Existent Key Returns Null", () => { + fc.assert( + fc.property(arbDomain, arbKey, (domain, key) => { + const result = engine.get(domain, key); + expect(result).toBeNull(); + }), + { numRuns: 100 }, + ); + }); + + it("Property 3: Remove Makes Document Unretrievable", async () => { + await fc.assert( + fc.asyncProperty(arbDomain, arbKey, arbDocument, async (domain, key, document) => { + engine.put(domain, key, document); + await engine.remove(domain, key); + const result = engine.get(domain, key); + expect(result).toBeNull(); + }), + { numRuns: 100 }, + ); + }); + + it("Property 4: Remove Non-Existent Key Succeeds", async () => { + await fc.assert( + fc.asyncProperty(arbDomain, arbKey, async (domain, key) => { + const result = await engine.remove(domain, key); + expect(result).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + it("Property 5: Batch Get Correctness", async () => { + await fc.assert( + fc.asyncProperty(arbDomain, fc.array(arbKey, { minLength: 1, maxLength: 10 }), arbDocument, async (domain, keys, document) => { + // Insert documents for even-indexed keys only + const insertedKeys = keys.filter((_, i) => i % 2 === 0); + for (const key of insertedKeys) { + engine.put(domain, key, document); + } + + const batchResult = engine.getBatch(domain, keys); + + for (const key of keys) { + if (insertedKeys.includes(key)) { + expect(batchResult.get(key)).toEqual({ ...document, _key: key }); + } else { + expect(batchResult.get(key)).toBeNull(); + } + } + + // Clean up to avoid state leakage between iterations + for (const key of insertedKeys) { + await engine.remove(domain, key); + } + }), + { numRuns: 100 }, + ); + }); + + it("Property 6: Collection Isolation", async () => { + const domains = Object.values(Api); + await fc.assert( + fc.asyncProperty(arbKey, arbDocument, async (key, document) => { + // Pick two distinct domains + const domainA = domains[0]; + const domainB = domains[1]; + + engine.put(domainA, key, document); + + // The other domain should not have this key + const resultB = engine.get(domainB, key); + expect(resultB).toBeNull(); + + // Clean up for next iteration + await engine.remove(domainA, key); + }), + { numRuns: 100 }, + ); + }); + + it("Property 7: Invalid Domain Returns Error", async () => { + await fc.assert( + fc.asyncProperty(arbInvalidDomain, arbKey, arbDocument, async (domain, key, document) => { + expect(() => engine.put(domain as Api, key, document)).toThrow(InvalidDomainError); + expect(() => engine.get(domain as Api, key)).toThrow(InvalidDomainError); + await expect(engine.remove(domain as Api, key)).rejects.toThrow(InvalidDomainError); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/database/DatabaseEngine.test.ts b/local-ai-sandbox/test/database/DatabaseEngine.test.ts new file mode 100644 index 000000000..5d7ef6e8a --- /dev/null +++ b/local-ai-sandbox/test/database/DatabaseEngine.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { DatabaseEngine } from "../../src/database/DatabaseEngine.js"; +import { InvalidDomainError, InvalidKeyError } from "../../src/database/types.js"; +import { Api } from "../../src/database/Context.js"; + +describe("DatabaseEngine", () => { + let engine: DatabaseEngine; + const allDomains = Object.values(Api); + + beforeEach(() => { + engine = new DatabaseEngine({ mode: "memory" }); + }); + + describe("initialization", () => { + it("creates empty collections for all domains", () => { + for (const domain of allDomains) { + const collection = engine.getCollection(domain); + expect(collection).not.toBeNull(); + expect(collection!.count()).toBe(0); + } + }); + }); + + describe("put and get", () => { + it("round-trips a document with concrete data", () => { + const doc = { title: "Widget", price: 9.99, tags: ["sale", "new"] }; + engine.put(Api.LISTINGS, "SKU-001", doc); + + const result = engine.get(Api.LISTINGS, "SKU-001"); + expect(result).toEqual({ _key: "SKU-001", title: "Widget", price: 9.99, tags: ["sale", "new"] }); + }); + + it("returns null for a missing key", () => { + const result = engine.get(Api.ORDERS, "non-existent-key"); + expect(result).toBeNull(); + }); + + it("overwrites existing document on upsert", () => { + engine.put(Api.CATALOG, "ASIN-1", { name: "Original", color: "red" }); + engine.put(Api.CATALOG, "ASIN-1", { name: "Updated", size: "large" }); + + const result = engine.get(Api.CATALOG, "ASIN-1"); + expect(result).toEqual({ _key: "ASIN-1", name: "Updated", size: "large" }); + expect(result).not.toHaveProperty("color"); + }); + }); + + describe("remove", () => { + it("deletes an existing document and returns true", async () => { + engine.put(Api.INVENTORY, "INV-100", { quantity: 50 }); + const result = await engine.remove(Api.INVENTORY, "INV-100"); + + expect(result).toBe(true); + expect(engine.get(Api.INVENTORY, "INV-100")).toBeNull(); + }); + + it("returns true for a missing key without throwing", async () => { + const result = await engine.remove(Api.PRICING, "does-not-exist"); + expect(result).toBe(true); + }); + }); + + describe("getBatch", () => { + it("returns a map with existing and missing keys", () => { + engine.put(Api.ORDERS, "ORD-1", { status: "shipped" }); + engine.put(Api.ORDERS, "ORD-3", { status: "pending" }); + + const result = engine.getBatch(Api.ORDERS, ["ORD-1", "ORD-2", "ORD-3"]); + + expect(result).toBeInstanceOf(Map); + expect(result.get("ORD-1")).toEqual({ _key: "ORD-1", status: "shipped" }); + expect(result.get("ORD-2")).toBeNull(); + expect(result.get("ORD-3")).toEqual({ _key: "ORD-3", status: "pending" }); + }); + }); + + describe("error handling", () => { + it("throws InvalidDomainError for an unknown domain on put", () => { + expect(() => engine.put("unknownDomain" as Api, "key-1", { data: true })).toThrow(InvalidDomainError); + }); + + it("throws InvalidDomainError for an unknown domain on get", () => { + expect(() => engine.get("unknownDomain" as Api, "key-1")).toThrow(InvalidDomainError); + }); + + it("throws InvalidDomainError for an unknown domain on remove", async () => { + await expect(engine.remove("unknownDomain" as Api, "key-1")).rejects.toThrow(InvalidDomainError); + }); + + it("throws InvalidDomainError for an unknown domain on getBatch", () => { + expect(() => engine.getBatch("unknownDomain" as Api, ["key-1"])).toThrow(InvalidDomainError); + }); + + it("throws InvalidKeyError for null key", () => { + expect(() => engine.put(Api.LISTINGS, null as unknown as string, { data: true })).toThrow(InvalidKeyError); + }); + + it("throws InvalidKeyError for empty string key", () => { + expect(() => engine.put(Api.LISTINGS, "", { data: true })).toThrow(InvalidKeyError); + }); + }); + + describe("collection isolation", () => { + it("operations on one domain do not affect another", async () => { + engine.put(Api.LISTINGS, "SHARED-KEY", { source: "listings" }); + engine.put(Api.ORDERS, "SHARED-KEY", { source: "orders" }); + + await engine.remove(Api.LISTINGS, "SHARED-KEY"); + + expect(engine.get(Api.LISTINGS, "SHARED-KEY")).toBeNull(); + expect(engine.get(Api.ORDERS, "SHARED-KEY")).toEqual({ _key: "SHARED-KEY", source: "orders" }); + }); + }); + + describe("clear", () => { + it("empties all collections", () => { + engine.put(Api.LISTINGS, "L1", { name: "listing" }); + engine.put(Api.ORDERS, "O1", { name: "order" }); + engine.put(Api.CATALOG, "C1", { name: "catalog" }); + + engine.clear(); + + for (const domain of allDomains) { + const collection = engine.getCollection(domain); + expect(collection!.count()).toBe(0); + } + }); + }); + + describe("getCollection", () => { + it("returns null for an unknown domain", () => { + const result = engine.getCollection("nonExistentDomain" as Api); + expect(result).toBeNull(); + }); + }); +}); diff --git a/local-ai-sandbox/test/database/triggerEmission.test.ts b/local-ai-sandbox/test/database/triggerEmission.test.ts new file mode 100644 index 000000000..5fcfdf332 --- /dev/null +++ b/local-ai-sandbox/test/database/triggerEmission.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Context, Api } from "../../src/database/Context.js"; +import { TriggerProcessor } from "../../src/trigger/TriggerProcessor.js"; +import { listingKey } from "../../src/operation/listingsItemModel.js"; + +/** + * The database layer emits data events for every write — insert, update and + * delete alike — and runs triggers detached, so a write returns before they + * finish. `silent` is the single opt-out, used by fixture seeding and by + * handlers writing their own output. + * + * Observed through the orders trigger, which reduces the ordered SKU's MFN + * ledger when a PENDING merchant-fulfilled order is written. + */ +describe("DatabaseEngine trigger emission", () => { + /** Yields to the event loop so detached trigger processing completes. */ + const flushTriggers = () => new Promise((resolve) => setImmediate(resolve)); + + beforeEach(() => { + Context.reset(); + }); + + // Drain any still-queued trigger work against the context that emitted it, + // so detached processing never lands on the next test's database. + afterEach(flushTriggers); + + const ORDER = { + orderId: "ORD-1", + fulfillment: { fulfillmentStatus: "PENDING", fulfilledBy: "MERCHANT" }, + orderItems: [{ product: { sellerSku: "SKU-1" }, quantityOrdered: 3 }], + }; + + const SELLER = "SELLER1"; + + function seedListing(): void { + Context.instance.engine.put( + Api.LISTINGS, + listingKey(SELLER, "SKU-1"), + { + sku: "SKU-1", + sellerId: SELLER, + productType: "PRODUCT", + attributes: {}, + issues: [], + mfnAvailability: [{ fulfillmentChannelCode: "DEFAULT", quantity: 10 }], + }, + { silent: true }, + ); + } + + function mfnQuantity(): number | undefined { + const ledger = Context.instance.engine.get(Api.LISTINGS, listingKey(SELLER, "SKU-1"))?.mfnAvailability as { quantity?: number }[] | undefined; + return ledger?.[0].quantity; + } + + it("returns from the write before triggers have run", () => { + seedListing(); + Context.instance.engine.put(Api.ORDERS, "ORD-1", { ...ORDER }); + // Nothing deducted yet: processing is detached, like production's + // asynchronous downstream handling of an order. + expect(mfnQuantity()).toBe(10); + }); + + it("fires triggers on insert", async () => { + seedListing(); + Context.instance.engine.put(Api.ORDERS, "ORD-1", { ...ORDER }); + await flushTriggers(); + expect(mfnQuantity()).toBe(7); + }); + + it("emits INSERT, UPDATE and DELETE with the same detached semantics", async () => { + const emit = vi.spyOn(TriggerProcessor, "emit").mockResolvedValue(); + + Context.instance.engine.put(Api.ORDERS, "ORD-1", { ...ORDER }); + Context.instance.engine.put(Api.ORDERS, "ORD-1", { ...ORDER, purchaseDate: "2026-02-02T00:00:00.000Z" }); + await Context.instance.engine.remove(Api.ORDERS, "ORD-1"); + + // Every write returned before anything was emitted. + expect(emit).not.toHaveBeenCalled(); + + await flushTriggers(); + expect(emit.mock.calls.map((call) => call[0])).toEqual(["INSERT", "UPDATE", "DELETE"]); + + emit.mockRestore(); + }); + + it("does not fire triggers for a silent write", async () => { + seedListing(); + Context.instance.engine.put(Api.ORDERS, "ORD-1", { ...ORDER }, { silent: true }); + await flushTriggers(); + expect(mfnQuantity()).toBe(10); + }); + + it("does not fire triggers for a silent remove", async () => { + Context.instance.engine.put(Api.ORDERS, "ORD-1", { orderId: "ORD-1" }, { silent: true }); + await expect(Context.instance.engine.remove(Api.ORDERS, "ORD-1", { silent: true })).resolves.toBe(true); + expect(Context.instance.engine.get(Api.ORDERS, "ORD-1")).toBeNull(); + }); + + it("settles instead of cascading when a handler writes another document", async () => { + seedListing(); + // The orders handler writes the listing silently, so no further event is + // emitted and processing terminates after one round. + Context.instance.engine.put(Api.ORDERS, "ORD-1", { ...ORDER }); + await flushTriggers(); + await flushTriggers(); + expect(mfnQuantity()).toBe(7); + }); +}); diff --git a/local-ai-sandbox/test/frontend/notificationValidation.property.test.ts b/local-ai-sandbox/test/frontend/notificationValidation.property.test.ts new file mode 100644 index 000000000..14ddd6cab --- /dev/null +++ b/local-ai-sandbox/test/frontend/notificationValidation.property.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect } from "vitest"; +import fc from "fast-check"; + +/** + * Property 5: Validation rejects forms with empty required fields + * **Validates: Requirements 5.1, 5.2** + * + * For any rendered form where one or more required fields (as defined in the schema's + * required arrays) have empty values, the validation SHALL fail, prevent submission, + * and display an inline error message next to each empty required field. + * + * This test reimplements the core validation logic from public/app.js as pure functions + * to verify the algorithmic correctness of required-field detection and validation. + */ + +// ========== Reimplemented core logic from public/app.js ========== + +interface PathSegment { + name?: string; + isIndex?: boolean; + index?: number; +} + +interface JsonSchema { + type?: string | string[]; + properties?: Record; + required?: string[]; + items?: JsonSchema; + enum?: string[]; + examples?: unknown[]; +} + +/** + * Parses a data-path string into segments. + * Mirrors parseDataPath from app.js. + */ +function parseDataPath(path: string): PathSegment[] { + const segments: PathSegment[] = []; + const parts = path.split("."); + + for (const part of parts) { + const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/); + if (arrayMatch) { + segments.push({ name: arrayMatch[1] }); + segments.push({ isIndex: true, index: parseInt(arrayMatch[2], 10) }); + } else { + segments.push({ name: part }); + } + } + + return segments; +} + +/** + * Resolves the effective type from a JSON Schema property. + * Mirrors resolveSchemaType from app.js. + */ +function resolveSchemaType(propSchema: JsonSchema | undefined): string { + if (!propSchema || !propSchema.type) return "string"; + if (Array.isArray(propSchema.type)) { + return propSchema.type.find((t) => t !== "null") || "string"; + } + return propSchema.type; +} + +/** + * Determines if a field is required based on the schema's required arrays at each nesting level. + * Mirrors isFieldRequired from app.js. + */ +function isFieldRequired(schema: JsonSchema, path: string): boolean { + const segments = parseDataPath(path); + let currentSchema: JsonSchema = schema; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + if (segment.isIndex) { + if (currentSchema && resolveSchemaType(currentSchema) === "array" && currentSchema.items) { + currentSchema = currentSchema.items; + } else { + return false; + } + continue; + } + + if (i === segments.length - 1) { + const requiredList = Array.isArray(currentSchema.required) ? currentSchema.required : []; + return requiredList.includes(segment.name!); + } + + if (currentSchema.properties && currentSchema.properties[segment.name!]) { + const propSchema = currentSchema.properties[segment.name!]; + const type = resolveSchemaType(propSchema); + if (type === "object") { + currentSchema = propSchema; + } else if (type === "array" && propSchema.items) { + currentSchema = propSchema; + } else { + return false; + } + } else { + return false; + } + } + + return false; +} + +/** + * Simulates the validation logic from validateForm in app.js. + * Given a schema and a map of field paths to their values, + * returns the set of field paths that have validation errors. + */ +function validateFormFields(schema: JsonSchema, fieldValues: Map): Set { + const errors = new Set(); + + for (const [path, value] of fieldValues) { + const required = isFieldRequired(schema, path); + if (required) { + const trimmed = value.trim(); + if (!trimmed) { + errors.add(path); + } + } + } + + return errors; +} + +// ========== Arbitraries ========== + +/** Generate a valid property name (simple camelCase identifier) */ +const arbPropertyName = fc + .tuple( + fc.constantFrom(..."abcdefghijklmnopqrstuvwxyz".split("")), + fc.array(fc.constantFrom(..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".split("")), { minLength: 1, maxLength: 8 }), + ) + .map(([first, rest]) => first + rest.join("")); + +/** Generate a schema with 1-5 string properties and a varying required subset */ +const arbFlatSchema = fc + .array(arbPropertyName, { minLength: 1, maxLength: 5 }) + .chain((propNames) => { + // Ensure unique property names + const uniqueNames = [...new Set(propNames)]; + if (uniqueNames.length === 0) return fc.constant({ schema: { type: "object", properties: {}, required: [] } as JsonSchema, allFields: [] as string[] }); + + // Generate a subset to mark as required (at least 1 required) + return fc.subarray(uniqueNames, { minLength: 1 }).map((requiredFields) => { + const properties: Record = {}; + for (const name of uniqueNames) { + properties[name] = { type: "string" }; + } + const schema: JsonSchema = { + type: "object", + properties, + required: requiredFields, + }; + return { schema, allFields: uniqueNames }; + }); + }); + +/** Generate a schema with nested object containing required fields */ +const arbNestedSchema = fc + .tuple( + arbPropertyName, // parent property name + fc.array(arbPropertyName, { minLength: 1, maxLength: 4 }), // child property names + ) + .chain(([parentName, childNames]) => { + const uniqueChildren = [...new Set(childNames)]; + if (uniqueChildren.length === 0) { + return fc.constant({ + schema: { type: "object", properties: { [parentName]: { type: "object", properties: {}, required: [] } }, required: [parentName] } as JsonSchema, + nestedFields: [] as string[], + nestedRequired: [] as string[], + parentName, + }); + } + + return fc.subarray(uniqueChildren, { minLength: 1 }).map((requiredChildren) => { + const childProperties: Record = {}; + for (const name of uniqueChildren) { + childProperties[name] = { type: "string" }; + } + const schema: JsonSchema = { + type: "object", + properties: { + [parentName]: { + type: "object", + properties: childProperties, + required: requiredChildren, + }, + }, + required: [parentName], + }; + return { + schema, + nestedFields: uniqueChildren.map((child) => `${parentName}.${child}`), + nestedRequired: requiredChildren.map((child) => `${parentName}.${child}`), + parentName, + }; + }); + }); + +/** + * Given a list of fields and a subset that must be empty, generates a fieldValues map + * where the empty-subset fields have empty/whitespace values and others have non-empty values. + */ +function arbFieldValues(allFields: string[], emptyFields: string[]) { + return fc.tuple(...allFields.map((field) => (emptyFields.includes(field) ? fc.constantFrom("", " ", "\t") : fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0)))).map((values) => { + const map = new Map(); + allFields.forEach((field, i) => { + map.set(field, values[i]); + }); + return map; + }); +} + +// ========== Property Tests ========== + +describe("Notification Validation Property Tests", () => { + describe("Property 5: Validation rejects forms with empty required fields", () => { + it("Property 5a: Validation identifies all empty required fields in a flat schema", () => { + fc.assert( + fc.property( + arbFlatSchema.chain(({ schema, allFields }) => { + const requiredFields = schema.required || []; + // Pick at least 1 required field to be empty + return fc.subarray(requiredFields, { minLength: 1 }).chain((emptyRequired) => { + return arbFieldValues(allFields, emptyRequired).map((fieldValues) => ({ + schema, + allFields, + emptyRequired, + fieldValues, + })); + }); + }), + ({ schema, emptyRequired, fieldValues }) => { + const errors = validateFormFields(schema, fieldValues); + + // All empty required fields must be identified as errors + for (const field of emptyRequired) { + expect(errors.has(field), `Expected error for empty required field "${field}"`).toBe(true); + } + + // Validation must fail (at least one error) + expect(errors.size).toBeGreaterThan(0); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 5b: Validation does not flag non-empty required fields as errors", () => { + fc.assert( + fc.property( + arbFlatSchema.chain(({ schema, allFields }) => { + const requiredFields = schema.required || []; + // Pick a subset to be empty (could be empty subset — all filled) + return fc.subarray(requiredFields).chain((emptyRequired) => { + return arbFieldValues(allFields, emptyRequired).map((fieldValues) => ({ + schema, + allFields, + requiredFields, + emptyRequired, + fieldValues, + })); + }); + }), + ({ schema, requiredFields, emptyRequired, fieldValues }) => { + const errors = validateFormFields(schema, fieldValues); + + // Required fields that are NOT empty should NOT have errors + const filledRequired = requiredFields.filter((f) => !emptyRequired.includes(f)); + for (const field of filledRequired) { + expect(errors.has(field), `Field "${field}" is filled but was flagged as error`).toBe(false); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 5c: Validation does not flag non-required empty fields as errors", () => { + fc.assert( + fc.property( + arbFlatSchema.chain(({ schema, allFields }) => { + const requiredFields = schema.required || []; + const nonRequired = allFields.filter((f) => !requiredFields.includes(f)); + // Make all non-required fields empty, and fill all required fields + return arbFieldValues(allFields, nonRequired).map((fieldValues) => ({ + schema, + nonRequired, + fieldValues, + })); + }), + ({ schema, nonRequired, fieldValues }) => { + const errors = validateFormFields(schema, fieldValues); + + // Non-required fields should never produce required-field errors + for (const field of nonRequired) { + expect(errors.has(field), `Non-required field "${field}" should not be flagged`).toBe(false); + } + + // With all required fields filled, validation should pass + expect(errors.size).toBe(0); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 5d: Validation correctly identifies empty required fields in nested schemas", () => { + fc.assert( + fc.property( + arbNestedSchema.chain(({ schema, nestedFields, nestedRequired }) => { + // Pick at least 1 nested required field to be empty + return fc.subarray(nestedRequired, { minLength: 1 }).chain((emptyRequired) => { + return arbFieldValues(nestedFields, emptyRequired).map((fieldValues) => ({ + schema, + nestedFields, + nestedRequired, + emptyRequired, + fieldValues, + })); + }); + }), + ({ schema, emptyRequired, fieldValues }) => { + const errors = validateFormFields(schema, fieldValues); + + // All empty required nested fields must be identified as errors + for (const field of emptyRequired) { + expect(errors.has(field), `Expected error for empty required nested field "${field}"`).toBe(true); + } + + // Validation must fail + expect(errors.size).toBeGreaterThan(0); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 5e: The set of error fields equals exactly the set of empty required fields", () => { + fc.assert( + fc.property( + arbFlatSchema.chain(({ schema, allFields }) => { + const requiredFields = schema.required || []; + // Pick a random subset of required fields to leave empty + return fc.subarray(requiredFields, { minLength: 1 }).chain((emptyRequired) => { + return arbFieldValues(allFields, emptyRequired).map((fieldValues) => ({ + schema, + emptyRequired, + fieldValues, + })); + }); + }), + ({ schema, emptyRequired, fieldValues }) => { + const errors = validateFormFields(schema, fieldValues); + + // The errors set should be EXACTLY the empty required fields + const expectedErrors = new Set(emptyRequired); + expect(errors).toEqual(expectedErrors); + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/local-ai-sandbox/test/frontend/numericValidation.property.test.ts b/local-ai-sandbox/test/frontend/numericValidation.property.test.ts new file mode 100644 index 000000000..03f89ccff --- /dev/null +++ b/local-ai-sandbox/test/frontend/numericValidation.property.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import fc from "fast-check"; + +/** + * Mirrors the numeric validation logic from public/app.js: + * + * if (inputType === "number") { + * const strValue = input.value.trim(); + * if (strValue !== "") { + * const num = Number(strValue); + * if (isNaN(num)) { + * // Show error: "Must be a valid number" + * isValid = false; + * } + * } + * } + * + * Returns an error message if the value is non-numeric for a number-type field, + * or null if the value is valid. + */ +function validateNumericField(value: string): string | null { + const strValue = value.trim(); + if (strValue !== "") { + const num = Number(strValue); + if (isNaN(num)) { + return "Must be a valid number"; + } + } + return null; +} + +/** + * Arbitrary that generates non-numeric strings that are non-empty after trimming + * and produce NaN when passed to Number(). + */ +const arbNonNumericString = fc + .string({ minLength: 1, maxLength: 50 }) + .filter((s) => s.trim() !== "" && isNaN(Number(s.trim()))); + +/** + * Arbitrary that generates valid numeric strings (integers, decimals, negative, scientific notation). + */ +const arbValidNumericString = fc.oneof( + fc.integer().map(String), + fc.float({ noNaN: true, noDefaultInfinity: true }).map(String), + fc.integer().map((n) => `-${Math.abs(n)}`), + fc + .tuple(fc.float({ min: Math.fround(1), max: Math.fround(9.5), noNaN: true, noDefaultInfinity: true }), fc.integer({ min: -10, max: 10 })) + .map(([base, exp]) => `${base}e${exp}`), +); + +describe("Numeric Field Validation Property Tests", () => { + /** + * Property 6: Numeric field validation rejects non-numeric input + * **Validates: Requirements 5.5** + * + * For any form field backed by a schema property of type "integer" or "number", + * if the entered value is not a valid numeric string, validation SHALL fail + * and display an error for that field. + */ + + it("Property 6a: Non-numeric strings are rejected with an error", () => { + fc.assert( + fc.property(arbNonNumericString, (value) => { + const error = validateNumericField(value); + expect(error).toBe("Must be a valid number"); + }), + { + numRuns: 100, + examples: [["12abc"], ["abc"], ["1.2.3"], ["e5"], [" abc "]], + }, + ); + }); + + it("Property 6b: Valid numeric strings are accepted without error", () => { + fc.assert( + fc.property(arbValidNumericString, (value) => { + const error = validateNumericField(value); + expect(error).toBeNull(); + }), + { + numRuns: 100, + examples: [["0"], ["42"], ["-7"], ["3.14"], ["1e10"], ["1.5e-3"]], + }, + ); + }); + + it("Property 6c: Empty and whitespace-only strings pass validation (no error)", () => { + fc.assert( + fc.property( + fc.constantFrom("", " ", " ", "\t", "\n", " \t "), + (value) => { + const error = validateNumericField(value); + expect(error).toBeNull(); + }, + ), + { numRuns: 20 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/operation/catalogItemsOperations.prop.test.ts b/local-ai-sandbox/test/operation/catalogItemsOperations.prop.test.ts new file mode 100644 index 000000000..5b10a8acf --- /dev/null +++ b/local-ai-sandbox/test/operation/catalogItemsOperations.prop.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import fc from "fast-check"; +import { filterCatalogItem, INCLUDED_DATA_CATEGORIES } from "../../src/operation/catalogItemsOperations.js"; + +// --- Generators --- + +/** Arbitrary subset of valid includedData categories */ +const arbIncludedData = fc.subarray([...INCLUDED_DATA_CATEGORIES], { minLength: 0 }); + +/** Arbitrary catalog item with random categories populated — always includes asin */ +const arbCatalogItem = fc.record( + { + asin: fc.string({ minLength: 1, maxLength: 20 }), + summaries: fc.array(fc.record({ itemName: fc.string(), brand: fc.string() }), { minLength: 1, maxLength: 3 }), + attributes: fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fc.string()), + classifications: fc.array(fc.record({ classificationId: fc.string() }), { minLength: 1, maxLength: 3 }), + dimensions: fc.array(fc.record({ height: fc.integer() }), { minLength: 1, maxLength: 2 }), + identifiers: fc.array(fc.record({ identifierType: fc.string(), identifier: fc.string() }), { minLength: 1, maxLength: 3 }), + images: fc.array(fc.record({ link: fc.webUrl() }), { minLength: 1, maxLength: 3 }), + productTypes: fc.array(fc.record({ productType: fc.string() }), { minLength: 1, maxLength: 2 }), + relationships: fc.array(fc.record({ type: fc.string() }), { minLength: 1, maxLength: 2 }), + salesRanks: fc.array(fc.record({ rank: fc.integer() }), { minLength: 1, maxLength: 2 }), + vendorDetails: fc.array(fc.record({ vendorCode: fc.string() }), { minLength: 1, maxLength: 2 }), + }, + { requiredKeys: ["asin"] }, +); + +describe("filterCatalogItem Property-Based Tests", () => { + // Feature: catalog-items-api, Property 1: includedData filtering preserves asin and only requested categories + it("Property 1: includedData filtering preserves asin and only requested categories", () => { + /** + * Validates: Requirements 3.2, 3.4, 6.1, 6.2, 6.3 + * + * For any stored catalog item with arbitrary data categories populated, and for any + * non-empty subset of valid includedData values, applying the filter function SHALL + * produce an object that contains the asin field and exactly the requested data + * categories (if present on the item), with no other top-level keys. + */ + fc.assert( + fc.property(arbCatalogItem, arbIncludedData, (item, includedData) => { + const result = filterCatalogItem(item, includedData); + + // asin is always present (generator guarantees asin exists on item) + expect(result).toHaveProperty("asin", item.asin); + + // Compute expected keys: asin + each requested category that exists on the item + const expectedKeys = new Set(["asin"]); + for (const cat of includedData) { + if (cat in item) expectedKeys.add(cat); + } + + // Result must have exactly the expected keys + expect(new Set(Object.keys(result))).toEqual(expectedKeys); + + // Each included category value must match the original + for (const category of includedData) { + const presentOnItem = category in item; + const presentOnResult = category in result; + expect(presentOnResult).toBe(presentOnItem); + } + }), + { numRuns: 100 }, + ); + }); + + // Feature: catalog-items-api, Property 2: Missing categories are silently omitted + it("Property 2: Missing categories are silently omitted", () => { + /** + * Validates: Requirements 6.4 + * + * For any stored catalog item that is missing one or more data categories, and for any + * includedData value referencing a missing category, the filter function SHALL return + * successfully (no error thrown) and the missing category SHALL not appear in the output. + */ + fc.assert( + fc.property(arbCatalogItem, arbIncludedData, (item, includedData) => { + // No error should be thrown + const result = filterCatalogItem(item, includedData); + + // Identify categories that are in includedData but NOT on the item + const missingCategories = includedData.filter((cat) => !(cat in item)); + + // None of the missing categories should appear in the result + for (const category of missingCategories) { + expect(result).not.toHaveProperty(category); + } + + // Result should still have asin + expect(result).toHaveProperty("asin"); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/operation/catalogItemsOperations.test.ts b/local-ai-sandbox/test/operation/catalogItemsOperations.test.ts new file mode 100644 index 000000000..18e61136c --- /dev/null +++ b/local-ai-sandbox/test/operation/catalogItemsOperations.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect } from "vitest"; +import { filterCatalogItem, INCLUDED_DATA_CATEGORIES, paginate } from "../../src/operation/catalogItemsOperations.js"; +import { encodePageToken, decodePageToken } from "../../src/service/Paginator.js"; + +describe("filterCatalogItem", () => { + const fullItem: Record = { + asin: "B08N5WRWNW", + summaries: [{ itemName: "Test Product", brand: "TestBrand" }], + attributes: { color: "blue" }, + classifications: [{ classificationId: "123" }], + dimensions: [{ height: { value: 10, unit: "inches" } }], + identifiers: [{ identifiers: [{ identifierType: "EAN", identifier: "12345" }] }], + images: [{ images: [{ link: "https://example.com/img.jpg" }] }], + productTypes: [{ productType: "SHOES" }], + relationships: [{ type: "VARIATION" }], + salesRanks: [{ classificationId: "123", rank: 5 }], + vendorDetails: [{ vendorCode: "ABC" }], + }; + + it("always includes asin in the output", () => { + const result = filterCatalogItem(fullItem, []); + expect(result).toHaveProperty("asin", "B08N5WRWNW"); + }); + + it("includes only requested data categories plus asin", () => { + const result = filterCatalogItem(fullItem, ["summaries"]); + expect(result).toEqual({ + asin: "B08N5WRWNW", + summaries: [{ itemName: "Test Product", brand: "TestBrand" }], + }); + }); + + it("includes multiple requested categories", () => { + const result = filterCatalogItem(fullItem, ["summaries", "images", "dimensions"]); + expect(Object.keys(result).sort()).toEqual(["asin", "dimensions", "images", "summaries"]); + }); + + it("silently skips categories not present on the item", () => { + const partialItem = { asin: "B000000001", summaries: [{ itemName: "Partial" }] }; + const result = filterCatalogItem(partialItem, ["summaries", "vendorDetails", "classifications"]); + expect(result).toEqual({ + asin: "B000000001", + summaries: [{ itemName: "Partial" }], + }); + }); + + it("omits keys not in includedData (except asin)", () => { + const result = filterCatalogItem(fullItem, ["images"]); + expect(result).toEqual({ + asin: "B08N5WRWNW", + images: [{ images: [{ link: "https://example.com/img.jpg" }] }], + }); + expect(result).not.toHaveProperty("summaries"); + expect(result).not.toHaveProperty("attributes"); + }); + + it("returns only asin when includedData is empty", () => { + const result = filterCatalogItem(fullItem, []); + expect(result).toEqual({ asin: "B08N5WRWNW" }); + }); + + it("handles item with no asin field gracefully", () => { + const noAsinItem = { summaries: [{ itemName: "No ASIN" }] }; + const result = filterCatalogItem(noAsinItem, ["summaries"]); + expect(result).toEqual({ summaries: [{ itemName: "No ASIN" }] }); + expect(result).not.toHaveProperty("asin"); + }); + + it("exports INCLUDED_DATA_CATEGORIES with all 10 valid categories", () => { + expect(INCLUDED_DATA_CATEGORIES).toHaveLength(10); + expect(INCLUDED_DATA_CATEGORIES).toContain("summaries"); + expect(INCLUDED_DATA_CATEGORIES).toContain("attributes"); + expect(INCLUDED_DATA_CATEGORIES).toContain("classifications"); + expect(INCLUDED_DATA_CATEGORIES).toContain("dimensions"); + expect(INCLUDED_DATA_CATEGORIES).toContain("identifiers"); + expect(INCLUDED_DATA_CATEGORIES).toContain("images"); + expect(INCLUDED_DATA_CATEGORIES).toContain("productTypes"); + expect(INCLUDED_DATA_CATEGORIES).toContain("relationships"); + expect(INCLUDED_DATA_CATEGORIES).toContain("salesRanks"); + expect(INCLUDED_DATA_CATEGORIES).toContain("vendorDetails"); + }); +}); + +describe("encodePageToken", () => { + it("encodes an offset into a base64 JSON string", () => { + const token = encodePageToken(10); + const decoded = JSON.parse(Buffer.from(token, "base64").toString("utf8")); + expect(decoded).toEqual({ offset: 10 }); + }); + + it("encodes offset 0", () => { + const token = encodePageToken(0); + const decoded = JSON.parse(Buffer.from(token, "base64").toString("utf8")); + expect(decoded).toEqual({ offset: 0 }); + }); +}); + +describe("decodePageToken", () => { + it("decodes a valid token to the offset number", () => { + const token = Buffer.from(JSON.stringify({ offset: 15 })).toString("base64"); + expect(decodePageToken(token)).toBe(15); + }); + + it("returns null for invalid base64", () => { + expect(decodePageToken("not-valid-base64!!!")).toBeNull(); + }); + + it("returns null for valid base64 but invalid JSON", () => { + const token = Buffer.from("not json").toString("base64"); + expect(decodePageToken(token)).toBeNull(); + }); + + it("returns null when offset is missing from parsed object", () => { + const token = Buffer.from(JSON.stringify({ foo: 5 })).toString("base64"); + expect(decodePageToken(token)).toBeNull(); + }); + + it("returns null when offset is negative", () => { + const token = Buffer.from(JSON.stringify({ offset: -1 })).toString("base64"); + expect(decodePageToken(token)).toBeNull(); + }); + + it("returns null when offset is not a number", () => { + const token = Buffer.from(JSON.stringify({ offset: "abc" })).toString("base64"); + expect(decodePageToken(token)).toBeNull(); + }); + + it("roundtrips with encodePageToken", () => { + expect(decodePageToken(encodePageToken(42))).toBe(42); + }); +}); + +describe("paginate", () => { + const items = Array.from({ length: 25 }, (_, i) => ({ id: i })); + + it("returns the first page with default pageSize of 10", () => { + const result = paginate(items, 0); + expect(result.page).toHaveLength(10); + expect(result.numberOfResults).toBe(25); + expect(result.nextToken).toBeDefined(); + expect(result.previousToken).toBeUndefined(); + }); + + it("caps pageSize at 20", () => { + const result = paginate(items, 50); + expect(result.page).toHaveLength(20); + }); + + it("respects pageSize within bounds", () => { + const result = paginate(items, 5); + expect(result.page).toHaveLength(5); + expect(result.page).toEqual(items.slice(0, 5)); + }); + + it("includes nextToken when more items exist", () => { + const result = paginate(items, 10); + expect(result.nextToken).toBeDefined(); + const nextOffset = decodePageToken(result.nextToken!); + expect(nextOffset).toBe(10); + }); + + it("does not include nextToken when on last page", () => { + const token = encodePageToken(20); + const result = paginate(items, 10, token); + expect(result.page).toHaveLength(5); + expect(result.nextToken).toBeUndefined(); + }); + + it("includes previousToken when offset > 0", () => { + const token = encodePageToken(10); + const result = paginate(items, 10, token); + expect(result.previousToken).toBeDefined(); + const prevOffset = decodePageToken(result.previousToken!); + expect(prevOffset).toBe(0); + }); + + it("does not include previousToken on first page", () => { + const result = paginate(items, 10); + expect(result.previousToken).toBeUndefined(); + }); + + it("returns empty page for invalid pageToken", () => { + const result = paginate(items, 10, "invalid-token"); + expect(result.page).toEqual([]); + expect(result.numberOfResults).toBe(25); + }); + + it("returns empty page when offset >= items length", () => { + const token = encodePageToken(100); + const result = paginate(items, 10, token); + expect(result.page).toEqual([]); + expect(result.numberOfResults).toBe(25); + }); + + it("handles empty items array", () => { + const result = paginate([], 10); + expect(result.page).toEqual([]); + expect(result.numberOfResults).toBe(0); + expect(result.nextToken).toBeUndefined(); + expect(result.previousToken).toBeUndefined(); + }); +}); diff --git a/local-ai-sandbox/test/operation/dataKioskOperations.integration.test.ts b/local-ai-sandbox/test/operation/dataKioskOperations.integration.test.ts new file mode 100644 index 000000000..93fa3cc76 --- /dev/null +++ b/local-ai-sandbox/test/operation/dataKioskOperations.integration.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Request, Response } from "express"; +import { executeValidation } from "../../src/service/validationEngine.js"; +import { RequestContext, ValidationFail } from "../../src/validation/validationTypes.js"; +import { OPERATIONS_REGISTRY } from "../../src/registry/operationRegistry.js"; + +// Seeded entities for the mocked engine.get (keyed by id, api-agnostic for these tests). +const seeded = new Map>(); + +vi.mock("../../src/database/Context.js", () => ({ + Api: { DATA_KIOSK: "dataKiosk" }, + Context: { + get instance() { + return { + engine: { + get: (_api: string, key: string) => seeded.get(key) ?? null, + find: () => [...seeded.values()], + put: (_api: string, key: string, value: Record) => seeded.set(key, { ...value, _key: key }), + getCollection: () => ({ find: () => [...seeded.values()] }), + }, + }; + }, + }, +})); + +// Stub index.js so importing spapiController does not execute the app's top-level route setup. +vi.mock("../../src/index.js", () => ({ + asyncLocalStorage: { run: async (_s: unknown, f: () => unknown) => f() }, +})); + +const { downloadDataKioskDocument } = await import("../../src/controller/spapiController.js"); + +const DATASET = "analytics_salesAndTraffic_2024_04_24"; +function sellerQuery(startDate = "2023-01-01", endDate = "2023-01-03"): string { + return `{${DATASET}{salesAndTrafficByDate(startDate:"${startDate}" endDate:"${endDate}" aggregateBy:DAY marketplaceIds:["ATVPDKIKX0DER"]){sales{orderedProductSales{amount currencyCode}}}}}`; +} + +function ctx(overrides: Partial): RequestContext { + return { + apiName: "Data Kiosk", + apiVersion: "2023-11-15", + operationId: "getQuery", + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + ...overrides, + }; +} + +describe("Data Kiosk integration — validation pipeline", () => { + it("getQuery with an unknown queryId is rejected with 404 before the handler", async () => { + seeded.clear(); + const result = await executeValidation(ctx({ operationId: "getQuery", pathParams: { queryId: "DK-UNKNOWN" } })); + expect(result.pass).toBe(false); + expect((result as ValidationFail).statusCode).toBe(404); + }); + + it("cancelQuery on a DONE query is rejected with 400", async () => { + seeded.clear(); + seeded.set("DK-DONE", { _key: "DK-DONE", queryId: "DK-DONE", query: "{x}", processingStatus: "DONE", createdTime: "2024-01-01T00:00:00Z" }); + const result = await executeValidation(ctx({ operationId: "cancelQuery", method: "DELETE", pathParams: { queryId: "DK-DONE" } })); + expect(result.pass).toBe(false); + expect((result as ValidationFail).statusCode).toBe(400); + }); + + it("cancelQuery on an IN_QUEUE query passes validation", async () => { + seeded.clear(); + seeded.set("DK-Q", { _key: "DK-Q", queryId: "DK-Q", query: "{x}", processingStatus: "IN_QUEUE", createdTime: "2024-01-01T00:00:00Z" }); + expect((await executeValidation(ctx({ operationId: "cancelQuery", method: "DELETE", pathParams: { queryId: "DK-Q" } }))).pass).toBe(true); + }); + + it("getDocument for an unknown documentId is rejected with 404", async () => { + seeded.clear(); + expect((await executeValidation(ctx({ operationId: "getDocument", pathParams: { documentId: "DKDOC-UNKNOWN" } }))).pass).toBe(false); + }); + + it("getQueries with createdSince after createdUntil is rejected with 400", async () => { + seeded.clear(); + const result = await executeValidation(ctx({ operationId: "getQueries", queryParams: { createdSince: "2024-05-01T00:00:00Z", createdUntil: "2024-01-01T00:00:00Z" } })); + expect(result.pass).toBe(false); + expect((result as ValidationFail).statusCode).toBe(400); + }); + + // Level B: 8000-char rule (stringLengthLimit) in the createQuery pipeline. + it("createQuery passes the length rule for a normal query", async () => { + seeded.clear(); + expect((await executeValidation(ctx({ operationId: "createQuery", method: "POST", body: { query: sellerQuery() } }))).pass).toBe(true); + }); + + it("createQuery rejects a query longer than 8000 chars (after whitespace normalization) with 400", async () => { + seeded.clear(); + const longQuery = `{${DATASET}{salesAndTrafficByDate(${"x".repeat(8100)})}}`; + const result = await executeValidation(ctx({ operationId: "createQuery", method: "POST", body: { query: longQuery } })); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + }); + + it("createQuery length rule ignores insignificant whitespace (padded query under the cap passes)", async () => { + seeded.clear(); + // Real content is short; padding is whitespace which normalizes away. + const padded = `{${DATASET}{salesAndTrafficByDate(startDate:"2023-01-01" endDate:"2023-01-02")}}` + " ".repeat(9000); + expect((await executeValidation(ctx({ operationId: "createQuery", method: "POST", body: { query: padded } }))).pass).toBe(true); + }); +}); + +describe("Data Kiosk integration — end-to-end submit/poll/download", () => { + const createQuery = OPERATIONS_REGISTRY.get("Data Kiosk:2023-11-15:createQuery")!; + const getQuery = OPERATIONS_REGISTRY.get("Data Kiosk:2023-11-15:getQuery")!; + const getDocument = OPERATIONS_REGISTRY.get("Data Kiosk:2023-11-15:getDocument")!; + + function vr(overrides: Record): never { + return { pass: true, apiName: "Data Kiosk", apiVersion: "2023-11-15", queryParams: {}, pathParams: {}, body: undefined, resolvedEntities: {}, operation: {}, ...overrides } as never; + } + function req(body?: Record): Request { + return { body, get: () => "localhost:9001" } as unknown as Request; + } + /** Submit then poll until terminal using the real handlers + the seeded store as the DB. */ + async function drive(query: string): Promise> { + const created = await createQuery(vr({ operationId: "createQuery" }), req({ query })); + const queryId = (created.data.body as Record).queryId as string; + let body: Record = {}; + for (let i = 0; i < 5; i++) { + body = (await getQuery(vr({ operationId: "getQuery", pathParams: { queryId }, resolvedEntities: { query: seeded.get(queryId)! } }), req())).data.body as Record; + if (["DONE", "FATAL", "CANCELLED"].includes(body.processingStatus as string)) break; + } + return body; + } + + beforeEach(() => { + seeded.clear(); + delete process.env.MODE; + }); + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.MODE; + }); + + it("Seller data query → DONE with a downloadable JSONL document", async () => { + const body = await drive(sellerQuery("2023-01-01", "2023-01-03")); + expect(body.processingStatus).toBe("DONE"); + const docId = body.dataDocumentId as string; + expect(docId).toBeDefined(); + + const docRes = await getDocument(vr({ operationId: "getDocument", pathParams: { documentId: docId } }), req()); + const url = (docRes.data.body as Record).documentUrl as string; + expect(url).toContain(`/dataKiosk/download/${docId}`); + + // Download serves the JSONL (3 days). + let status = 0; + let payload: unknown; + let ctype: string | undefined; + const res = { setHeader: (k: string, v: string) => { if (k === "Content-Type") ctype = v; }, status: (c: number) => { status = c; return res; }, send: (b: unknown) => { payload = b; return res; }, json: (b: unknown) => { payload = b; return res; } }; + await downloadDataKioskDocument({ params: { documentId: docId } } as unknown as Request, res as unknown as Response); + expect(status).toBe(200); + expect(ctype).toBe("application/jsonl"); + expect((payload as string).split("\n")).toHaveLength(3); + }); + + it("FATAL query → FATAL with a downloadable JSON error document", async () => { + const body = await drive(sellerQuery("2023-01-01", "2023-01-02").replace("aggregateBy:DAY", "aggregateBy:DAY FATAL_TEST")); + expect(body.processingStatus).toBe("FATAL"); + const errId = body.errorDocumentId as string; + expect(errId).toBeDefined(); + let ctype: string | undefined; + const res = { setHeader: (k: string, v: string) => { if (k === "Content-Type") ctype = v; }, status: () => res, send: () => res, json: () => res }; + await downloadDataKioskDocument({ params: { documentId: errId } } as unknown as Request, res as unknown as Response); + expect(ctype).toBe("application/json"); + }); + + it("no-data query → DONE with no document ids", async () => { + const body = await drive(sellerQuery("2099-01-01", "2099-01-03")); + expect(body.processingStatus).toBe("DONE"); + expect(body.dataDocumentId).toBeUndefined(); + expect(body.errorDocumentId).toBeUndefined(); + }); + + it("Vendor-only dataset: rejected in Seller mode, accepted in Vendor mode", async () => { + const vendorQuery = `{analytics_vendorSales{vendorSalesByDate(startDate:"2023-01-01" endDate:"2023-01-02" marketplaceIds:["ATVPDKIKX0DER"]){shippedRevenue{amount}}}}`; + + // Seller mode (default) → 400. + delete process.env.MODE; + const sellerRes = await createQuery(vr({ operationId: "createQuery" }), req({ query: vendorQuery })); + expect(sellerRes.statusCode).toBe(400); + + // Vendor mode → 202. + process.env.MODE = "Vendor"; + const vendorRes = await createQuery(vr({ operationId: "createQuery" }), req({ query: vendorQuery })); + expect(vendorRes.statusCode).toBe(202); + }); +}); diff --git a/local-ai-sandbox/test/operation/dataKioskOperations.test.ts b/local-ai-sandbox/test/operation/dataKioskOperations.test.ts new file mode 100644 index 000000000..77ffda340 --- /dev/null +++ b/local-ai-sandbox/test/operation/dataKioskOperations.test.ts @@ -0,0 +1,482 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fc from "fast-check"; +import type { Request } from "express"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; + +// --- Stateful in-memory mock of the DATA_KIOSK namespace --- +const store = new Map>(); + +const mockPut = vi.fn((_api: string, key: string, value: Record) => { + store.set(key, { ...value, _key: key }); +}); +const mockGet = vi.fn((_api: string, key: string) => store.get(key) ?? null); +const mockGetCollection = vi.fn((_api: string) => ({ find: () => [...store.values()] })); + +vi.mock("../../src/database/Context.js", () => ({ + Api: { DATA_KIOSK: "dataKiosk" }, + Context: { + get instance() { + return { engine: { put: mockPut, get: mockGet, getCollection: mockGetCollection } }; + }, + }, +})); + +// Stub index.js so importing spapiController does not execute the app's top-level route setup. +vi.mock("../../src/index.js", () => ({ + asyncLocalStorage: { run: async (_s: unknown, f: () => unknown) => f() }, +})); + +import { + createQueryHandler, + getQueryHandler, + getQueriesHandler, + cancelQueryHandler, + getDocumentHandler, +} from "../../src/operation/dataKioskOperations.js"; +import { downloadDataKioskDocument } from "../../src/controller/spapiController.js"; +import { OPERATIONS_REGISTRY } from "../../src/registry/operationRegistry.js"; +import { parseGraphQLQuery, normalizedLength } from "../../src/operation/dataKioskQueryParser.js"; +import { DATASETS, classifyOutcome, datesInRange, MAX_GENERATED_ROWS } from "../../src/operation/dataKioskDatasets.js"; + +// --- Helpers --- + +const DATASET = "analytics_salesAndTraffic_2024_04_24"; + +/** A valid Seller sales-and-traffic query for a given date range. */ +function sellerQuery(startDate = "2023-01-01", endDate = "2023-01-03", field = "salesAndTrafficByDate"): string { + return `{${DATASET}{${field}(startDate:"${startDate}" endDate:"${endDate}" aggregateBy:DAY marketplaceIds:["ATVPDKIKX0DER"]){sales{orderedProductSales{amount currencyCode}}}}}`; +} + +function makeVR(overrides: Partial = {}): UnifiedValidationPass { + return { + pass: true, + operationId: "createQuery", + apiName: "Data Kiosk", + apiVersion: "2023-11-15", + pathParams: {}, + queryParams: {}, + body: undefined, + resolvedEntities: {}, + operation: {}, + ...overrides, + }; +} + +function makeRequest(opts: { body?: Record; host?: string } = {}): Request { + return { + body: opts.body, + get: (_h: string) => opts.host ?? "localhost:9001", + } as unknown as Request; +} + +function seedQuery(rec: Record): Record { + const full = { _key: rec.queryId as string, ...rec }; + store.set(rec.queryId as string, full); + return full; +} + +/** Submits a query and polls getQuery until terminal (or maxPolls), returning the final stripped body. */ +async function submitAndDrain(query: string, maxPolls = 5): Promise> { + const created = await createQueryHandler(makeVR({ operationId: "createQuery" }), makeRequest({ body: { query } })); + expect(created.statusCode).toBe(202); + const queryId = (created.data.body as Record).queryId as string; + let body: Record = {}; + for (let i = 0; i < maxPolls; i++) { + const stored = store.get(queryId)!; + const res = await getQueryHandler(makeVR({ operationId: "getQuery", pathParams: { queryId }, resolvedEntities: { query: stored } }), makeRequest()); + body = res.data.body as Record; + if (body.processingStatus === "DONE" || body.processingStatus === "FATAL" || body.processingStatus === "CANCELLED") break; + } + return body; +} + +beforeEach(() => { + store.clear(); + vi.clearAllMocks(); +}); + +// --- createQuery (Level B) --- + +describe("createQueryHandler (Level B)", () => { + it("returns 202 with only { queryId } and persists an IN_QUEUE Query_Record (no document yet)", async () => { + const result = await createQueryHandler(makeVR(), makeRequest({ body: { query: sellerQuery() } })); + + expect(result.statusCode).toBe(202); + expect(Object.keys(result.data.body as Record)).toEqual(["queryId"]); + const queryId = (result.data.body as Record).queryId as string; + expect(queryId).toMatch(/^DK-[0-9A-F]{8}$/); + + const stored = store.get(queryId)!; + expect(stored.processingStatus).toBe("IN_QUEUE"); + expect(stored.recordType).toBe("query"); + expect(stored.pollCount).toBe(0); + expect(stored.dataDocumentId).toBeUndefined(); + expect(stored.outcome).toBe("DATA"); + }); + + it("persists paginationToken when provided and still returns only { queryId }", async () => { + const result = await createQueryHandler(makeVR(), makeRequest({ body: { query: sellerQuery(), paginationToken: "TOKEN-1" } })); + expect(Object.keys(result.data.body as Record)).toEqual(["queryId"]); + expect(store.get((result.data.body as Record).queryId as string)!.paginationToken).toBe("TOKEN-1"); + }); + + it("rejects an unparseable query with 400 InvalidInput and stores nothing", async () => { + const result = await createQueryHandler(makeVR(), makeRequest({ body: { query: "not a graphql query" } })); + expect(result.statusCode).toBe(400); + expect((result.data.body as { errors: { code: string }[] }).errors[0].code).toBe("InvalidInput"); + expect(store.size).toBe(0); + }); + + it("rejects an unknown dataset with 400 InvalidInput", async () => { + const result = await createQueryHandler(makeVR(), makeRequest({ body: { query: "{unknown_dataset{foo(startDate:\"2023-01-01\"){bar}}}" } })); + expect(result.statusCode).toBe(400); + expect(store.size).toBe(0); + }); +}); + +// --- getQuery lifecycle (Level B) --- + +describe("getQueryHandler lifecycle (Level B)", () => { + it("advances IN_QUEUE -> IN_PROGRESS -> DONE across polls and materializes a data document", async () => { + const created = await createQueryHandler(makeVR(), makeRequest({ body: { query: sellerQuery("2023-01-01", "2023-01-02") } })); + const queryId = (created.data.body as Record).queryId as string; + + const poll = async () => + (await getQueryHandler(makeVR({ operationId: "getQuery", pathParams: { queryId }, resolvedEntities: { query: store.get(queryId)! } }), makeRequest())).data.body as Record; + + expect((await poll()).processingStatus).toBe("IN_PROGRESS"); + const done = await poll(); + expect(done.processingStatus).toBe("DONE"); + expect(done.dataDocumentId).toBeDefined(); + expect(done.processingStartTime).toBeDefined(); + expect(done.processingEndTime).toBeDefined(); + + // Document exists with two JSONL lines (2 days). + const doc = store.get(done.dataDocumentId as string)!; + expect((doc.content as string).split("\n")).toHaveLength(2); + expect(doc.contentType).toBe("application/jsonl"); + }); + + it("does not include internal fields (_key, pollCount, parsed, outcome, content) in the response", async () => { + const body = await submitAndDrain(sellerQuery("2023-01-01", "2023-01-01")); + for (const k of ["_key", "pollCount", "parsed", "outcome", "content", "contentType"]) { + expect(body).not.toHaveProperty(k); + } + }); + + it("FATAL query drains to FATAL with an errorDocumentId (JSON error document)", async () => { + const body = await submitAndDrain(sellerQuery("2023-01-01", "2023-01-02").replace("aggregateBy:DAY", "aggregateBy:DAY FATAL_TEST")); + expect(body.processingStatus).toBe("FATAL"); + expect(body.errorDocumentId).toBeDefined(); + expect(body.dataDocumentId).toBeUndefined(); + const errDoc = store.get(body.errorDocumentId as string)!; + expect(errDoc.contentType).toBe("application/json"); + expect(() => JSON.parse(errDoc.content as string)).not.toThrow(); + }); + + it("no-data query (future/empty range) drains to DONE with neither document", async () => { + // Range entirely after the reference date -> NO_DATA. + const body = await submitAndDrain(sellerQuery("2099-01-01", "2099-01-03")); + expect(body.processingStatus).toBe("DONE"); + expect(body.dataDocumentId).toBeUndefined(); + expect(body.errorDocumentId).toBeUndefined(); + }); + + it("returns a terminal (seeded) record unchanged and does not advance pollCount", async () => { + const rec = seedQuery({ queryId: "DK-TERM", query: "{x}", processingStatus: "DONE", createdTime: "2024-01-01T00:00:00Z", pollCount: 9, outcome: "DATA", dataDocumentId: "DKDOC-Z" }); + const res = await getQueryHandler(makeVR({ operationId: "getQuery", pathParams: { queryId: "DK-TERM" }, resolvedEntities: { query: rec } }), makeRequest()); + expect((res.data.body as Record).processingStatus).toBe("DONE"); + expect(store.get("DK-TERM")!.pollCount).toBe(9); // unchanged + }); +}); + +// --- getQueries (unchanged filters; does not advance pollCount) --- + +describe("getQueriesHandler (Level B)", () => { + function seedMany(): void { + seedQuery({ queryId: "DK-1", query: "{a}", processingStatus: "IN_QUEUE", createdTime: "2024-01-01T00:00:00Z", pollCount: 0, outcome: "DATA" }); + seedQuery({ queryId: "DK-2", query: "{b}", processingStatus: "IN_PROGRESS", createdTime: "2024-02-01T00:00:00Z", pollCount: 1, outcome: "DATA" }); + seedQuery({ queryId: "DK-3", query: "{c}", processingStatus: "DONE", createdTime: "2024-03-01T00:00:00Z", pollCount: 2, outcome: "DATA" }); + store.set("DKDOC-x", { _key: "DKDOC-x", documentId: "DKDOC-x", content: "line", contentType: "application/jsonl" }); + } + + it("filters by processingStatuses, excludes Document_Records, and strips internal fields", async () => { + seedMany(); + const result = await getQueriesHandler(makeVR({ operationId: "getQueries", queryParams: { processingStatuses: "IN_QUEUE,DONE" } }), makeRequest()); + const queries = (result.data.body as Record).queries as Record[]; + expect(new Set(queries.map((q) => q.processingStatus))).toEqual(new Set(["IN_QUEUE", "DONE"])); + expect(queries.every((q) => q.content === undefined && q.pollCount === undefined && q.outcome === undefined && q._key === undefined)).toBe(true); + }); + + it("does not advance pollCount", async () => { + seedMany(); + await getQueriesHandler(makeVR({ operationId: "getQueries", queryParams: {} }), makeRequest()); + expect(store.get("DK-1")!.pollCount).toBe(0); + expect(store.get("DK-2")!.pollCount).toBe(1); + }); + + it("ignores a malformed createdSince (NaN guard) instead of dropping every record", async () => { + seedMany(); + const result = await getQueriesHandler(makeVR({ operationId: "getQueries", queryParams: { createdSince: "not-a-date" } }), makeRequest()); + const queries = (result.data.body as Record).queries as Record[]; + // All three seeded Query_Records are returned; the bad filter is skipped. + expect(queries).toHaveLength(3); + }); + + it("paginates via the shared Paginator (default 10 + nextToken)", async () => { + for (let i = 0; i < 12; i++) { + seedQuery({ queryId: `DK-${String(i).padStart(2, "0")}`, query: "{x}", processingStatus: "DONE", createdTime: `2024-01-${String(i + 1).padStart(2, "0")}T00:00:00Z`, pollCount: 2, outcome: "DATA" }); + } + const result = await getQueriesHandler(makeVR({ operationId: "getQueries", queryParams: {} }), makeRequest()); + const body = result.data.body as Record; + expect((body.queries as unknown[]).length).toBe(10); + expect((body.pagination as Record).nextToken).toBeDefined(); + }); +}); + +// --- cancelQuery (unchanged from Level A) --- + +describe("cancelQueryHandler", () => { + it("transitions IN_QUEUE to CANCELLED and returns 204", async () => { + const query = seedQuery({ queryId: "DK-Q", query: "{x}", processingStatus: "IN_QUEUE", createdTime: "2024-01-01T00:00:00Z", pollCount: 0, outcome: "DATA" }); + const result = await cancelQueryHandler(makeVR({ operationId: "cancelQuery", pathParams: { queryId: "DK-Q" }, resolvedEntities: { query } }), makeRequest()); + expect(result.statusCode).toBe(204); + expect(store.get("DK-Q")!.processingStatus).toBe("CANCELLED"); + }); + + it("no-ops when already CANCELLED and returns 204", async () => { + const query = seedQuery({ queryId: "DK-C", query: "{x}", processingStatus: "CANCELLED", createdTime: "2024-01-01T00:00:00Z", pollCount: 0, outcome: "DATA" }); + const result = await cancelQueryHandler(makeVR({ operationId: "cancelQuery", pathParams: { queryId: "DK-C" }, resolvedEntities: { query } }), makeRequest()); + expect(result.statusCode).toBe(204); + expect(mockPut).not.toHaveBeenCalled(); + }); +}); + +describe("getDocumentHandler", () => { + it("returns a documentUrl targeting the download route", async () => { + const result = await getDocumentHandler(makeVR({ operationId: "getDocument", pathParams: { documentId: "DKDOC-999" } }), makeRequest({ host: "example.test:1234" })); + expect((result.data.body as Record).documentUrl).toBe("http://example.test:1234/dataKiosk/download/DKDOC-999"); + }); +}); + +describe("registry registration", () => { + it("registers all five Data Kiosk handlers", () => { + for (const op of ["createQuery", "getQueries", "getQuery", "cancelQuery", "getDocument"]) { + expect(typeof OPERATIONS_REGISTRY.get(`Data Kiosk:2023-11-15:${op}`)).toBe("function"); + } + }); +}); + +// --- Parser (B1) --- + +describe("parseGraphQLQuery", () => { + it("extracts dataset, field, dates, aggregateBy, marketplaceIds", () => { + const p = parseGraphQLQuery(sellerQuery("2023-01-01", "2023-01-31"))!; + expect(p.dataset).toBe(DATASET); + expect(p.queryField).toBe("salesAndTrafficByDate"); + expect(p.startDate).toBe("2023-01-01"); + expect(p.endDate).toBe("2023-01-31"); + expect(p.aggregateBy).toBe("DAY"); + expect(p.marketplaceIds).toEqual(["ATVPDKIKX0DER"]); + }); + + it("returns null when there is no top-level dataset selection", () => { + expect(parseGraphQLQuery("")).toBeNull(); + expect(parseGraphQLQuery("no braces here")).toBeNull(); + }); + + // Feature: data-kiosk, Property 11: Parser Field Extraction Round-Trip + // **Validates: Requirements 9.5** + it("Property 11: round-trips dataset/field/date range/marketplaces", () => { + fc.assert( + fc.property( + fc.constantFrom("salesAndTrafficByDate", "salesAndTrafficByAsin"), + fc.integer({ min: 1_577_836_800_000, max: 1_735_603_200_000 }).map((ms) => new Date(ms).toISOString().slice(0, 10)), + fc.integer({ min: 1_577_836_800_000, max: 1_735_603_200_000 }).map((ms) => new Date(ms).toISOString().slice(0, 10)), + fc.uniqueArray(fc.stringMatching(/^[A-Z0-9]{10,14}$/), { minLength: 1, maxLength: 3 }), + (field, startDate, endDate, marketplaceIds) => { + const q = `{${DATASET}{${field}(startDate:"${startDate}" endDate:"${endDate}" aggregateBy:DAY marketplaceIds:[${marketplaceIds.map((m) => `"${m}"`).join(",")}]){x}}}`; + const p = parseGraphQLQuery(q)!; + expect(p.dataset).toBe(DATASET); + expect(p.queryField).toBe(field); + expect(p.startDate).toBe(startDate); + expect(p.endDate).toBe(endDate); + expect(p.marketplaceIds).toEqual(marketplaceIds); + }, + ), + { numRuns: 100 }, + ); + }); + + it("normalizedLength collapses whitespace", () => { + expect(normalizedLength(" a b\t\nc ")).toBe(5); // "a b c" + }); +}); + +// --- Dataset registry (B2) --- + +describe("dataset registry", () => { + it("every dataset sample is valid JSONL (each line parses)", () => { + for (const def of Object.values(DATASETS)) { + for (const line of def.sample.split("\n")) { + expect(() => JSON.parse(line)).not.toThrow(); + } + } + }); + + it("salesAndTrafficByDate generates one line per day in range", () => { + const p = parseGraphQLQuery(sellerQuery("2023-01-01", "2023-01-05"))!; + const jsonl = DATASETS[DATASET].generate(p); + expect(jsonl.split("\n")).toHaveLength(datesInRange("2023-01-01", "2023-01-05").length); + expect(datesInRange("2023-01-01", "2023-01-05")).toHaveLength(5); + }); + + it("classifyOutcome: DATA for a valid range, NO_DATA for future range, FATAL for the marker", () => { + expect(classifyOutcome(parseGraphQLQuery(sellerQuery("2023-01-01", "2023-01-02"))!)).toBe("DATA"); + expect(classifyOutcome(parseGraphQLQuery(sellerQuery("2099-01-01", "2099-01-02"))!)).toBe("NO_DATA"); + expect(classifyOutcome(parseGraphQLQuery(sellerQuery().replace("aggregateBy:DAY", "aggregateBy:DAY FATAL_TEST"))!)).toBe("FATAL"); + }); + + it("the vendor dataset is Vendor-only", () => { + expect(DATASETS.analytics_vendorSales.supportedModes).toEqual(["Vendor"]); + }); + + it("honors aggregateBy WEEK/MONTH by emitting one row per period, not per day", () => { + // 2023-01-01 (Sun) .. 2023-01-15 spans 3 ISO weeks (Mon-based) and 1 month. + const byWeek = parseGraphQLQuery(sellerQuery("2023-01-01", "2023-01-15").replace("aggregateBy:DAY", "aggregateBy:WEEK"))!; + const byMonth = parseGraphQLQuery(sellerQuery("2023-01-01", "2023-01-15").replace("aggregateBy:DAY", "aggregateBy:MONTH"))!; + expect(byWeek.aggregateBy).toBe("WEEK"); + // 15 days -> weeks starting Dec-26, Jan-02, Jan-09 = 3 buckets. + expect(DATASETS[DATASET].generate(byWeek).split("\n")).toHaveLength(3); + // All 15 days fall in 2023-01 -> a single monthly bucket. + const monthRows = DATASETS[DATASET].generate(byMonth).split("\n"); + expect(monthRows).toHaveLength(1); + // The monthly bucket spans the full in-range window. + const row = JSON.parse(monthRows[0]) as { startDate: string; endDate: string }; + expect(row.startDate).toBe("2023-01-01"); + expect(row.endDate).toBe("2023-01-15"); + }); + + it("caps generated rows at MAX_GENERATED_ROWS for a very wide range", () => { + // A multi-year daily range would exceed the cap; datesInRange truncates it. + expect(datesInRange("2020-01-01", "2024-01-31").length).toBe(MAX_GENERATED_ROWS); + }); + + it("salesAndTrafficByAsin emits the reference-capped window, never a future endDate", () => { + // Start is valid, end is beyond DATASET_REFERENCE_DATE (2024-01-31). + const p = parseGraphQLQuery(sellerQuery("2024-01-01", "2099-12-31", "salesAndTrafficByAsin"))!; + const rows = DATASETS[DATASET].generate(p).split("\n").map((l) => JSON.parse(l) as { startDate: string; endDate: string }); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row.startDate).toBe("2024-01-01"); + // Capped at the reference date, not the raw future endDate. + expect(row.endDate).toBe("2024-01-31"); + } + }); +}); + +// --- Properties --- + +// Feature: data-kiosk, Property 8: Lifecycle Monotonic Progression +// **Validates: Requirements 8.1, 8.2, 8.3, 8.4** +describe("Property 8: Lifecycle Monotonic Progression", () => { + const RANK: Record = { IN_QUEUE: 0, IN_PROGRESS: 1, DONE: 2, FATAL: 2 }; + it("status never regresses and reaches terminal within threshold+1 polls", async () => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 6 }), async (days) => { + store.clear(); + const created = await createQueryHandler(makeVR(), makeRequest({ body: { query: sellerQuery("2023-01-01", `2023-01-${String(days).padStart(2, "0")}`) } })); + const queryId = (created.data.body as Record).queryId as string; + let prev = 0; + let terminalAt = -1; + for (let i = 1; i <= 4; i++) { + const res = await getQueryHandler(makeVR({ operationId: "getQuery", pathParams: { queryId }, resolvedEntities: { query: store.get(queryId)! } }), makeRequest()); + const status = (res.data.body as Record).processingStatus as string; + expect(RANK[status]).toBeGreaterThanOrEqual(prev); + prev = RANK[status]; + if ((status === "DONE" || status === "FATAL") && terminalAt < 0) terminalAt = i; + } + expect(terminalAt).toBeGreaterThan(0); + expect(terminalAt).toBeLessThanOrEqual(3); // LIFECYCLE_THRESHOLD (2) + 1 + }), + { numRuns: 50 }, + ); + }); +}); + +// Feature: data-kiosk, Property 9: Outcome Determinism +// **Validates: Requirements 10.2, 10.5** +describe("Property 9: Outcome Determinism", () => { + it("same query yields identical generated content across fresh submissions", async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1_672_531_200_000, max: 1_688_083_200_000 }).map((ms) => new Date(ms).toISOString().slice(0, 10)), + async (startDate) => { + const q = sellerQuery(startDate, startDate); + const drain = async (): Promise => { + store.clear(); + const body = await submitAndDrain(q); + const docId = body.dataDocumentId as string | undefined; + return docId ? (store.get(docId)!.content as string) : ""; + }; + expect(await drain()).toBe(await drain()); + }, + ), + { numRuns: 50 }, + ); + }); +}); + +// Feature: data-kiosk, Property 13: Date-Range Row Count +// **Validates: Requirements 10.1, 11.3** +describe("Property 13: Date-Range Row Count (salesAndTrafficByDate)", () => { + it("generated JSONL has exactly one line per calendar day in the inclusive range", () => { + fc.assert( + fc.property(fc.integer({ min: 0, max: 60 }), (offsetDays) => { + const start = new Date(Date.parse("2023-03-01T00:00:00Z") + offsetDays * 86400000).toISOString().slice(0, 10); + const end = new Date(Date.parse("2023-03-01T00:00:00Z") + (offsetDays + 3) * 86400000).toISOString().slice(0, 10); + const p = parseGraphQLQuery(sellerQuery(start, end))!; + expect(DATASETS[DATASET].generate(p).split("\n")).toHaveLength(datesInRange(start, end).length); + }), + { numRuns: 50 }, + ); + }); +}); + +// Feature: data-kiosk, Property 7 (Level A, retained): Document Content-Type Fidelity +// **Validates: Requirements 5.3** +describe("Property 7: Document Content-Type Fidelity", () => { + it("the download route responds with the stored contentType and byte-identical content", async () => { + await fc.assert( + fc.asyncProperty(fc.string({ minLength: 0, maxLength: 500 }), fc.constantFrom("application/jsonl", "application/json"), async (content, contentType) => { + store.clear(); + store.set("DKDOC-P7", { _key: "DKDOC-P7", documentId: "DKDOC-P7", content, contentType }); + let sentStatus = 0; + let sentBody: unknown; + let sentContentType: string | undefined; + const res = { + setHeader: (k: string, v: string) => { + if (k === "Content-Type") sentContentType = v; + }, + status: (c: number) => { + sentStatus = c; + return res; + }, + send: (b: unknown) => { + sentBody = b; + return res; + }, + json: (b: unknown) => { + sentBody = b; + return res; + }, + }; + await downloadDataKioskDocument({ params: { documentId: "DKDOC-P7" } } as any, res as any); + expect(sentStatus).toBe(200); + expect(sentContentType).toBe(contentType); + expect(sentBody).toBe(content); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/operation/extFulfillmentInventoryOperations.test.ts b/local-ai-sandbox/test/operation/extFulfillmentInventoryOperations.test.ts new file mode 100644 index 000000000..4e0b651f1 --- /dev/null +++ b/local-ai-sandbox/test/operation/extFulfillmentInventoryOperations.test.ts @@ -0,0 +1,587 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; + +const mockGet = vi.fn<(api: string, key: string) => Record | null>().mockReturnValue(null); +const mockPut = vi.fn(); + +vi.mock("../../src/database/Context.js", () => ({ + Api: { EXT_FULFILLMENT_INVENTORY: "extFulfillmentInventory" }, + Context: { + get instance() { + return { engine: { get: mockGet, put: mockPut } }; + }, + }, +})); + +import { batchInventoryHandler } from "../../src/operation/extFulfillmentInventoryOperations.js"; + +// --- Helpers --- + +interface SubRequest { + uri: string; + method?: string; + body?: Record; +} + +function makeValidationResult(requests: SubRequest[]): UnifiedValidationPass { + return { + pass: true, + operationId: "batchInventory", + apiName: "External Fulfillment Inventory", + apiVersion: "2024-09-11", + pathParams: {}, + queryParams: {}, + body: { requests }, + resolvedEntities: {}, + operation: {}, + }; +} + +// --- Arbitraries --- + +/** Alphanumeric string (1-20 chars) suitable for locationId/skuId */ +const alphaNumStr = fc.stringMatching(/^[a-zA-Z0-9]{1,20}$/); + +/** Positive integer for clientSequenceNumber */ +const positiveInt = fc.integer({ min: 1, max: 2_147_483_647 }); + +/** Non-negative integer for quantity */ +const nonNegInt = fc.integer({ min: 0, max: 2_147_483_647 }); + +/** Arbitrary marketplace attributes */ +const marketplaceAttrsArb = fc.option( + fc.record({ + marketplaceId: alphaNumStr, + channelName: alphaNumStr, + }), + { nil: undefined }, +); + +/** A valid update sub-request */ +const updateSubRequestArb = (locationId?: string, skuId?: string) => + fc + .record({ + locId: locationId ? fc.constant(locationId) : alphaNumStr, + sku: skuId ? fc.constant(skuId) : alphaNumStr, + quantity: nonNegInt, + clientSequenceNumber: positiveInt, + marketplaceAttributes: marketplaceAttrsArb, + }) + .map(({ locId, sku, quantity, clientSequenceNumber, marketplaceAttributes }) => ({ + uri: `/inventory/update?locationId=${locId}&skuId=${sku}`, + method: "POST", + body: { quantity, clientSequenceNumber, ...(marketplaceAttributes ? { marketplaceAttributes } : {}) }, + })); + +/** A valid fetch sub-request */ +const fetchSubRequestArb = (locationId?: string, skuId?: string) => + fc + .record({ + locId: locationId ? fc.constant(locationId) : alphaNumStr, + sku: skuId ? fc.constant(skuId) : alphaNumStr, + }) + .map(({ locId, sku }) => ({ + uri: `/inventory/fetch?locationId=${locId}&skuId=${sku}`, + method: "GET", + })); + +/** A valid sub-request (either update or fetch) */ +const validSubRequestArb = fc.oneof( + updateSubRequestArb(), + fetchSubRequestArb(), +); + +describe("Property-Based Tests: External Fulfillment Inventory", () => { + beforeEach(() => { + mockGet.mockReset(); + mockGet.mockReturnValue(null); + mockPut.mockReset(); + }); + + /** + * **Validates: Requirements 1.1, 6.2** + * + * Property 1: Batch response length and positional order + * For any valid batch of 1 to 10 sub-requests, the handler returns an HTTP 207 + * response where the responses array has the same length as the input requests array. + */ + describe("Property 1: Batch response length and positional order", () => { + it("responses array length equals requests array length", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(validSubRequestArb, { minLength: 1, maxLength: 10 }), + async (requests) => { + const validationResult = makeValidationResult(requests); + const result = await batchInventoryHandler(validationResult, {} as never); + + expect(result.statusCode).toBe(207); + const body = result.data.body as { responses: unknown[] }; + expect(body.responses.length).toBe(requests.length); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 1.5** + * + * Property 2: Unrecognized URI path returns error + * For any sub-request whose URI path does not contain /inventory/update or /inventory/fetch, + * the handler returns a per-item response with statusCode 400 and errorType INVALID_REQUEST. + */ + describe("Property 2: Unrecognized URI path returns error", () => { + /** Generate URIs that do NOT contain /inventory/update or /inventory/fetch */ + const badUriArb = fc + .record({ + path: fc.constantFrom("/inventory/delete", "/stock/update", "/something/else", "/inv/fetch", ""), + locId: alphaNumStr, + sku: alphaNumStr, + }) + .map(({ path, locId, sku }) => `${path}?locationId=${locId}&skuId=${sku}`); + + it("returns statusCode 400 with INVALID_REQUEST for unrecognized URI paths", async () => { + await fc.assert( + fc.asyncProperty(badUriArb, async (badUri) => { + const requests: SubRequest[] = [{ uri: badUri, method: "POST" }]; + const validationResult = makeValidationResult(requests); + const result = await batchInventoryHandler(validationResult, {} as never); + + expect(result.statusCode).toBe(207); + const body = result.data.body as { responses: Array<{ status: { statusCode: number }; body: { actionableErrors: Array<{ errorType: string }> } }> }; + expect(body.responses.length).toBe(1); + expect(body.responses[0].status.statusCode).toBe(400); + expect(body.responses[0].body.actionableErrors[0].errorType).toBe("INVALID_REQUEST"); + }), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 2.1, 2.3, 3.1, 7.1** + * + * Property 3: Insert-then-fetch round trip + * For any valid inventory update followed by a fetch for the same locationId/skuId, + * the fetched record contains the same sellableQuantity, clientSequenceNumber, + * marketplaceAttributes, and reservedQuantity equal to 0. + */ + describe("Property 3: Insert-then-fetch round trip", () => { + it("fetch returns the record that was previously inserted via update", async () => { + await fc.assert( + fc.asyncProperty( + alphaNumStr, + alphaNumStr, + nonNegInt, + positiveInt, + marketplaceAttrsArb, + async (locationId, skuId, quantity, clientSequenceNumber, marketplaceAttributes) => { + // Use a local Map to simulate stateful DB + const db = new Map>(); + mockGet.mockImplementation((_api: string, key: string) => db.get(key) ?? null); + mockPut.mockImplementation((_api: string, key: string, record: Record) => { + db.set(key, record); + }); + + const updateReq: SubRequest = { + uri: `/inventory/update?locationId=${locationId}&skuId=${skuId}`, + method: "POST", + body: { quantity, clientSequenceNumber, ...(marketplaceAttributes ? { marketplaceAttributes } : {}) }, + }; + const fetchReq: SubRequest = { + uri: `/inventory/fetch?locationId=${locationId}&skuId=${skuId}`, + method: "GET", + }; + + const validationResult = makeValidationResult([updateReq, fetchReq]); + const result = await batchInventoryHandler(validationResult, {} as never); + + const body = result.data.body as { responses: Array<{ status: { statusCode: number }; body: Record }> }; + + // Update should succeed + expect(body.responses[0].status.statusCode).toBe(200); + + // Fetch should succeed and return the stored values + expect(body.responses[1].status.statusCode).toBe(200); + expect(body.responses[1].body.sellableQuantity).toBe(quantity); + expect(body.responses[1].body.clientSequenceNumber).toBe(clientSequenceNumber); + expect(body.responses[1].body.reservedQuantity).toBe(0); + expect(body.responses[1].body.marketplaceAttributes).toEqual(marketplaceAttributes); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 2.2** + * + * Property 4: Update with higher sequence number preserves reservedQuantity + * For any existing inventory record with reservedQuantity R, when an update provides + * a clientSequenceNumber strictly greater than the stored value, the resulting record + * has the new sellableQuantity, the new clientSequenceNumber, and reservedQuantity unchanged at R. + */ + describe("Property 4: Update with higher sequence number preserves reservedQuantity", () => { + it("preserves reservedQuantity after update with higher sequence number", async () => { + await fc.assert( + fc.asyncProperty( + alphaNumStr, + alphaNumStr, + nonNegInt, + nonNegInt, + positiveInt, + fc.integer({ min: 0, max: 1000 }), + fc.integer({ min: 0, max: 1000 }), + marketplaceAttrsArb, + async (locationId, skuId, initialQty, newQty, initialSeq, seqIncrement, reservedQuantity, marketplaceAttributes) => { + const newSeq = initialSeq + seqIncrement + 1; // Ensure strictly greater + + // Simulate existing record + const db = new Map>(); + const compositeKey = `${locationId}:${skuId}`; + db.set(compositeKey, { + locationId, + skuId, + sellableQuantity: initialQty, + reservedQuantity, + clientSequenceNumber: initialSeq, + marketplaceAttributes: undefined, + }); + + mockGet.mockImplementation((_api: string, key: string) => db.get(key) ?? null); + mockPut.mockImplementation((_api: string, key: string, record: Record) => { + db.set(key, record); + }); + + const updateReq: SubRequest = { + uri: `/inventory/update?locationId=${locationId}&skuId=${skuId}`, + method: "POST", + body: { quantity: newQty, clientSequenceNumber: newSeq, ...(marketplaceAttributes ? { marketplaceAttributes } : {}) }, + }; + + const validationResult = makeValidationResult([updateReq]); + const result = await batchInventoryHandler(validationResult, {} as never); + + const body = result.data.body as { responses: Array<{ status: { statusCode: number }; body: Record }> }; + expect(body.responses[0].status.statusCode).toBe(200); + expect(body.responses[0].body.sellableQuantity).toBe(newQty); + expect(body.responses[0].body.clientSequenceNumber).toBe(newSeq); + expect(body.responses[0].body.reservedQuantity).toBe(reservedQuantity); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 2.4** + * + * Property 5: Stale clientSequenceNumber rejection + * For any existing inventory record with stored clientSequenceNumber N, an update + * with clientSequenceNumber <= N returns a per-item error with STALE_DATA + * and does NOT modify the stored record. + */ + describe("Property 5: Stale clientSequenceNumber rejection", () => { + it("rejects update with stale clientSequenceNumber and does not modify stored record", async () => { + await fc.assert( + fc.asyncProperty( + alphaNumStr, + alphaNumStr, + nonNegInt, + positiveInt, + nonNegInt, + async (locationId, skuId, originalQty, storedSeq, staleOffset) => { + const staleSeq = Math.max(1, storedSeq - staleOffset); // <= storedSeq + + const db = new Map>(); + const compositeKey = `${locationId}:${skuId}`; + const originalRecord = { + locationId, + skuId, + sellableQuantity: originalQty, + reservedQuantity: 5, + clientSequenceNumber: storedSeq, + marketplaceAttributes: undefined, + }; + db.set(compositeKey, { ...originalRecord }); + + mockGet.mockImplementation((_api: string, key: string) => db.get(key) ?? null); + mockPut.mockImplementation((_api: string, key: string, record: Record) => { + db.set(key, record); + }); + + const updateReq: SubRequest = { + uri: `/inventory/update?locationId=${locationId}&skuId=${skuId}`, + method: "POST", + body: { quantity: 999, clientSequenceNumber: staleSeq }, + }; + + const validationResult = makeValidationResult([updateReq]); + const result = await batchInventoryHandler(validationResult, {} as never); + + const body = result.data.body as { responses: Array<{ status: { statusCode: number }; body: { actionableErrors: Array<{ errorType: string }> } }> }; + expect(body.responses[0].status.statusCode).toBe(400); + expect(body.responses[0].body.actionableErrors[0].errorType).toBe("STALE_DATA"); + + // Verify stored record was not modified + const storedRecord = db.get(compositeKey); + expect(storedRecord?.sellableQuantity).toBe(originalQty); + expect(storedRecord?.clientSequenceNumber).toBe(storedSeq); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 3.2** + * + * Property 6: Fetch for non-existing key returns INVALID_SKU + * For any locationId and skuId combination that does not exist in the database, + * a fetch returns a per-item error with INVALID_SKU. + */ + describe("Property 6: Fetch for non-existing key returns INVALID_SKU", () => { + it("returns INVALID_SKU error for non-existing locationId:skuId", async () => { + await fc.assert( + fc.asyncProperty(alphaNumStr, alphaNumStr, async (locationId, skuId) => { + // mockGet already returns null by default (reset in beforeEach) + mockGet.mockReturnValue(null); + + const fetchReq: SubRequest = { + uri: `/inventory/fetch?locationId=${locationId}&skuId=${skuId}`, + method: "GET", + }; + + const validationResult = makeValidationResult([fetchReq]); + const result = await batchInventoryHandler(validationResult, {} as never); + + const body = result.data.body as { responses: Array<{ status: { statusCode: number }; body: { actionableErrors: Array<{ errorType: string }> } }> }; + expect(body.responses[0].status.statusCode).toBe(400); + expect(body.responses[0].body.actionableErrors[0].errorType).toBe("INVALID_SKU"); + }), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 3.3** + * + * Property 7: Fetch ignores body marketplaceAttributes for lookup + * For any existing inventory record, a fetch with arbitrary marketplaceAttributes + * in its body returns the same result as a fetch with no marketplaceAttributes. + */ + describe("Property 7: Fetch ignores body marketplaceAttributes for lookup", () => { + it("fetch result is independent of body marketplaceAttributes", async () => { + await fc.assert( + fc.asyncProperty( + alphaNumStr, + alphaNumStr, + nonNegInt, + positiveInt, + fc.record({ marketplaceId: alphaNumStr, channelName: alphaNumStr }), + async (locationId, skuId, qty, seq, arbitraryAttrs) => { + const compositeKey = `${locationId}:${skuId}`; + const storedRecord = { + locationId, + skuId, + sellableQuantity: qty, + reservedQuantity: 0, + clientSequenceNumber: seq, + marketplaceAttributes: { marketplaceId: "STORED_MP", channelName: "STORED_CH" }, + }; + + mockGet.mockImplementation((_api: string, key: string) => (key === compositeKey ? storedRecord : null)); + + // Fetch with arbitrary body marketplaceAttributes + const fetchWithAttrs: SubRequest = { + uri: `/inventory/fetch?locationId=${locationId}&skuId=${skuId}`, + method: "GET", + body: { marketplaceAttributes: arbitraryAttrs }, + }; + + // Fetch without body + const fetchWithout: SubRequest = { + uri: `/inventory/fetch?locationId=${locationId}&skuId=${skuId}`, + method: "GET", + }; + + const result1 = await batchInventoryHandler(makeValidationResult([fetchWithAttrs]), {} as never); + const result2 = await batchInventoryHandler(makeValidationResult([fetchWithout]), {} as never); + + const body1 = result1.data.body as { responses: Array<{ status: { statusCode: number }; body: Record }> }; + const body2 = result2.data.body as { responses: Array<{ status: { statusCode: number }; body: Record }> }; + + // Both should return the same result + expect(body1.responses[0].status.statusCode).toBe(body2.responses[0].status.statusCode); + expect(body1.responses[0].body.sellableQuantity).toBe(body2.responses[0].body.sellableQuantity); + expect(body1.responses[0].body.reservedQuantity).toBe(body2.responses[0].body.reservedQuantity); + expect(body1.responses[0].body.clientSequenceNumber).toBe(body2.responses[0].body.clientSequenceNumber); + expect(body1.responses[0].body.marketplaceAttributes).toEqual(body2.responses[0].body.marketplaceAttributes); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 7.4** + * + * Property 8: Location independence + * For any two distinct locationId values sharing the same skuId, updating inventory + * at one location does NOT modify the inventory record at the other location. + */ + describe("Property 8: Location independence", () => { + it("updating inventory at location L1 does not affect inventory at location L2", async () => { + await fc.assert( + fc.asyncProperty( + alphaNumStr, + alphaNumStr, + alphaNumStr, + nonNegInt, + nonNegInt, + positiveInt, + positiveInt, + async (location1, location2Suffix, skuId, qty1, qty2, seq1, seq2) => { + // Ensure distinct locations + const location2 = location1 + location2Suffix + "X"; + + const db = new Map>(); + mockGet.mockImplementation((_api: string, key: string) => db.get(key) ?? null); + mockPut.mockImplementation((_api: string, key: string, record: Record) => { + db.set(key, record); + }); + + // Insert at location1 + const update1: SubRequest = { + uri: `/inventory/update?locationId=${location1}&skuId=${skuId}`, + method: "POST", + body: { quantity: qty1, clientSequenceNumber: seq1 }, + }; + + // Insert at location2 + const update2: SubRequest = { + uri: `/inventory/update?locationId=${location2}&skuId=${skuId}`, + method: "POST", + body: { quantity: qty2, clientSequenceNumber: seq2 }, + }; + + await batchInventoryHandler(makeValidationResult([update1, update2]), {} as never); + + // Verify both records exist independently + const key1 = `${location1}:${skuId}`; + const key2 = `${location2}:${skuId}`; + const record1 = db.get(key1); + const record2 = db.get(key2); + + expect(record1).not.toBeNull(); + expect(record2).not.toBeNull(); + expect(record1?.sellableQuantity).toBe(qty1); + expect(record2?.sellableQuantity).toBe(qty2); + + // Now update location1 with a higher sequence number + const higherSeq = Math.max(seq1, seq2) + 1; + const update1Again: SubRequest = { + uri: `/inventory/update?locationId=${location1}&skuId=${skuId}`, + method: "POST", + body: { quantity: 0, clientSequenceNumber: higherSeq }, + }; + + await batchInventoryHandler(makeValidationResult([update1Again]), {} as never); + + // location2 record should be unchanged + const record2After = db.get(key2); + expect(record2After?.sellableQuantity).toBe(qty2); + expect(record2After?.clientSequenceNumber).toBe(seq2); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 6.1, 6.3** + * + * Property 9: Per-item error isolation in mixed batches + * For any batch containing a mix of valid and invalid sub-requests, the handler + * returns HTTP 207 with per-item success (200) for valid requests and per-item + * error (400) for invalid requests. No valid sub-request's processing is affected + * by the presence of an invalid sub-request. + */ + describe("Property 9: Per-item error isolation in mixed batches", () => { + /** Generate an invalid sub-request (unrecognized URI path) */ + const invalidSubRequestArb = alphaNumStr.map((id) => ({ + uri: `/inventory/unknown?locationId=${id}&skuId=${id}`, + method: "POST", + })); + + it("valid requests succeed and invalid requests fail independently in mixed batches", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + isValid: fc.boolean(), + locationId: alphaNumStr, + skuId: alphaNumStr, + quantity: nonNegInt, + clientSequenceNumber: positiveInt, + }), + { minLength: 2, maxLength: 10 }, + ), + async (items) => { + // Ensure at least one valid and one invalid + if (items.every((i) => i.isValid) || items.every((i) => !i.isValid)) { + // Force mix: flip first item + items[0] = { ...items[0], isValid: !items[0].isValid }; + } + + const db = new Map>(); + mockGet.mockImplementation((_api: string, key: string) => db.get(key) ?? null); + mockPut.mockImplementation((_api: string, key: string, record: Record) => { + db.set(key, record); + }); + + const requests: SubRequest[] = items.map((item) => { + if (item.isValid) { + return { + uri: `/inventory/update?locationId=${item.locationId}&skuId=${item.skuId}`, + method: "POST", + body: { quantity: item.quantity, clientSequenceNumber: item.clientSequenceNumber }, + }; + } else { + return { + uri: `/inventory/unknown?locationId=${item.locationId}&skuId=${item.skuId}`, + method: "POST", + }; + } + }); + + const validationResult = makeValidationResult(requests); + const result = await batchInventoryHandler(validationResult, {} as never); + + expect(result.statusCode).toBe(207); + const body = result.data.body as { responses: Array<{ status: { statusCode: number }; body: { actionableErrors: Array<{ errorType: string }> } }> }; + expect(body.responses.length).toBe(items.length); + + for (let i = 0; i < items.length; i++) { + if (items[i].isValid) { + expect(body.responses[i].status.statusCode).toBe(200); + } else { + expect(body.responses[i].status.statusCode).toBe(400); + expect(body.responses[i].body.actionableErrors[0].errorType).toBe("INVALID_REQUEST"); + } + } + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/local-ai-sandbox/test/operation/extFulfillmentInventoryOperations.unit.test.ts b/local-ai-sandbox/test/operation/extFulfillmentInventoryOperations.unit.test.ts new file mode 100644 index 000000000..210d8caad --- /dev/null +++ b/local-ai-sandbox/test/operation/extFulfillmentInventoryOperations.unit.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; + +// Mock DB functions +const mockGet = vi.fn<(api: string, key: string) => Record | null>().mockReturnValue(null); +const mockPut = vi.fn(); + +vi.mock("../../src/database/Context.js", () => ({ + Api: { EXT_FULFILLMENT_INVENTORY: "extFulfillmentInventory" }, + Context: { + get instance() { + return { engine: { get: mockGet, put: mockPut } }; + }, + }, +})); + +import { batchInventoryHandler } from "../../src/operation/extFulfillmentInventoryOperations.js"; + +interface SubRequest { + uri: string; + method?: string; + body?: Record; +} + +function makeValidationResult(requests: SubRequest[]): UnifiedValidationPass { + return { + pass: true, + operationId: "batchInventory", + apiName: "External Fulfillment Inventory", + apiVersion: "2024-09-11", + pathParams: {}, + queryParams: {}, + body: { requests }, + resolvedEntities: {}, + operation: {}, + }; +} + +describe("batchInventoryHandler — unit tests for edge cases and error conditions", () => { + beforeEach(() => { + mockGet.mockReturnValue(null); + mockPut.mockReset(); + }); + + // **Validates: Requirements 1.5** + describe("empty URI", () => { + it("should return statusCode 400 with INVALID_REQUEST for an empty URI string", async () => { + const result = await batchInventoryHandler(makeValidationResult([{ uri: "" }]), {} as any); + const responses = (result.data as any).body.responses; + + expect(responses).toHaveLength(1); + expect(responses[0].status.statusCode).toBe(400); + expect(responses[0].body.actionableErrors[0].errorType).toBe("INVALID_REQUEST"); + }); + }); + + // **Validates: Requirements 1.6** + describe("missing locationId or skuId in URI", () => { + it("should return 400 INVALID_REQUEST when skuId is missing", async () => { + const result = await batchInventoryHandler(makeValidationResult([{ uri: "/inventory/update?locationId=LOC1" }]), {} as any); + const responses = (result.data as any).body.responses; + + expect(responses).toHaveLength(1); + expect(responses[0].status.statusCode).toBe(400); + expect(responses[0].body.actionableErrors[0].errorType).toBe("INVALID_REQUEST"); + expect(responses[0].body.actionableErrors[0].errorSubType).toContain("locationId and skuId"); + }); + + it("should return 400 INVALID_REQUEST when locationId is missing", async () => { + const result = await batchInventoryHandler(makeValidationResult([{ uri: "/inventory/fetch?skuId=SKU1" }]), {} as any); + const responses = (result.data as any).body.responses; + + expect(responses).toHaveLength(1); + expect(responses[0].status.statusCode).toBe(400); + expect(responses[0].body.actionableErrors[0].errorType).toBe("INVALID_REQUEST"); + }); + + it("should return 400 INVALID_REQUEST when both locationId and skuId are missing", async () => { + const result = await batchInventoryHandler(makeValidationResult([{ uri: "/inventory/update" }]), {} as any); + const responses = (result.data as any).body.responses; + + expect(responses).toHaveLength(1); + expect(responses[0].status.statusCode).toBe(400); + expect(responses[0].body.actionableErrors[0].errorType).toBe("INVALID_REQUEST"); + }); + }); + + // **Validates: Requirements 2.5** + describe("negative quantity", () => { + it("should return 400 INVALID_INPUT with 'Quantity must be non-negative'", async () => { + const result = await batchInventoryHandler( + makeValidationResult([ + { + uri: "/inventory/update?locationId=LOC1&skuId=SKU1", + body: { quantity: -5, clientSequenceNumber: 1 }, + }, + ]), + {} as any, + ); + const responses = (result.data as any).body.responses; + + expect(responses).toHaveLength(1); + expect(responses[0].status.statusCode).toBe(400); + expect(responses[0].body.actionableErrors[0].errorType).toBe("INVALID_INPUT"); + expect(responses[0].body.actionableErrors[0].errorSubType).toBe("Quantity must be non-negative"); + }); + }); + + // **Validates: Requirements 2.6** + describe("floating-point quantity", () => { + it("should return 400 INVALID_INPUT with 'Quantity must be an integer'", async () => { + const result = await batchInventoryHandler( + makeValidationResult([ + { + uri: "/inventory/update?locationId=LOC1&skuId=SKU1", + body: { quantity: 3.14, clientSequenceNumber: 1 }, + }, + ]), + {} as any, + ); + const responses = (result.data as any).body.responses; + + expect(responses).toHaveLength(1); + expect(responses[0].status.statusCode).toBe(400); + expect(responses[0].body.actionableErrors[0].errorType).toBe("INVALID_INPUT"); + expect(responses[0].body.actionableErrors[0].errorSubType).toBe("Quantity must be an integer"); + }); + }); + + // **Validates: Requirements 2.7** + describe("missing quantity field", () => { + it("should return 400 INVALID_INPUT with 'Quantity is required for update operations'", async () => { + const result = await batchInventoryHandler( + makeValidationResult([ + { + uri: "/inventory/update?locationId=LOC1&skuId=SKU1", + body: { clientSequenceNumber: 1 }, + }, + ]), + {} as any, + ); + const responses = (result.data as any).body.responses; + + expect(responses).toHaveLength(1); + expect(responses[0].status.statusCode).toBe(400); + expect(responses[0].body.actionableErrors[0].errorType).toBe("INVALID_INPUT"); + expect(responses[0].body.actionableErrors[0].errorSubType).toBe("Quantity is required for update operations"); + }); + }); + + // **Validates: Requirements 2.8** + describe("missing clientSequenceNumber", () => { + it("should return 400 INVALID_INPUT with 'clientSequenceNumber is required for update operations'", async () => { + const result = await batchInventoryHandler( + makeValidationResult([ + { + uri: "/inventory/update?locationId=LOC1&skuId=SKU1", + body: { quantity: 10 }, + }, + ]), + {} as any, + ); + const responses = (result.data as any).body.responses; + + expect(responses).toHaveLength(1); + expect(responses[0].status.statusCode).toBe(400); + expect(responses[0].body.actionableErrors[0].errorType).toBe("INVALID_INPUT"); + expect(responses[0].body.actionableErrors[0].errorSubType).toBe("clientSequenceNumber is required for update operations"); + }); + }); + + // **Validates: Requirements 2.3** + describe("success response format", () => { + it("should return statusCode 200 with all required fields present", async () => { + const result = await batchInventoryHandler( + makeValidationResult([ + { + uri: "/inventory/update?locationId=LOC1&skuId=SKU1", + body: { + quantity: 50, + clientSequenceNumber: 1, + marketplaceAttributes: { marketplaceId: "ATVPDKIKX0DER", channelName: "DEFAULT" }, + }, + }, + ]), + {} as any, + ); + const responses = (result.data as any).body.responses; + + expect(responses).toHaveLength(1); + const resp = responses[0]; + + expect(resp.status.statusCode).toBe(200); + expect(resp.status.reasonPhrase).toBe("Success"); + expect(resp.body.locationId).toBe("LOC1"); + expect(resp.body.skuId).toBe("SKU1"); + expect(resp.body.sellableQuantity).toBe(50); + expect(resp.body.reservedQuantity).toBe(0); + expect(resp.body.clientSequenceNumber).toBe(1); + expect(resp.body.marketplaceAttributes).toEqual({ marketplaceId: "ATVPDKIKX0DER", channelName: "DEFAULT" }); + expect(resp.body.actionableErrors).toEqual([]); + }); + + it("should include all required fields even when marketplaceAttributes is undefined", async () => { + const result = await batchInventoryHandler( + makeValidationResult([ + { + uri: "/inventory/update?locationId=LOC1&skuId=SKU1", + body: { quantity: 10, clientSequenceNumber: 1 }, + }, + ]), + {} as any, + ); + const responses = (result.data as any).body.responses; + const resp = responses[0]; + + expect(resp.status.statusCode).toBe(200); + expect(resp.body).toHaveProperty("locationId"); + expect(resp.body).toHaveProperty("skuId"); + expect(resp.body).toHaveProperty("sellableQuantity"); + expect(resp.body).toHaveProperty("reservedQuantity"); + expect(resp.body).toHaveProperty("clientSequenceNumber"); + expect(resp.body).toHaveProperty("marketplaceAttributes"); + expect(resp.body).toHaveProperty("actionableErrors"); + expect(resp.body.actionableErrors).toEqual([]); + }); + }); + + // **Validates: Requirements 6.4** + describe("unexpected error handling (statusCode 500, INTERNAL_ERROR)", () => { + it("should return per-item 500 INTERNAL_ERROR when DB put throws", async () => { + mockPut.mockImplementationOnce(() => { + throw new Error("Simulated DB failure"); + }); + + const result = await batchInventoryHandler( + makeValidationResult([ + { + uri: "/inventory/update?locationId=LOC1&skuId=SKU1", + body: { quantity: 10, clientSequenceNumber: 1 }, + }, + ]), + {} as any, + ); + const responses = (result.data as any).body.responses; + + expect(responses).toHaveLength(1); + expect(responses[0].status.statusCode).toBe(500); + expect(responses[0].status.reasonPhrase).toBe("Internal Server Error"); + expect(responses[0].body.actionableErrors[0].errorType).toBe("INTERNAL_ERROR"); + expect(responses[0].body.actionableErrors[0].errorSubType).toBe("Unexpected processing failure"); + }); + + it("should isolate errors — other items in the batch still process correctly", async () => { + // First call to mockPut throws, second succeeds + mockPut.mockImplementationOnce(() => { + throw new Error("Simulated DB failure"); + }); + + const result = await batchInventoryHandler( + makeValidationResult([ + { + uri: "/inventory/update?locationId=LOC1&skuId=SKU1", + body: { quantity: 10, clientSequenceNumber: 1 }, + }, + { + uri: "/inventory/update?locationId=LOC2&skuId=SKU2", + body: { quantity: 20, clientSequenceNumber: 2 }, + }, + ]), + {} as any, + ); + const responses = (result.data as any).body.responses; + + expect(responses).toHaveLength(2); + // First item should have errored + expect(responses[0].status.statusCode).toBe(500); + expect(responses[0].body.actionableErrors[0].errorType).toBe("INTERNAL_ERROR"); + // Second item should have succeeded + expect(responses[1].status.statusCode).toBe(200); + expect(responses[1].body.locationId).toBe("LOC2"); + expect(responses[1].body.skuId).toBe("SKU2"); + expect(responses[1].body.sellableQuantity).toBe(20); + }); + + it("should return per-item 500 INTERNAL_ERROR when DB get throws during fetch", async () => { + mockGet.mockImplementationOnce(() => { + throw new Error("Simulated DB read failure"); + }); + + const result = await batchInventoryHandler( + makeValidationResult([ + { + uri: "/inventory/fetch?locationId=LOC1&skuId=SKU1", + }, + ]), + {} as any, + ); + const responses = (result.data as any).body.responses; + + expect(responses).toHaveLength(1); + expect(responses[0].status.statusCode).toBe(500); + expect(responses[0].body.actionableErrors[0].errorType).toBe("INTERNAL_ERROR"); + }); + }); +}); diff --git a/local-ai-sandbox/test/operation/extFulfillmentReturnsOperations.test.ts b/local-ai-sandbox/test/operation/extFulfillmentReturnsOperations.test.ts new file mode 100644 index 000000000..f05559de4 --- /dev/null +++ b/local-ai-sandbox/test/operation/extFulfillmentReturnsOperations.test.ts @@ -0,0 +1,375 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fc from "fast-check"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; +import { encodePageToken } from "../../src/service/Paginator.js"; + +// Mock find function — tests set the return value via mockFind.mockReturnValue(...) +const mockFind = vi.fn<() => Record[]>().mockReturnValue([]); + +// Mock the Context singleton so engine.find returns our controlled data +vi.mock("../../src/database/Context.js", () => ({ + Api: { EXT_FULFILLMENT_RETURNS: "extFulfillmentReturns" }, + Context: { + get instance() { + return { + engine: { + find: mockFind, + }, + }; + }, + }, +})); + +import { listReturnsHandler } from "../../src/operation/extFulfillmentReturnsOperations.js"; + +/** + * Creates a minimal valid UnifiedValidationPass object for testing listReturnsHandler. + */ +function makeValidationResult(queryParams: Record = {}): UnifiedValidationPass { + return { + pass: true, + operationId: "listReturns", + apiName: "External Fulfillment Returns", + apiVersion: "2024-09-11", + pathParams: {}, + queryParams: { ...queryParams }, + body: undefined, + resolvedEntities: {}, + operation: {}, + }; +} + +/** + * Creates a return entity with sensible defaults, allowing overrides. + */ +function makeReturnEntity(overrides: Partial> = {}): Record { + return { + _key: "ret-001", + id: "ret-001", + returnLocationId: "LOC-001", + merchantSku: "SKU-001", + returnType: "CUSTOMER", + status: "CREATED", + numberOfUnits: 1, + creationDateTime: "2024-06-15T10:00:00Z", + lastUpdatedDateTime: "2024-06-20T10:00:00Z", + returnMetadata: { rmaId: "RMA-001" }, + returnShippingInfo: { reverseTrackingInfo: { carrierName: "UPS", trackingId: "TRACK-001" } }, + ...overrides, + }; +} + +describe("listReturnsHandler", () => { + beforeEach(() => { + mockFind.mockReturnValue([]); + }); + + describe("property tests", () => { + // Feature: ext-fulfillment-returns-api, Property 1: Exact-match filter correctness + // **Validates: Requirements 1.2, 1.3, 1.4, 1.5** + it("Property 1: Exact-match filter correctness — every returned record matches the filter and all matching records are present", async () => { + const filterArb = fc.oneof( + fc.constant("returnLocationId" as const), + fc.constant("rmaId" as const), + fc.constant("status" as const), + fc.constant("reverseTrackingId" as const), + ); + + const valueArb = fc.stringMatching(/^[A-Za-z0-9_-]{1,20}$/); + + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + returnLocationId: valueArb, + rmaId: valueArb, + status: valueArb, + reverseTrackingId: valueArb, + }), + { minLength: 1, maxLength: 30 }, + ), + filterArb, + fc.nat({ max: 29 }), + async (entitySpecs, filterName, pickIndex) => { + const safeIndex = pickIndex % entitySpecs.length; + const filterValue = entitySpecs[safeIndex][filterName]; + + const entities = entitySpecs.map((spec, i) => + makeReturnEntity({ + _key: `ret-${i}`, + id: `ret-${i}`, + returnLocationId: spec.returnLocationId, + status: spec.status, + returnMetadata: { rmaId: spec.rmaId }, + returnShippingInfo: { reverseTrackingInfo: { carrierName: "UPS", trackingId: spec.reverseTrackingId } }, + }), + ); + + mockFind.mockReturnValue(entities); + + const queryParams: Record = { maxResults: "100" }; + if (filterName === "returnLocationId") queryParams.returnLocationId = filterValue; + else if (filterName === "rmaId") queryParams.rmaId = filterValue; + else if (filterName === "status") queryParams.status = filterValue; + else if (filterName === "reverseTrackingId") queryParams.reverseTrackingId = filterValue; + + const result = await listReturnsHandler(makeValidationResult(queryParams), {} as any); + const body = result.data.body as Record; + const returns = body.returns as Record[]; + + // Compute expected matches + const expectedMatches = entities.filter((e) => { + if (filterName === "returnLocationId") return e.returnLocationId === filterValue; + if (filterName === "rmaId") return (e.returnMetadata as any)?.rmaId === filterValue; + if (filterName === "status") return e.status === filterValue; + if (filterName === "reverseTrackingId") return (e.returnShippingInfo as any)?.reverseTrackingInfo?.trackingId === filterValue; + return false; + }); + + // Every returned record matches the filter + for (const ret of returns) { + if (filterName === "returnLocationId") expect(ret.returnLocationId).toBe(filterValue); + else if (filterName === "rmaId") expect((ret.returnMetadata as any)?.rmaId).toBe(filterValue); + else if (filterName === "status") expect(ret.status).toBe(filterValue); + else if (filterName === "reverseTrackingId") + expect((ret.returnShippingInfo as any)?.reverseTrackingInfo?.trackingId).toBe(filterValue); + } + + // All matching records are present + expect(returns).toHaveLength(expectedMatches.length); + }, + ), + { numRuns: 100 }, + ); + }); + + // Feature: ext-fulfillment-returns-api, Property 2: Date range filter correctness + // **Validates: Requirements 1.6, 1.7, 1.8, 1.9** + it("Property 2: Date range filter correctness — every returned record satisfies the date boundary condition", async () => { + const dateFilterArb = fc.oneof( + fc.constant("createdSince" as const), + fc.constant("createdUntil" as const), + fc.constant("lastUpdatedSince" as const), + fc.constant("lastUpdatedUntil" as const), + ); + + const timestampArb = fc.integer({ min: 1672531200000, max: 1767225600000 }).map((ms) => new Date(ms).toISOString()); + + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + creationDateTime: timestampArb, + lastUpdatedDateTime: timestampArb, + }), + { minLength: 1, maxLength: 20 }, + ), + dateFilterArb, + timestampArb, + async (entitySpecs, filterName, filterValue) => { + const entities = entitySpecs.map((spec, i) => + makeReturnEntity({ + _key: `ret-${i}`, + id: `ret-${i}`, + creationDateTime: spec.creationDateTime, + lastUpdatedDateTime: spec.lastUpdatedDateTime, + }), + ); + + mockFind.mockReturnValue(entities); + + const queryParams: Record = { maxResults: "100" }; + queryParams[filterName] = filterValue; + + const result = await listReturnsHandler(makeValidationResult(queryParams), {} as any); + const body = result.data.body as Record; + const returns = body.returns as Record[]; + + const boundary = new Date(filterValue).getTime(); + + for (const ret of returns) { + if (filterName === "createdSince") { + expect(new Date(ret.creationDateTime as string).getTime()).toBeGreaterThanOrEqual(boundary); + } else if (filterName === "createdUntil") { + expect(new Date(ret.creationDateTime as string).getTime()).toBeLessThanOrEqual(boundary); + } else if (filterName === "lastUpdatedSince") { + expect(new Date(ret.lastUpdatedDateTime as string).getTime()).toBeGreaterThanOrEqual(boundary); + } else if (filterName === "lastUpdatedUntil") { + expect(new Date(ret.lastUpdatedDateTime as string).getTime()).toBeLessThanOrEqual(boundary); + } + } + }, + ), + { numRuns: 100 }, + ); + }); + + // Feature: ext-fulfillment-returns-api, Property 3: Filter composition is conjunction + // **Validates: Requirements 1.10** + it("Property 3: Filter composition is conjunction — combining two filters equals the intersection of each applied individually", async () => { + const valueArb = fc.stringMatching(/^[A-Za-z0-9_-]{1,10}$/); + + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + returnLocationId: valueArb, + status: valueArb, + }), + { minLength: 1, maxLength: 20 }, + ), + valueArb, + valueArb, + async (entitySpecs, filterLoc, filterStatus) => { + const entities = entitySpecs.map((spec, i) => + makeReturnEntity({ + _key: `ret-${i}`, + id: `ret-${i}`, + returnLocationId: spec.returnLocationId, + status: spec.status, + }), + ); + + mockFind.mockReturnValue(entities); + + // Apply filter A alone (returnLocationId) + const resultA = await listReturnsHandler( + makeValidationResult({ returnLocationId: filterLoc, maxResults: "100" }), + {} as any, + ); + const returnsA = (resultA.data.body as any).returns as Record[]; + + // Apply filter B alone (status) + const resultB = await listReturnsHandler(makeValidationResult({ status: filterStatus, maxResults: "100" }), {} as any); + const returnsB = (resultB.data.body as any).returns as Record[]; + + // Apply both filters together + const resultBoth = await listReturnsHandler( + makeValidationResult({ returnLocationId: filterLoc, status: filterStatus, maxResults: "100" }), + {} as any, + ); + const returnsBoth = (resultBoth.data.body as any).returns as Record[]; + + // Compute intersection of A and B by id + const idsA = new Set(returnsA.map((r) => r.id)); + const idsB = new Set(returnsB.map((r) => r.id)); + const intersection = [...idsA].filter((id) => idsB.has(id)).sort(); + + const idsBoth = returnsBoth.map((r) => r.id as string).sort(); + + expect(idsBoth).toEqual(intersection); + }, + ), + { numRuns: 100 }, + ); + }); + + // Feature: ext-fulfillment-returns-api, Property 4: Page size invariant + // **Validates: Requirements 1.11** + it("Property 4: Page size invariant — response never contains more items than maxResults", async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 100 }), + fc.integer({ min: 1, max: 50 }), + async (maxResults, entityCount) => { + const entities = Array.from({ length: entityCount }, (_, i) => + makeReturnEntity({ _key: `ret-${i}`, id: `ret-${i}` }), + ); + + mockFind.mockReturnValue(entities); + + const result = await listReturnsHandler(makeValidationResult({ maxResults: String(maxResults) }), {} as any); + const body = result.data.body as Record; + const returns = body.returns as Record[]; + + expect(returns.length).toBeLessThanOrEqual(maxResults); + }, + ), + { numRuns: 100 }, + ); + }); + + // Feature: ext-fulfillment-returns-api, Property 5: Pagination completeness + // **Validates: Requirements 1.12, 1.13** + it("Property 5: Pagination completeness — iterating all pages produces the full filtered set with no duplicates/omissions", async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 80 }), + fc.integer({ min: 1, max: 20 }), + async (entityCount, pageSize) => { + const entities = Array.from({ length: entityCount }, (_, i) => + makeReturnEntity({ _key: `ret-${i}`, id: `ret-${i}`, returnLocationId: `LOC-${i}` }), + ); + + mockFind.mockReturnValue(entities); + + const allCollected: Record[] = []; + let nextToken: string | undefined = undefined; + const maxPages = Math.ceil(entityCount / pageSize) + 2; + + for (let page = 0; page < maxPages; page++) { + const qp: Record = { maxResults: String(pageSize) }; + if (nextToken) qp.nextToken = nextToken; + + const result = await listReturnsHandler(makeValidationResult(qp), {} as any); + const body = result.data.body as Record; + const returns = body.returns as Record[]; + + allCollected.push(...returns); + + if (body.nextToken) { + nextToken = body.nextToken as string; + } else { + break; + } + } + + // No duplicates + const ids = allCollected.map((r) => r.id as string); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(ids.length); + + // All entities present + expect(allCollected).toHaveLength(entityCount); + }, + ), + { numRuns: 100 }, + ); + }); + + // Feature: ext-fulfillment-returns-api, Property 6: Invalid token graceful degradation + // **Validates: Requirements 1.15** + it("Property 6: Invalid token graceful degradation — invalid tokens produce 200 with empty returns", async () => { + // Generate strings that are definitively NOT valid base64 { offset } tokens + const invalidTokenArb = fc.oneof( + // Strings with characters invalid in base64 + fc.stringMatching(/^[!@#$%^&*()]{1,20}$/), + // Valid base64 but not valid JSON + fc.constant(Buffer.from("not-json-content").toString("base64")), + // Valid base64 + JSON but missing "offset" key + fc.constant(Buffer.from(JSON.stringify({ wrongKey: 5 })).toString("base64")), + // Valid base64 + JSON with negative offset (rejected by decodePageToken) + fc.constant(Buffer.from(JSON.stringify({ offset: -1 })).toString("base64")), + // Valid base64 + JSON with non-number offset + fc.constant(Buffer.from(JSON.stringify({ offset: "abc" })).toString("base64")), + // Valid token but offset beyond result set size (out-of-range) + fc.constant(encodePageToken(9999)), + ); + + await fc.assert( + fc.asyncProperty(invalidTokenArb, async (invalidToken) => { + // Put some entities in the DB so we can verify they aren't returned + mockFind.mockReturnValue([makeReturnEntity({ _key: "ret-0", id: "ret-0" })]); + + const result = await listReturnsHandler(makeValidationResult({ nextToken: invalidToken }), {} as any); + + expect(result.statusCode).toBe(200); + const body = result.data.body as Record; + const returns = body.returns as Record[]; + expect(returns).toEqual([]); + }), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.pbt.test.ts b/local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.pbt.test.ts new file mode 100644 index 000000000..f09e8f610 --- /dev/null +++ b/local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.pbt.test.ts @@ -0,0 +1,660 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; + +const mockFind = vi.fn<() => Record[]>().mockReturnValue([]); +const mockPut = vi.fn(); + +vi.mock("../../src/database/Context.js", () => ({ + Api: { EXT_FULFILLMENT_SHIPMENTS: "extFulfillmentShipments" }, + Context: { + get instance() { + return { engine: { find: mockFind, put: mockPut } }; + }, + }, +})); + +import { getShipmentsHandler } from "../../src/operation/extFulfillmentShipmentsOperations.js"; + +/** + * Creates a minimal valid UnifiedValidationPass object for testing. + */ +function makeValidationResult(queryParams: Record = {}): UnifiedValidationPass { + return { + pass: true, + operationId: "getShipments", + apiName: "External Fulfillment Shipments", + apiVersion: "2024-09-11", + pathParams: {}, + queryParams: Object.fromEntries(Object.entries(queryParams).filter(([_, v]) => v !== undefined)), + body: undefined, + resolvedEntities: {}, + operation: {}, + }; +} + +/** Valid shipment status values */ +const SHIPMENT_STATUSES = [ + "CREATED", + "ACCEPTED", + "CONFIRMED", + "PACKAGE_CREATED", + "PICKUP_SLOT_RETRIEVED", + "INVOICE_GENERATED", + "SHIPLABEL_GENERATED", + "CANCELLED", + "SHIPPED", + "DELIVERED", +] as const; + +/** Alphanumeric string arbitrary (1-20 chars) */ +const alphaNumStr = fc.stringMatching(/^[a-zA-Z0-9]{1,20}$/); + +/** Arbitrary for a single shipment entity */ +const shipmentEntityArb = fc + .record({ + id: fc.uuid(), + _key: fc.uuid(), + status: fc.constantFrom(...SHIPMENT_STATUSES), + locationId: alphaNumStr, + marketplaceAttributes: fc.record({ + marketplaceId: alphaNumStr, + channelName: alphaNumStr, + }), + lastUpdatedDateTime: fc.integer({ min: 1577836800000, max: 1893456000000 }).map((ts) => new Date(ts).toISOString()), + }) + .map((r) => ({ ...r }) as Record); + +/** Arbitrary for a list of 1-20 shipment entities */ +const shipmentEntitiesArb = fc.array(shipmentEntityArb, { minLength: 1, maxLength: 20 }); + +/** Describes an exact-match filter field and how to access its value from an entity */ +type FilterField = "status" | "locationId" | "marketplaceId" | "channelName"; + +const FILTER_FIELDS: FilterField[] = ["status", "locationId", "marketplaceId", "channelName"]; + +/** Extracts the value for a given filter field from an entity */ +function getEntityFieldValue(entity: Record, field: FilterField): unknown { + if (field === "marketplaceId") { + return (entity.marketplaceAttributes as Record | undefined)?.marketplaceId; + } + if (field === "channelName") { + return (entity.marketplaceAttributes as Record | undefined)?.channelName; + } + return entity[field]; +} + +describe("Property-Based Tests: External Fulfillment Shipments", () => { + beforeEach(() => { + mockFind.mockReturnValue([]); + mockPut.mockClear(); + }); + + /** + * **Validates: Requirements 1.2, 1.3, 1.4, 1.5** + * + * Property 1: Exact-match filter correctness + * For any set of generated shipment entities and any exact-match filter: + * - Every record in the response matches the filter value + * - No matching record from the DB is excluded from unpaginated results + */ + describe("Property 1: Exact-match filter correctness", () => { + it("every returned record matches the filter AND no matching record is excluded from unpaginated results", async () => { + await fc.assert( + fc.asyncProperty( + shipmentEntitiesArb, + fc.constantFrom(...FILTER_FIELDS), + fc.boolean(), + fc.nat({ max: 100 }), + async (entities, filterField, pickFromExisting, randomIdx) => { + // Determine filter value: either pick from existing entity values or generate random + let filterValue: string; + if (pickFromExisting && entities.length > 0) { + const idx = randomIdx % entities.length; + filterValue = String(getEntityFieldValue(entities[idx], filterField)); + } else { + filterValue = `random-value-${randomIdx}`; + } + + // Mock the DB to return our generated entities + mockFind.mockReturnValue(entities); + + // Build query params with the filter and a large maxResults to avoid pagination + const queryParams: Record = { maxResults: "100" }; + queryParams[filterField] = filterValue; + + const validationResult = makeValidationResult(queryParams); + const result = await getShipmentsHandler(validationResult, {} as never); + + expect(result.statusCode).toBe(200); + const body = result.data.body as { shipments: Record[] }; + + // Assertion 1: Every returned record matches the filter + for (const shipment of body.shipments) { + const actual = getEntityFieldValue(shipment, filterField); + expect(actual).toBe(filterValue); + } + + // Assertion 2: No matching record in the DB is excluded + const expectedMatching = entities.filter((e) => getEntityFieldValue(e, filterField) === filterValue); + expect(body.shipments.length).toBe(expectedMatching.length); + + // Verify IDs match (order-preserved) + const returnedIds = body.shipments.map((s) => s.id); + const expectedIds = expectedMatching.map((e) => e.id); + expect(returnedIds).toEqual(expectedIds); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 1.6, 1.7, 1.14** + * + * Property 2: Date range filter correctness + * For any set of generated shipment entities (some with lastUpdatedDateTime, some without) + * and any date boundary (lastUpdatedAfter and/or lastUpdatedBefore): + * - Every record in results has lastUpdatedDateTime satisfying the boundary + * - No record without lastUpdatedDateTime appears when date filter is applied + * - No record satisfying the boundary is missing from results + */ + describe("Property 2: Date range filter correctness", () => { + /** Timestamp arbitrary in millis range [2020-01-01, 2030-01-01] mapped to ISO string */ + const timestampArb = fc.integer({ min: 1577836800000, max: 1893456000000 }).map((ts) => new Date(ts).toISOString()); + + /** Entity arbitrary with optional lastUpdatedDateTime */ + const dateEntityArb = fc + .record({ + id: fc.uuid(), + _key: fc.uuid(), + status: fc.constantFrom(...SHIPMENT_STATUSES), + locationId: alphaNumStr, + marketplaceAttributes: fc.record({ + marketplaceId: alphaNumStr, + channelName: alphaNumStr, + }), + lastUpdatedDateTime: fc.option(timestampArb, { nil: undefined }), + }) + .map((r) => ({ ...r }) as Record); + + it("lastUpdatedAfter: every returned record has timestamp strictly greater than boundary, and no qualifying record is missing", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(dateEntityArb, { minLength: 1, maxLength: 20 }), + timestampArb, + async (entities, afterBoundary) => { + mockFind.mockReturnValue(entities); + + const validationResult = makeValidationResult({ maxResults: "100", lastUpdatedAfter: afterBoundary }); + const result = await getShipmentsHandler(validationResult, {} as never); + + expect(result.statusCode).toBe(200); + const body = result.data.body as { shipments: Record[] }; + const afterTime = new Date(afterBoundary).getTime(); + + // Assertion 1: Every returned record has lastUpdatedDateTime > afterBoundary + for (const shipment of body.shipments) { + const ts = shipment.lastUpdatedDateTime as string; + expect(ts).toBeDefined(); + expect(new Date(ts).getTime()).toBeGreaterThan(afterTime); + } + + // Assertion 2: No record without lastUpdatedDateTime appears + for (const shipment of body.shipments) { + expect(shipment.lastUpdatedDateTime).toBeDefined(); + } + + // Assertion 3: No record satisfying boundary is missing + const expectedMatching = entities.filter((e) => { + const lud = e.lastUpdatedDateTime as string | undefined; + if (!lud) return false; + return new Date(lud).getTime() > afterTime; + }); + expect(body.shipments.length).toBe(expectedMatching.length); + const returnedIds = body.shipments.map((s) => s.id); + const expectedIds = expectedMatching.map((e) => e.id); + expect(returnedIds).toEqual(expectedIds); + }, + ), + { numRuns: 100 }, + ); + }); + + it("lastUpdatedBefore: every returned record has timestamp strictly less than boundary, and no qualifying record is missing", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(dateEntityArb, { minLength: 1, maxLength: 20 }), + timestampArb, + async (entities, beforeBoundary) => { + mockFind.mockReturnValue(entities); + + const validationResult = makeValidationResult({ maxResults: "100", lastUpdatedBefore: beforeBoundary }); + const result = await getShipmentsHandler(validationResult, {} as never); + + expect(result.statusCode).toBe(200); + const body = result.data.body as { shipments: Record[] }; + const beforeTime = new Date(beforeBoundary).getTime(); + + // Assertion 1: Every returned record has lastUpdatedDateTime < beforeBoundary + for (const shipment of body.shipments) { + const ts = shipment.lastUpdatedDateTime as string; + expect(ts).toBeDefined(); + expect(new Date(ts).getTime()).toBeLessThan(beforeTime); + } + + // Assertion 2: No record without lastUpdatedDateTime appears + for (const shipment of body.shipments) { + expect(shipment.lastUpdatedDateTime).toBeDefined(); + } + + // Assertion 3: No record satisfying boundary is missing + const expectedMatching = entities.filter((e) => { + const lud = e.lastUpdatedDateTime as string | undefined; + if (!lud) return false; + return new Date(lud).getTime() < beforeTime; + }); + expect(body.shipments.length).toBe(expectedMatching.length); + const returnedIds = body.shipments.map((s) => s.id); + const expectedIds = expectedMatching.map((e) => e.id); + expect(returnedIds).toEqual(expectedIds); + }, + ), + { numRuns: 100 }, + ); + }); + + it("combined lastUpdatedAfter + lastUpdatedBefore: only records within the range are returned", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(dateEntityArb, { minLength: 1, maxLength: 20 }), + timestampArb, + timestampArb, + async (entities, ts1, ts2) => { + // Ensure after < before for a valid range + const afterBoundary = ts1 < ts2 ? ts1 : ts2; + const beforeBoundary = ts1 < ts2 ? ts2 : ts1; + + mockFind.mockReturnValue(entities); + + const validationResult = makeValidationResult({ + maxResults: "100", + lastUpdatedAfter: afterBoundary, + lastUpdatedBefore: beforeBoundary, + }); + const result = await getShipmentsHandler(validationResult, {} as never); + + expect(result.statusCode).toBe(200); + const body = result.data.body as { shipments: Record[] }; + const afterTime = new Date(afterBoundary).getTime(); + const beforeTime = new Date(beforeBoundary).getTime(); + + // Every returned record satisfies both boundaries + for (const shipment of body.shipments) { + const ts = shipment.lastUpdatedDateTime as string; + expect(ts).toBeDefined(); + const recordTime = new Date(ts).getTime(); + expect(recordTime).toBeGreaterThan(afterTime); + expect(recordTime).toBeLessThan(beforeTime); + } + + // No qualifying record is missing + const expectedMatching = entities.filter((e) => { + const lud = e.lastUpdatedDateTime as string | undefined; + if (!lud) return false; + const t = new Date(lud).getTime(); + return t > afterTime && t < beforeTime; + }); + expect(body.shipments.length).toBe(expectedMatching.length); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 1.8** + * + * Property 3: Filter composition is conjunction + * For any set of generated entities and any random combination of 2+ filter parameters + * applied simultaneously, the result set equals the intersection of the result sets + * produced by applying each filter individually. + */ + describe("Property 3: Filter composition is conjunction", () => { + /** All 6 filter types */ + type FilterType = "status" | "locationId" | "marketplaceId" | "channelName" | "lastUpdatedAfter" | "lastUpdatedBefore"; + const ALL_FILTER_TYPES: FilterType[] = ["status", "locationId", "marketplaceId", "channelName", "lastUpdatedAfter", "lastUpdatedBefore"]; + + /** Generate a subset of 2-4 filters with values derived from entities */ + const filterSubsetArb = (entities: Record[]) => + fc.subarray([...ALL_FILTER_TYPES], { minLength: 2, maxLength: 4 }).chain((selectedFilters) => { + const valueArbs = selectedFilters.map((filterType): fc.Arbitrary => { + if (filterType === "status") { + return fc.constantFrom(...SHIPMENT_STATUSES); + } else if (filterType === "locationId") { + const existing = entities.map((e) => e.locationId as string).filter(Boolean); + return existing.length > 0 ? fc.constantFrom(...existing) : alphaNumStr; + } else if (filterType === "marketplaceId") { + const existing = entities + .map((e) => (e.marketplaceAttributes as Record | undefined)?.marketplaceId as string) + .filter(Boolean); + return existing.length > 0 ? fc.constantFrom(...existing) : alphaNumStr; + } else if (filterType === "channelName") { + const existing = entities + .map((e) => (e.marketplaceAttributes as Record | undefined)?.channelName as string) + .filter(Boolean); + return existing.length > 0 ? fc.constantFrom(...existing) : alphaNumStr; + } else { + // lastUpdatedAfter or lastUpdatedBefore + const minTs = new Date("2020-01-01T00:00:00.000Z").getTime(); + const maxTs = new Date("2030-01-01T00:00:00.000Z").getTime(); + return fc.integer({ min: minTs, max: maxTs }).map((ts) => new Date(ts).toISOString()); + } + }); + + return fc.tuple(...(valueArbs as [fc.Arbitrary, ...fc.Arbitrary[]])).map((values) => { + const filters: Record = {}; + selectedFilters.forEach((filterType, idx) => { + filters[filterType] = values[idx]; + }); + return filters; + }); + }); + + it("combined filter result equals intersection of individual filter results", async () => { + await fc.assert( + fc.asyncProperty( + shipmentEntitiesArb.chain((entities) => fc.tuple(fc.constant(entities), filterSubsetArb(entities))), + async ([entities, filters]) => { + mockFind.mockReturnValue(entities); + + // Call handler with all filters combined + const combinedQueryParams: Record = { maxResults: "100", ...filters }; + const combinedResult = await getShipmentsHandler(makeValidationResult(combinedQueryParams), {} as never); + expect(combinedResult.statusCode).toBe(200); + const combinedBody = combinedResult.data.body as { shipments: Record[] }; + const combinedIds = new Set(combinedBody.shipments.map((s) => s.id as string)); + + // Call handler with each filter individually and compute intersection + const filterEntries = Object.entries(filters); + let intersectionIds: Set | null = null; + + for (const [filterKey, filterValue] of filterEntries) { + mockFind.mockReturnValue(entities); + const singleQueryParams: Record = { maxResults: "100", [filterKey]: filterValue }; + const singleResult = await getShipmentsHandler(makeValidationResult(singleQueryParams), {} as never); + expect(singleResult.statusCode).toBe(200); + const singleBody = singleResult.data.body as { shipments: Record[] }; + const singleIds = new Set(singleBody.shipments.map((s) => s.id as string)); + + if (intersectionIds === null) { + intersectionIds = singleIds; + } else { + intersectionIds = new Set([...intersectionIds].filter((id: string) => singleIds.has(id))); + } + } + + // Assert combined results == intersection of individual results + const expectedIds = intersectionIds ?? new Set(); + expect(combinedIds.size).toBe(expectedIds.size); + for (const id of combinedIds) { + expect(expectedIds.has(id)).toBe(true); + } + for (const id of expectedIds) { + expect(combinedIds.has(id)).toBe(true); + } + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 1.9** + * + * Property 4: Page size invariant + * For any random maxResults in [1, 100] and any set of entities in the DB, + * the number of records returned in the shipments array never exceeds the + * effective page size. Also verifies that when maxResults is absent, the + * default page size of 10 is applied. + */ + describe("Property 4: Page size invariant", () => { + /** Entity arbitrary using integer-based timestamps to avoid invalid date issues */ + const pageSizeEntityArb = fc + .record({ + id: fc.uuid(), + _key: fc.uuid(), + status: fc.constantFrom(...SHIPMENT_STATUSES), + locationId: alphaNumStr, + marketplaceAttributes: fc.record({ + marketplaceId: alphaNumStr, + channelName: alphaNumStr, + }), + lastUpdatedDateTime: fc + .integer({ min: 1577836800000, max: 1893456000000 }) // 2020-01-01 to 2030-01-01 in millis + .map((ts) => new Date(ts).toISOString()), + }) + .map((r) => ({ ...r }) as Record); + + it("returned shipments count never exceeds the requested maxResults", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(pageSizeEntityArb, { minLength: 0, maxLength: 50 }), + fc.integer({ min: 1, max: 100 }), + async (entities, maxResults) => { + mockFind.mockReturnValue(entities); + + const validationResult = makeValidationResult({ maxResults: String(maxResults) }); + const result = await getShipmentsHandler(validationResult, {} as never); + + expect(result.statusCode).toBe(200); + const body = result.data.body as { shipments: Record[] }; + + // The number of returned shipments must never exceed the requested page size + expect(body.shipments.length).toBeLessThanOrEqual(maxResults); + }, + ), + { numRuns: 100 }, + ); + }); + + it("when maxResults is absent, default page size of 10 is applied", async () => { + await fc.assert( + fc.asyncProperty(fc.array(pageSizeEntityArb, { minLength: 0, maxLength: 50 }), async (entities) => { + mockFind.mockReturnValue(entities); + + // No maxResults param — default page size of 10 + const validationResult = makeValidationResult({}); + const result = await getShipmentsHandler(validationResult, {} as never); + + expect(result.statusCode).toBe(200); + const body = result.data.body as { shipments: Record[] }; + + // Should never exceed default page size of 10 + expect(body.shipments.length).toBeLessThanOrEqual(10); + }), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 1.10, 1.11** + * + * Property 5: Pagination completeness + * For any set of entities and any page size, iterating all pages by following nextToken + * until absent yields the complete filtered result set with: + * 1. No duplicates (each entity ID appears exactly once) + * 2. No omissions (all entities that should be in the result appear) + * 3. Correct order preserved across pages + */ + describe("Property 5: Pagination completeness", () => { + it("iterating all pages yields the complete result set with no duplicates or omissions, preserving order", async () => { + // Use a simpler entity arbitrary that avoids date edge cases + const paginationEntityArb = fc + .record({ + id: fc.uuid(), + _key: fc.uuid(), + status: fc.constantFrom(...SHIPMENT_STATUSES), + locationId: alphaNumStr, + marketplaceAttributes: fc.record({ + marketplaceId: alphaNumStr, + channelName: alphaNumStr, + }), + lastUpdatedDateTime: fc.integer({ min: 1577836800000, max: 1893456000000 }).map((ts) => new Date(ts).toISOString()), + }) + .map((r) => ({ ...r }) as Record); + + await fc.assert( + fc.asyncProperty( + // Generate 5-30 shipment entities + fc.array(paginationEntityArb, { minLength: 5, maxLength: 30 }), + // Small page size (1-5) to force multiple pages + fc.integer({ min: 1, max: 5 }), + async (entities, pageSize) => { + // Mock the DB to return our generated entities + mockFind.mockReturnValue(entities); + + // Collect all shipments across all pages + const allCollected: Record[] = []; + let nextToken: string | undefined = undefined; + + // Safety limit to prevent infinite loops + const maxPages = Math.ceil(entities.length / pageSize) + 2; + let pageCount = 0; + + do { + const queryParams: Record = { + maxResults: String(pageSize), + paginationToken: nextToken, + }; + + const validationResult = makeValidationResult(queryParams); + const result = await getShipmentsHandler(validationResult, {} as never); + + expect(result.statusCode).toBe(200); + const body = result.data.body as { shipments: Record[]; pagination?: { nextToken: string } }; + + // Each page should not exceed the page size + expect(body.shipments.length).toBeLessThanOrEqual(pageSize); + + allCollected.push(...body.shipments); + nextToken = body.pagination?.nextToken; + pageCount++; + + // Guard against infinite loops + expect(pageCount).toBeLessThanOrEqual(maxPages); + } while (nextToken !== undefined); + + // Assertion 1: No duplicates — each entity ID appears exactly once + const collectedIds = allCollected.map((s) => s.id); + const uniqueIds = new Set(collectedIds); + expect(uniqueIds.size).toBe(collectedIds.length); + + // Assertion 2: No omissions — all entities appear in the collected results + expect(allCollected.length).toBe(entities.length); + + // Assertion 3: Correct order preserved — order matches original entity order + const expectedIds = entities.map((e) => e.id); + expect(collectedIds).toEqual(expectedIds); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 1.12** + * + * Property 6: Invalid token graceful degradation + * For any string that is NOT a valid base64-encoded `{ offset: number }` JSON object, + * or encodes an offset beyond the filtered result set length, the handler returns + * HTTP 200 with an empty `shipments` array. + */ + describe("Property 6: Invalid token graceful degradation", () => { + /** Entity arbitrary using integer-based timestamps to avoid Invalid Date issues */ + const tokenTestEntityArb = fc + .record({ + id: fc.uuid(), + _key: fc.uuid(), + status: fc.constantFrom(...SHIPMENT_STATUSES), + locationId: alphaNumStr, + marketplaceAttributes: fc.record({ + marketplaceId: alphaNumStr, + channelName: alphaNumStr, + }), + lastUpdatedDateTime: fc.integer({ min: 1577836800000, max: 1893456000000 }).map((ts) => new Date(ts).toISOString()), + }) + .map((r) => ({ ...r }) as Record); + + /** Arbitrary: random strings that are NOT valid base64-encoded { offset: number } JSON */ + const invalidTokenArb = fc.oneof( + // Random ASCII strings (very unlikely to be valid base64 JSON with offset) + fc.string({ minLength: 1, maxLength: 50 }), + // Valid base64 of non-JSON content + fc.string({ minLength: 1, maxLength: 30 }).map((s) => Buffer.from(s).toString("base64")), + // Valid base64 of JSON without `offset` field + fc + .record({ notOffset: fc.integer(), name: fc.string() }) + .map((obj) => Buffer.from(JSON.stringify(obj)).toString("base64")), + // Valid base64 of JSON with offset that is not a number + fc.string({ minLength: 1, maxLength: 10 }).map((s) => Buffer.from(JSON.stringify({ offset: s })).toString("base64")), + // Valid base64 of JSON with negative offset + fc.integer({ min: -1000, max: -1 }).map((n) => Buffer.from(JSON.stringify({ offset: n })).toString("base64")), + ); + + it("returns 200 with empty shipments array for any non-valid pagination token", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(tokenTestEntityArb, { minLength: 1, maxLength: 10 }), + invalidTokenArb, + async (entities, invalidToken) => { + mockFind.mockReturnValue(entities); + + const validationResult = makeValidationResult({ + maxResults: "100", + paginationToken: invalidToken, + }); + const result = await getShipmentsHandler(validationResult, {} as never); + + expect(result.statusCode).toBe(200); + const body = result.data.body as { shipments: Record[] }; + expect(body.shipments).toEqual([]); + }, + ), + { numRuns: 100 }, + ); + }); + + it("returns 200 with empty shipments array when token offset >= filtered result count", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(tokenTestEntityArb, { minLength: 1, maxLength: 10 }), + fc.integer({ min: 0, max: 1000 }), + async (entities, extraOffset) => { + mockFind.mockReturnValue(entities); + + // Create a valid token but with offset >= entities.length + const outOfRangeOffset = entities.length + extraOffset; + const outOfRangeToken = Buffer.from(JSON.stringify({ offset: outOfRangeOffset })).toString("base64"); + + const validationResult = makeValidationResult({ + maxResults: "100", + paginationToken: outOfRangeToken, + }); + const result = await getShipmentsHandler(validationResult, {} as never); + + expect(result.statusCode).toBe(200); + const body = result.data.body as { shipments: Record[] }; + expect(body.shipments).toEqual([]); + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.pbt2.test.ts b/local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.pbt2.test.ts new file mode 100644 index 000000000..965b3b361 --- /dev/null +++ b/local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.pbt2.test.ts @@ -0,0 +1,462 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; + +const mockFind = vi.fn<() => Record[]>().mockReturnValue([]); +const mockPut = vi.fn(); + +vi.mock("../../src/database/Context.js", () => ({ + Api: { EXT_FULFILLMENT_SHIPMENTS: "extFulfillmentShipments" }, + Context: { + get instance() { + return { engine: { find: mockFind, put: mockPut } }; + }, + }, +})); + +import { + getShipmentHandler, + processShipmentHandler, + createPackagesHandler, + updatePackageHandler, + updatePackageStatusHandler, + retrieveShippingOptionsHandler, + generateInvoiceHandler, + generateShipLabelsHandler, +} from "../../src/operation/extFulfillmentShipmentsOperations.js"; + +function makeEntityValidationResult( + operationId: string, + resolvedEntities: Record> = {}, + queryParams: Record = {}, + body?: Record, + pathParams: Record = {}, +): UnifiedValidationPass { + return { + pass: true, + operationId, + apiName: "External Fulfillment Shipments", + apiVersion: "2024-09-11", + pathParams, + queryParams, + body, + resolvedEntities, + operation: {}, + }; +} + +/** Valid shipment status values */ +const SHIPMENT_STATUSES = [ + "CREATED", + "ACCEPTED", + "CONFIRMED", + "PACKAGE_CREATED", + "PICKUP_SLOT_RETRIEVED", + "INVOICE_GENERATED", + "SHIPLABEL_GENERATED", + "CANCELLED", + "SHIPPED", + "DELIVERED", +] as const; + +/** Alphanumeric string arbitrary (1-20 chars) */ +const alphaNumStr = fc.stringMatching(/^[a-zA-Z0-9]{1,20}$/); + +/** Arbitrary for a line item */ +const lineItemArb = fc.record({ + id: fc.uuid(), + quantity: fc.integer({ min: 1, max: 100 }), +}); + +/** Arbitrary for a shipment entity suitable for processShipment testing */ +const shipmentEntityArb = fc + .record({ + id: fc.uuid(), + _key: fc.uuid(), + status: fc.constantFrom(...SHIPMENT_STATUSES), + locationId: alphaNumStr, + marketplaceAttributes: fc.record({ + marketplaceId: alphaNumStr, + channelName: alphaNumStr, + }), + lineItems: fc.array(lineItemArb, { minLength: 1, maxLength: 5 }), + lastUpdatedDateTime: fc.constant("2020-01-01T00:00:00.000Z"), + }) + .map((r) => ({ ...r }) as Record); + +describe("Property-Based Tests: External Fulfillment Shipments (pbt2)", () => { + beforeEach(() => { + mockFind.mockReturnValue([]); + mockPut.mockClear(); + }); + + /** + * **Validates: Requirements 3.1, 3.2** + * + * Property 8: processShipment status transition + * For ANY shipment entity and ANY choice of CONFIRM or REJECT: + * 1. After CONFIRM: entity.status === "CONFIRMED" + * 2. After REJECT: entity.status === "CANCELLED" + * 3. In both cases: lastUpdatedDateTime is updated (different from original) + * 4. In both cases: handler returns 204 + * 5. In both cases: mockPut is called with the entity + */ + describe("Property 8: processShipment status transition", () => { + it("CONFIRM or REJECT sets the correct status, updates lastUpdatedDateTime, returns 204, and persists", async () => { + await fc.assert( + fc.asyncProperty(shipmentEntityArb, fc.constantFrom("CONFIRM", "REJECT"), async (entity, operation) => { + mockPut.mockClear(); + + // Store original timestamp for comparison + const originalTimestamp = entity.lastUpdatedDateTime as string; + + // Build the validation result with the entity in resolvedEntities + const validationResult = makeEntityValidationResult("processShipment", { shipment: entity }, { operation }, undefined, { + shipmentId: entity.id as string, + }); + + const result = await processShipmentHandler(validationResult, {} as never); + + // Assertion 1 & 2: Status is correctly set + if (operation === "CONFIRM") { + expect(entity.status).toBe("CONFIRMED"); + } else { + expect(entity.status).toBe("CANCELLED"); + } + + // Assertion 3: lastUpdatedDateTime has been updated from the original past date + expect(entity.lastUpdatedDateTime).not.toBe(originalTimestamp); + // Verify it's a valid ISO date string + expect(new Date(entity.lastUpdatedDateTime as string).toISOString()).toBe(entity.lastUpdatedDateTime); + + // Assertion 4: Handler returns 204 + expect(result.statusCode).toBe(204); + expect(result.data.body).toEqual({}); + + // Assertion 5: mockPut is called with the correct arguments + expect(mockPut).toHaveBeenCalledTimes(1); + expect(mockPut).toHaveBeenCalledWith("extFulfillmentShipments", entity.id, entity); + }), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 4.1, 4.2** + * + * Property 10: createPackages appends and preserves + * For ANY entity with zero or more existing packages and ANY array of new package objects: + * 1. After createPackages, entity.packages contains ALL previously existing packages followed by ALL new packages + * 2. entity.status === "PACKAGE_CREATED" + * 3. entity.lastUpdatedDateTime is updated + * 4. Handler returns 204 + * 5. mockPut is called with the entity + */ + describe("Property 10: createPackages appends and preserves", () => { + /** Arbitrary for a single package object */ + const packageArb = fc.record({ + id: fc.uuid(), + weight: fc.integer({ min: 1, max: 1000 }).map((n) => n / 10), + dimensions: fc.record({ + length: fc.integer({ min: 1, max: 200 }), + width: fc.integer({ min: 1, max: 200 }), + height: fc.integer({ min: 1, max: 200 }), + }), + }); + + /** Arbitrary for existing packages (0-5 items) */ + const existingPackagesArb = fc.array(packageArb, { minLength: 0, maxLength: 5 }); + + /** Arbitrary for new packages to add (1-5 items) */ + const newPackagesArb = fc.array(packageArb, { minLength: 1, maxLength: 5 }); + + /** Arbitrary for entity with packages field present */ + const entityWithPackagesArb = fc + .tuple( + fc.record({ + id: fc.uuid(), + _key: fc.uuid(), + status: fc.constantFrom(...SHIPMENT_STATUSES), + locationId: alphaNumStr, + lastUpdatedDateTime: fc.constant("2020-01-01T00:00:00.000Z"), + }), + existingPackagesArb, + ) + .map(([base, packages]) => ({ ...base, packages }) as Record); + + /** Arbitrary for entity with packages undefined or null (tests initialization) */ + const entityWithoutPackagesArb = fc + .record({ + id: fc.uuid(), + _key: fc.uuid(), + status: fc.constantFrom(...SHIPMENT_STATUSES), + locationId: alphaNumStr, + lastUpdatedDateTime: fc.constant("2020-01-01T00:00:00.000Z"), + }) + .chain((base) => + fc.constantFrom(undefined, null).map((packagesValue) => { + const entity = { ...base } as Record; + entity.packages = packagesValue; + return entity; + }), + ); + + it("appends new packages to existing packages, sets status to PACKAGE_CREATED, updates timestamp, returns 204, and persists", async () => { + await fc.assert( + fc.asyncProperty(entityWithPackagesArb, newPackagesArb, async (entity, newPackages) => { + mockPut.mockClear(); + + // Snapshot existing packages before mutation + const existingPackages = [...(entity.packages as Record[])]; + const originalTimestamp = entity.lastUpdatedDateTime as string; + + const validationResult = makeEntityValidationResult( + "createPackages", + { shipment: entity }, + {}, + { packages: newPackages }, + { shipmentId: entity.id as string }, + ); + + const result = await createPackagesHandler(validationResult, {} as never); + + // Assertion 1: entity.packages contains all existing + all new packages in order + const finalPackages = entity.packages as Record[]; + expect(finalPackages.length).toBe(existingPackages.length + newPackages.length); + + // Existing packages are preserved at the start + for (let i = 0; i < existingPackages.length; i++) { + expect(finalPackages[i]).toEqual(existingPackages[i]); + } + // New packages are appended after existing + for (let i = 0; i < newPackages.length; i++) { + expect(finalPackages[existingPackages.length + i]).toEqual(newPackages[i]); + } + + // Assertion 2: status is PACKAGE_CREATED + expect(entity.status).toBe("PACKAGE_CREATED"); + + // Assertion 3: lastUpdatedDateTime is updated + expect(entity.lastUpdatedDateTime).not.toBe(originalTimestamp); + expect(new Date(entity.lastUpdatedDateTime as string).toISOString()).toBe(entity.lastUpdatedDateTime); + + // Assertion 4: handler returns 204 + expect(result.statusCode).toBe(204); + expect(result.data.body).toEqual({}); + + // Assertion 5: mockPut is called with the entity + expect(mockPut).toHaveBeenCalledTimes(1); + expect(mockPut).toHaveBeenCalledWith("extFulfillmentShipments", entity.id, entity); + }), + { numRuns: 100 }, + ); + }); + + it("initializes packages array when undefined or null, then appends new packages correctly", async () => { + await fc.assert( + fc.asyncProperty(entityWithoutPackagesArb, newPackagesArb, async (entity, newPackages) => { + mockPut.mockClear(); + + const originalTimestamp = entity.lastUpdatedDateTime as string; + + const validationResult = makeEntityValidationResult( + "createPackages", + { shipment: entity }, + {}, + { packages: newPackages }, + { shipmentId: entity.id as string }, + ); + + const result = await createPackagesHandler(validationResult, {} as never); + + // Assertion 1: entity.packages contains exactly the new packages (started from empty) + const finalPackages = entity.packages as Record[]; + expect(finalPackages.length).toBe(newPackages.length); + for (let i = 0; i < newPackages.length; i++) { + expect(finalPackages[i]).toEqual(newPackages[i]); + } + + // Assertion 2: status is PACKAGE_CREATED + expect(entity.status).toBe("PACKAGE_CREATED"); + + // Assertion 3: lastUpdatedDateTime is updated + expect(entity.lastUpdatedDateTime).not.toBe(originalTimestamp); + expect(new Date(entity.lastUpdatedDateTime as string).toISOString()).toBe(entity.lastUpdatedDateTime); + + // Assertion 4: handler returns 204 + expect(result.statusCode).toBe(204); + expect(result.data.body).toEqual({}); + + // Assertion 5: mockPut is called with the entity + expect(mockPut).toHaveBeenCalledTimes(1); + expect(mockPut).toHaveBeenCalledWith("extFulfillmentShipments", entity.id, entity); + }), + { numRuns: 100 }, + ); + }); + }); +}); + +describe("Property-Based Tests: External Fulfillment Shipments (REJECT lineItem cancellation)", () => { + beforeEach(() => { + mockFind.mockReturnValue([]); + mockPut.mockClear(); + }); + + /** Cancellation reasons */ + const CANCELLATION_REASONS = ["OUT_OF_STOCK", "CUSTOMER_REQUESTED"] as const; + + /** Arbitrary for an existing cancellation entry */ + const existingCancellationArb = fc.record({ + reason: fc.constantFrom(...CANCELLATION_REASONS), + cancelledQuantity: fc.integer({ min: 1, max: 50 }), + cancelledAt: fc.integer({ min: 1577836800000, max: 1893456000000 }).map((ts) => new Date(ts).toISOString()), + }); + + /** Arbitrary for a line item with existing cancellations (0-3) */ + const lineItemWithCancellationsArb = fc.record({ + id: fc.uuid(), + quantity: fc.integer({ min: 1, max: 100 }), + cancellations: fc.array(existingCancellationArb, { minLength: 0, maxLength: 3 }), + }); + + /** Arbitrary for an entity with 1-10 line items that have cancellations */ + const entityWithLineItemsArb = fc + .record({ + _key: fc.uuid(), + id: fc.uuid(), + status: fc.constantFrom("CREATED", "ACCEPTED"), + lineItems: fc.array(lineItemWithCancellationsArb, { minLength: 1, maxLength: 10 }), + lastUpdatedDateTime: fc.integer({ min: 1577836800000, max: 1893456000000 }).map((ts) => new Date(ts).toISOString()), + }) + .map((r) => r as Record); + + /** + * **Validates: Requirements 3.3, 3.4** + * + * Property 9: REJECT lineItem cancellation append + * For ANY entity with N line items (some with existing cancellations) and ANY subset + * of line item IDs in the request: + * 1. Each matched line item has exactly one NEW cancellation appended (with correct reason, + * quantity, and cancelledAt timestamp) + * 2. Non-matching IDs in the request are skipped without error + * 3. Previously existing cancellations on ALL line items are preserved (not overwritten or removed) + * 4. Line items not referenced in the request body remain unchanged + */ + describe("Property 9: REJECT lineItem cancellation append", () => { + it("matched items get cancellation appended, non-matching skipped, existing cancellations preserved", async () => { + await fc.assert( + fc.asyncProperty( + entityWithLineItemsArb.chain((entity) => { + const lineItems = entity.lineItems as Array<{ id: string; quantity: number; cancellations: unknown[] }>; + const entityLineItemIds = lineItems.map((li) => li.id); + + // Generate a request with a mix of matching and non-matching IDs + const matchingIdsArb = fc.subarray(entityLineItemIds, { minLength: 0 }).chain((ids) => + fc.tuple( + fc.constant(ids), + fc.array(fc.integer({ min: 1, max: 50 }), { minLength: ids.length, maxLength: ids.length }), + ), + ); + const nonMatchingIdsArb = fc.array(fc.uuid(), { minLength: 0, maxLength: 3 }).chain((ids) => + fc.tuple( + fc.constant(ids), + fc.array(fc.integer({ min: 1, max: 50 }), { minLength: ids.length, maxLength: ids.length }), + ), + ); + + return fc.tuple(fc.constant(entity), matchingIdsArb, nonMatchingIdsArb, fc.constantFrom(...CANCELLATION_REASONS)); + }), + async ([entity, [matchingIds, matchingQuantities], [nonMatchingIds, nonMatchingQuantities], reason]) => { + // Deep clone the entity so we can compare before/after + const entityClone = JSON.parse(JSON.stringify(entity)) as Record; + const originalLineItems = JSON.parse(JSON.stringify(entity)) as Record; + + // Build request body lineItems combining matching and non-matching IDs + const requestLineItems = [ + ...matchingIds.map((id, i) => ({ + lineItem: { id, quantity: matchingQuantities[i] }, + reason, + })), + ...nonMatchingIds + .filter((id) => !matchingIds.includes(id) && !(entity.lineItems as Array<{ id: string }>).some((li) => li.id === id)) + .map((id, i) => ({ + lineItem: { id, quantity: nonMatchingQuantities[i] }, + reason, + })), + ]; + + const validationResult = makeEntityValidationResult( + "processShipment", + { shipment: entityClone }, + { operation: "REJECT" }, + { lineItems: requestLineItems } as unknown as Record, + ); + + const result = await processShipmentHandler(validationResult, {} as never); + + // Handler should return 204 (success, no body) + expect(result.statusCode).toBe(204); + + // Get the mutated entity from the validation result (handler mutates in place) + const mutatedEntity = entityClone; + const mutatedLineItems = mutatedEntity.lineItems as Array<{ + id: string; + quantity: number; + cancellations: Array<{ reason: string; cancelledQuantity: number; cancelledAt: string }>; + }>; + const originalLineItemsArr = (originalLineItems as { lineItems: Array<{ id: string; quantity: number; cancellations: unknown[] }> }) + .lineItems; + + // Assertion 1: Each matched line item has exactly one NEW cancellation appended + for (const matchedId of matchingIds) { + const mutatedLi = mutatedLineItems.find((li) => li.id === matchedId)!; + const originalLi = originalLineItemsArr.find((li) => li.id === matchedId)!; + const originalCancellationCount = originalLi.cancellations.length; + + // Should have exactly one more cancellation than before + expect(mutatedLi.cancellations.length).toBe(originalCancellationCount + 1); + + // The new cancellation should be the last one + const newCancellation = mutatedLi.cancellations[mutatedLi.cancellations.length - 1]; + expect(newCancellation.reason).toBe(reason); + + // Find the request entry for this ID to verify quantity + const requestEntry = requestLineItems.find((rl) => rl.lineItem.id === matchedId)!; + expect(newCancellation.cancelledQuantity).toBe(requestEntry.lineItem.quantity); + + // cancelledAt should be a valid ISO date string + expect(new Date(newCancellation.cancelledAt).toISOString()).toBe(newCancellation.cancelledAt); + } + + // Assertion 2: Non-matching IDs in the request are skipped without error + // (The handler returned 204, so no error was thrown) + + // Assertion 3: Previously existing cancellations on ALL line items are preserved + for (const originalLi of originalLineItemsArr) { + const mutatedLi = mutatedLineItems.find((li) => li.id === originalLi.id)!; + // All original cancellations should still be present at the beginning of the array + for (let i = 0; i < originalLi.cancellations.length; i++) { + expect(mutatedLi.cancellations[i]).toEqual(originalLi.cancellations[i]); + } + } + + // Assertion 4: Line items not referenced in the request body remain unchanged + const requestedIds = new Set(requestLineItems.map((rl) => rl.lineItem.id)); + for (const originalLi of originalLineItemsArr) { + if (!requestedIds.has(originalLi.id)) { + const mutatedLi = mutatedLineItems.find((li) => li.id === originalLi.id)!; + // Should have same number of cancellations (no new ones added) + expect(mutatedLi.cancellations.length).toBe(originalLi.cancellations.length); + // All cancellations should be identical + expect(mutatedLi.cancellations).toEqual(originalLi.cancellations); + } + } + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.pbt3.test.ts b/local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.pbt3.test.ts new file mode 100644 index 000000000..a1fab479d --- /dev/null +++ b/local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.pbt3.test.ts @@ -0,0 +1,686 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; + +const mockFind = vi.fn<() => Record[]>().mockReturnValue([]); +const mockPut = vi.fn(); + +vi.mock("../../src/database/Context.js", () => ({ + Api: { EXT_FULFILLMENT_SHIPMENTS: "extFulfillmentShipments" }, + Context: { + get instance() { + return { engine: { find: mockFind, put: mockPut } }; + }, + }, +})); + +import { updatePackageStatusHandler, retrieveShippingOptionsHandler, generateInvoiceHandler, generateShipLabelsHandler } from "../../src/operation/extFulfillmentShipmentsOperations.js"; + +function makeEntityValidationResult( + operationId: string, + resolvedEntities: Record> = {}, + queryParams: Record = {}, + body?: Record, + pathParams: Record = {}, +): UnifiedValidationPass { + return { + pass: true, + operationId, + apiName: "External Fulfillment Shipments", + apiVersion: "2024-09-11", + pathParams, + queryParams, + body, + resolvedEntities, + operation: {}, + }; +} + +/** Valid package statuses for testing */ +const PACKAGE_STATUSES = ["CREATED", "SHIPPED", "DELIVERED", "IN_TRANSIT", "RETURNED"] as const; + +/** Non-propagating statuses (everything except SHIPPED and DELIVERED) */ +const NON_PROPAGATING_STATUSES = ["CREATED", "IN_TRANSIT", "RETURNED"] as const; + +/** Valid shipment status values */ +const SHIPMENT_STATUSES = [ + "CREATED", + "ACCEPTED", + "CONFIRMED", + "PACKAGE_CREATED", + "PICKUP_SLOT_RETRIEVED", + "INVOICE_GENERATED", + "SHIPLABEL_GENERATED", + "CANCELLED", + "SHIPPED", + "DELIVERED", +] as const; + +/** Alphanumeric string arbitrary (1-20 chars) */ +const alphaNumStr = fc.stringMatching(/^[a-zA-Z0-9]{1,20}$/); + +describe("Property-Based Tests: External Fulfillment Shipments (pbt3)", () => { + beforeEach(() => { + mockFind.mockReturnValue([]); + mockPut.mockClear(); + }); + + /** + * **Validates: Requirements 6.4, 6.5** + * + * Property 13: Shipment status propagation + * For ANY entity with N packages (N >= 2) in various statuses: + * 1. If after the update, every package has status "SHIPPED" → shipment status becomes "SHIPPED" + * 2. If after the update, every package has status "DELIVERED" → shipment status becomes "DELIVERED" + * 3. If packages have MIXED statuses (not all same propagating status) → shipment status is NOT changed by propagation + */ + describe("Property 13: Shipment status propagation", () => { + it("propagates to SHIPPED when all packages become SHIPPED after update", async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 2, max: 6 }).chain((numPackages) => { + // Generate an entity with numPackages where N-1 are already SHIPPED + // and the last one will be updated to SHIPPED + const packageIds = fc.tuple(...Array.from({ length: numPackages }, () => fc.uuid())); + const shipmentId = fc.uuid(); + const initialShipmentStatus = fc.constantFrom("CREATED", "CONFIRMED", "PACKAGE_CREATED", "IN_TRANSIT"); + + return fc.tuple(shipmentId, packageIds, initialShipmentStatus).map(([sId, pIds, initialStatus]) => { + // Build packages: all but last already have status SHIPPED + const packages = pIds.map((id, idx) => ({ + id, + status: idx < numPackages - 1 ? "SHIPPED" : "CREATED", // last one is NOT shipped yet + })); + const entity: Record = { + id: sId, + _key: sId, + status: initialStatus, + packages, + lastUpdatedDateTime: "2020-01-01T00:00:00.000Z", + }; + const targetPackageId = pIds[numPackages - 1]; + return { entity, targetPackageId }; + }); + }), + async ({ entity, targetPackageId }) => { + mockPut.mockClear(); + + const originalStatus = entity.status as string; + + const validationResult = makeEntityValidationResult( + "updatePackageStatus", + { shipment: entity }, + {}, + { status: "SHIPPED" }, + { shipmentId: entity.id as string, packageId: targetPackageId }, + ); + + await updatePackageStatusHandler(validationResult, {} as never); + + // After update, target package status should be SHIPPED + const packages = entity.packages as Array>; + const targetPkg = packages.find((p) => p.id === targetPackageId); + expect(targetPkg!.status).toBe("SHIPPED"); + + // All packages are now SHIPPED, so shipment status should propagate to SHIPPED + expect(entity.status).toBe("SHIPPED"); + expect(entity.status).not.toBe(originalStatus); + }, + ), + { numRuns: 100 }, + ); + }); + + it("propagates to DELIVERED when all packages become DELIVERED after update", async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 2, max: 6 }).chain((numPackages) => { + const packageIds = fc.tuple(...Array.from({ length: numPackages }, () => fc.uuid())); + const shipmentId = fc.uuid(); + const initialShipmentStatus = fc.constantFrom("SHIPPED", "CREATED", "CONFIRMED"); + + return fc.tuple(shipmentId, packageIds, initialShipmentStatus).map(([sId, pIds, initialStatus]) => { + // All but last already have status DELIVERED + const packages = pIds.map((id, idx) => ({ + id, + status: idx < numPackages - 1 ? "DELIVERED" : "SHIPPED", // last one is NOT delivered yet + })); + const entity: Record = { + id: sId, + _key: sId, + status: initialStatus, + packages, + lastUpdatedDateTime: "2020-01-01T00:00:00.000Z", + }; + const targetPackageId = pIds[numPackages - 1]; + return { entity, targetPackageId }; + }); + }), + async ({ entity, targetPackageId }) => { + mockPut.mockClear(); + + const validationResult = makeEntityValidationResult( + "updatePackageStatus", + { shipment: entity }, + {}, + { status: "DELIVERED" }, + { shipmentId: entity.id as string, packageId: targetPackageId }, + ); + + await updatePackageStatusHandler(validationResult, {} as never); + + // After update, target package status should be DELIVERED + const packages = entity.packages as Array>; + const targetPkg = packages.find((p) => p.id === targetPackageId); + expect(targetPkg!.status).toBe("DELIVERED"); + + // All packages are now DELIVERED, so shipment status should propagate to DELIVERED + expect(entity.status).toBe("DELIVERED"); + }, + ), + { numRuns: 100 }, + ); + }); + + it("does NOT propagate when packages have mixed statuses after update", async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 2, max: 6 }).chain((numPackages) => { + const packageIds = fc.tuple(...Array.from({ length: numPackages }, () => fc.uuid())); + const shipmentId = fc.uuid(); + const initialShipmentStatus = fc.constantFrom("CREATED", "CONFIRMED", "PACKAGE_CREATED"); + // The target status we'll set on the updated package + const targetStatus = fc.constantFrom("SHIPPED", "DELIVERED"); + // A different status for at least one other package to ensure mix + const differentStatus = fc.constantFrom(...NON_PROPAGATING_STATUSES); + + return fc.tuple(shipmentId, packageIds, initialShipmentStatus, targetStatus, differentStatus).map( + ([sId, pIds, shipmentStatus, tStatus, dStatus]) => { + // Build packages: set first package to a DIFFERENT status from targetStatus + // to guarantee mixed statuses after the update + const packages = pIds.map((id, idx) => { + if (idx === 0) { + // This package will have a status that differs from targetStatus + return { id, status: dStatus }; + } + // All other packages (including target at the end) get some status + return { id, status: "CREATED" }; + }); + const entity: Record = { + id: sId, + _key: sId, + status: shipmentStatus, + packages, + lastUpdatedDateTime: "2020-01-01T00:00:00.000Z", + }; + // Target is the LAST package (index numPackages - 1) + const targetPackageId = pIds[numPackages - 1]; + return { entity, targetPackageId, targetStatus: tStatus, originalShipmentStatus: shipmentStatus }; + }, + ); + }), + async ({ entity, targetPackageId, targetStatus, originalShipmentStatus }) => { + mockPut.mockClear(); + + const validationResult = makeEntityValidationResult( + "updatePackageStatus", + { shipment: entity }, + {}, + { status: targetStatus }, + { shipmentId: entity.id as string, packageId: targetPackageId }, + ); + + await updatePackageStatusHandler(validationResult, {} as never); + + // After update, target package should have the new status + const packages = entity.packages as Array>; + const targetPkg = packages.find((p) => p.id === targetPackageId); + expect(targetPkg!.status).toBe(targetStatus); + + // But since packages[0] has a non-propagating status, shipment should NOT propagate + // It should remain at its original status + expect(entity.status).toBe(originalShipmentStatus); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 7.1, 7.2, 7.3** + * + * Property 14: retrieveShippingOptions determinism + * For ANY entity with random shippingType: + * 1. If shippingInfo.shippingType === "MARKETPLACE": returns exactly one shipping option + * with deterministic shippingOptionId = `so-{shipmentId}-{packageId}`, correct fixed values + * (carrierName: "ATS", etc.), AND recommendedShippingOption equals that option + * 2. If shippingType is NOT "MARKETPLACE" (including undefined, null, "SELF_SHIP", random strings): + * returns empty shippingOptions array with NO recommendedShippingOption field + * 3. The output is always deterministic — same inputs produce same outputs (call twice, verify identical) + */ + describe("Property 14: retrieveShippingOptions determinism", () => { + it("MARKETPLACE shippingType returns deterministic shipping option with correct fields", async () => { + await fc.assert( + fc.asyncProperty(alphaNumStr, alphaNumStr, async (shipmentId, packageId) => { + const entity: Record = { + id: shipmentId, + _key: "some-key", + shippingInfo: { shippingType: "MARKETPLACE" }, + }; + + const validationResult = makeEntityValidationResult("retrieveShippingOptions", { shipment: entity }, {}, undefined, { + shipmentId, + packageId, + }); + + const result = await retrieveShippingOptionsHandler(validationResult, {} as never); + + // Should return 200 + expect(result.statusCode).toBe(200); + + const body = result.data.body as { + shippingOptions: Array>; + recommendedShippingOption: Record; + }; + + // Exactly one shipping option + expect(body.shippingOptions).toHaveLength(1); + + const option = body.shippingOptions[0]; + const expectedOptionId = `so-${shipmentId}-${packageId}`; + + // Deterministic shippingOptionId + expect(option.shippingOptionId).toBe(expectedOptionId); + + // Fixed values + expect(option.carrierName).toBe("ATS"); + expect(option.shipBy).toBe("MARKETPLACE"); + expect(option.pickupWindow).toEqual({ startTime: "1612933142", endTime: "1612494142" }); + expect(option.timeSlot).toEqual({ startTime: "1612933142", endTime: "1612494142", handoverMethod: "PICKUP" }); + + // recommendedShippingOption equals the option + expect(body.recommendedShippingOption).toEqual(option); + }), + { numRuns: 100 }, + ); + }); + + it("non-MARKETPLACE shippingType returns empty shippingOptions with no recommendedShippingOption", async () => { + const nonMarketplaceShippingTypeArb = fc.oneof( + fc.constant("SELF_SHIP"), + alphaNumStr.filter((s) => s !== "MARKETPLACE"), + fc.constant(""), + ); + + await fc.assert( + fc.asyncProperty(alphaNumStr, alphaNumStr, nonMarketplaceShippingTypeArb, async (shipmentId, packageId, shippingType) => { + const entity: Record = { + id: shipmentId, + _key: "some-key", + shippingInfo: { shippingType }, + }; + + const validationResult = makeEntityValidationResult("retrieveShippingOptions", { shipment: entity }, {}, undefined, { + shipmentId, + packageId, + }); + + const result = await retrieveShippingOptionsHandler(validationResult, {} as never); + + // Should return 200 + expect(result.statusCode).toBe(200); + + const body = result.data.body as Record; + + // Empty shippingOptions array + expect(body.shippingOptions).toEqual([]); + + // No recommendedShippingOption field + expect(body).not.toHaveProperty("recommendedShippingOption"); + }), + { numRuns: 100 }, + ); + }); + + it("undefined or missing shippingInfo returns empty shippingOptions with no recommendedShippingOption", async () => { + const missingShippingInfoArb = fc.oneof( + fc.constant(undefined as unknown as Record), + fc.constant({} as Record), + ); + + await fc.assert( + fc.asyncProperty(alphaNumStr, alphaNumStr, missingShippingInfoArb, async (shipmentId, packageId, shippingInfo) => { + const entity: Record = { + id: shipmentId, + _key: "some-key", + }; + if (shippingInfo !== undefined) { + entity.shippingInfo = shippingInfo; + } + + const validationResult = makeEntityValidationResult("retrieveShippingOptions", { shipment: entity }, {}, undefined, { + shipmentId, + packageId, + }); + + const result = await retrieveShippingOptionsHandler(validationResult, {} as never); + + // Should return 200 + expect(result.statusCode).toBe(200); + + const body = result.data.body as Record; + + // Empty shippingOptions array + expect(body.shippingOptions).toEqual([]); + + // No recommendedShippingOption field + expect(body).not.toHaveProperty("recommendedShippingOption"); + }), + { numRuns: 100 }, + ); + }); + + it("calling the handler twice with the same inputs produces identical outputs (determinism)", async () => { + const shippingTypeArb = fc.oneof(fc.constant("MARKETPLACE"), fc.constant("SELF_SHIP"), alphaNumStr); + + await fc.assert( + fc.asyncProperty(alphaNumStr, alphaNumStr, shippingTypeArb, async (shipmentId, packageId, shippingType) => { + const makeEntity = () => ({ + id: shipmentId, + _key: "some-key", + shippingInfo: { shippingType }, + }); + + const makeValidation = (entity: Record) => + makeEntityValidationResult("retrieveShippingOptions", { shipment: entity }, {}, undefined, { + shipmentId, + packageId, + }); + + // Call 1 + const entity1 = makeEntity(); + const result1 = await retrieveShippingOptionsHandler(makeValidation(entity1), {} as never); + + // Call 2 (fresh entity with same data) + const entity2 = makeEntity(); + const result2 = await retrieveShippingOptionsHandler(makeValidation(entity2), {} as never); + + // Both calls must produce identical status code and body + expect(result1.statusCode).toBe(result2.statusCode); + expect(result1.data.body).toEqual(result2.data.body); + }), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 8.2** + * + * Property 15: generateInvoice state mutation + * For ANY entity (with or without existing `shipmentRequirements` path): + * 1. After calling generateInvoice, `entity.shipmentRequirements.invoice.status` === "AVAILABLE" + * 2. `entity.lastUpdatedDateTime` is updated from the original value + * 3. Handler calls mockPut with the entity + * 4. Handler returns 200 with document response + * 5. The nested path is created regardless of whether `shipmentRequirements`, `shipmentRequirements.invoice`, or neither existed before + */ + describe("Property 15: generateInvoice state mutation", () => { + /** Entity WITHOUT shipmentRequirements field */ + const entityWithoutShipmentRequirements = fc + .record({ + id: fc.uuid(), + _key: fc.uuid(), + status: fc.constantFrom(...SHIPMENT_STATUSES), + locationId: alphaNumStr, + lastUpdatedDateTime: fc.constant("2020-01-01T00:00:00.000Z"), + }) + .map((r) => r as Record); + + /** Entity WITH shipmentRequirements: {} (no invoice) */ + const entityWithEmptyShipmentRequirements = fc + .record({ + id: fc.uuid(), + _key: fc.uuid(), + status: fc.constantFrom(...SHIPMENT_STATUSES), + locationId: alphaNumStr, + lastUpdatedDateTime: fc.constant("2020-01-01T00:00:00.000Z"), + shipmentRequirements: fc.constant({}), + }) + .map((r) => r as Record); + + /** Entity WITH shipmentRequirements: { invoice: {} } (no status) */ + const entityWithInvoiceNoStatus = fc + .record({ + id: fc.uuid(), + _key: fc.uuid(), + status: fc.constantFrom(...SHIPMENT_STATUSES), + locationId: alphaNumStr, + lastUpdatedDateTime: fc.constant("2020-01-01T00:00:00.000Z"), + shipmentRequirements: fc.constant({ invoice: {} }), + }) + .map((r) => r as Record); + + /** Entity WITH shipmentRequirements: { invoice: { status: "NOT_AVAILABLE" } } (existing status) */ + const entityWithExistingInvoiceStatus = fc + .record({ + id: fc.uuid(), + _key: fc.uuid(), + status: fc.constantFrom(...SHIPMENT_STATUSES), + locationId: alphaNumStr, + lastUpdatedDateTime: fc.constant("2020-01-01T00:00:00.000Z"), + shipmentRequirements: fc.constant({ invoice: { status: "NOT_AVAILABLE" } }), + }) + .map((r) => r as Record); + + /** Use fc.oneof to cover all 4 cases */ + const entityArb = fc.oneof( + entityWithoutShipmentRequirements, + entityWithEmptyShipmentRequirements, + entityWithInvoiceNoStatus, + entityWithExistingInvoiceStatus, + ); + + it("sets invoice.status to AVAILABLE, updates timestamp, persists entity, and returns 200 with document", async () => { + await fc.assert( + fc.asyncProperty(entityArb, async (entity) => { + mockPut.mockClear(); + + const originalTimestamp = entity.lastUpdatedDateTime as string; + + const validationResult = makeEntityValidationResult("generateInvoice", { shipment: entity }, {}, undefined, { + shipmentId: entity.id as string, + }); + + const result = await generateInvoiceHandler(validationResult, { get: () => "localhost:9001" } as never); + + // Assertion 1: invoice.status is set to "AVAILABLE" + const requirements = entity.shipmentRequirements as Record; + expect(requirements).toBeDefined(); + const invoice = requirements.invoice as Record; + expect(invoice).toBeDefined(); + expect(invoice.status).toBe("AVAILABLE"); + + // Assertion 2: lastUpdatedDateTime is updated + expect(entity.lastUpdatedDateTime).not.toBe(originalTimestamp); + expect(new Date(entity.lastUpdatedDateTime as string).toISOString()).toBe(entity.lastUpdatedDateTime); + + // Assertion 3: mockPut called with correct args + expect(mockPut).toHaveBeenCalledTimes(1); + expect(mockPut).toHaveBeenCalledWith("extFulfillmentShipments", entity.id, entity); + + // Assertion 4: returns 200 with document response + expect(result.statusCode).toBe(200); + expect(result.data.body).toEqual({ + document: { format: "PDF", content: "http://localhost:9001/invoice.pdf" }, + }); + }), + { numRuns: 100 }, + ); + }); + }); + + /** + * **Validates: Requirements 9.1, 9.2, 9.3, 9.5** + * + * Property 16: generateShipLabels one-to-one mapping + * For ANY array of packageIds (1-20): + * 1. The response's `packageShipLabelList` contains exactly ONE entry per packageId with `status: "SUCCESS"` + * 2. The count of entries equals the count of packageIds (one-to-one mapping) + * 3. Each entry has the correct `packageId`, `shipLabelMetadata` (from courierSupportedAttributes), and `fileData.url` + * 4. The entity's status is updated to "SHIPLABEL_GENERATED" + * 5. The entity's `lastUpdatedDateTime` is updated + * 6. Non-matching packageIds (not in entity's packages array) STILL get SUCCESS entries + */ + describe("Property 16: generateShipLabels one-to-one mapping", () => { + it("produces exactly one SUCCESS entry per packageId with correct metadata and updates entity status", async () => { + // Generator for an entity with 1-10 packages + const entityPackagesArb = fc.array(fc.uuid(), { minLength: 1, maxLength: 10 }).map((ids) => + ids.map((id) => ({ id, status: "CREATED" })), + ); + + // Generator for courierSupportedAttributes (optional carrierName and trackingId) + const courierAttrsArb = fc.oneof( + fc.constant(undefined as { carrierName?: string; trackingId?: string } | undefined), + fc.record({ + carrierName: fc.oneof(fc.constant(undefined as string | undefined), alphaNumStr), + trackingId: fc.oneof(fc.constant(undefined as string | undefined), alphaNumStr), + }), + ); + + await fc.assert( + fc.asyncProperty( + fc.uuid(), + entityPackagesArb, + courierAttrsArb, + fc.constantFrom(...SHIPMENT_STATUSES), + fc.integer({ min: 1, max: 20 }).chain((numIds) => + // Generate a mix of existing package IDs from entity and random non-matching IDs + fc.tuple(fc.constant(numIds), fc.array(fc.uuid(), { minLength: numIds, maxLength: numIds })), + ), + async (shipmentId, entityPackages, courierAttrs, initialStatus, [_numIds, randomPackageIds]) => { + mockPut.mockClear(); + + // Mix some existing entity package IDs with random ones + const existingIds = entityPackages.map((p) => p.id); + // Take a random subset of existing IDs (0 to all) and combine with random IDs + const mixedPackageIds = [ + ...existingIds.slice(0, Math.min(existingIds.length, Math.floor(randomPackageIds.length / 2))), + ...randomPackageIds.slice(0, Math.max(1, randomPackageIds.length - Math.floor(existingIds.length / 2))), + ]; + // Ensure at least 1 packageId + const packageIds = mixedPackageIds.length > 0 ? mixedPackageIds : [randomPackageIds[0]]; + + const entity: Record = { + id: shipmentId, + _key: shipmentId, + status: initialStatus, + packages: entityPackages, + lastUpdatedDateTime: "2020-01-01T00:00:00.000Z", + }; + + const body: Record = { packageIds }; + if (courierAttrs !== undefined) { + body.courierSupportedAttributes = courierAttrs; + } + + const validationResult = makeEntityValidationResult("generateShipLabels", { shipment: entity }, {}, body, { + shipmentId, + }); + + const result = await generateShipLabelsHandler(validationResult, { get: () => "localhost:9001" } as never); + + // Should return 200 + expect(result.statusCode).toBe(200); + + const responseBody = result.data.body as { packageShipLabelList: Array> }; + + // Assertion 1 & 2: one-to-one mapping — count equals packageIds count + expect(responseBody.packageShipLabelList).toHaveLength(packageIds.length); + + // Expected metadata values + const expectedCarrierName = courierAttrs?.carrierName ?? ""; + const expectedTrackingId = courierAttrs?.trackingId ?? ""; + + // Assertion 3: Each entry has correct packageId, metadata, fileData, and status + for (let i = 0; i < packageIds.length; i++) { + const entry = responseBody.packageShipLabelList[i]; + expect(entry.packageId).toBe(packageIds[i]); + expect(entry.status).toBe("SUCCESS"); + expect(entry.shipLabelMetadata).toEqual({ + carrierName: expectedCarrierName, + trackingId: expectedTrackingId, + }); + expect(entry.fileData).toEqual({ url: "http://localhost:9001/label.png" }); + } + + // Assertion 4: Entity status updated to SHIPLABEL_GENERATED + expect(entity.status).toBe("SHIPLABEL_GENERATED"); + + // Assertion 5: lastUpdatedDateTime is updated from original + expect(entity.lastUpdatedDateTime).not.toBe("2020-01-01T00:00:00.000Z"); + expect(new Date(entity.lastUpdatedDateTime as string).toISOString()).toBe(entity.lastUpdatedDateTime); + + // Assertion 6: mockPut called with the updated entity + expect(mockPut).toHaveBeenCalledTimes(1); + expect(mockPut).toHaveBeenCalledWith("extFulfillmentShipments", shipmentId, entity); + }, + ), + { numRuns: 100 }, + ); + }); + + it("non-matching packageIds (not in entity packages) still receive SUCCESS entries", async () => { + await fc.assert( + fc.asyncProperty( + fc.uuid(), + fc.array(fc.uuid(), { minLength: 1, maxLength: 5 }).map((ids) => ids.map((id) => ({ id, status: "CREATED" }))), + fc.array(fc.uuid(), { minLength: 1, maxLength: 10 }), + async (shipmentId, entityPackages, nonMatchingIds) => { + mockPut.mockClear(); + + // Ensure none of the nonMatchingIds are in the entity packages + const existingIdSet = new Set(entityPackages.map((p) => p.id)); + const trulyNonMatching = nonMatchingIds.filter((id) => !existingIdSet.has(id)); + // Skip if all randomly matched (extremely unlikely but handle gracefully) + if (trulyNonMatching.length === 0) return; + + const entity: Record = { + id: shipmentId, + _key: shipmentId, + status: "CONFIRMED", + packages: entityPackages, + lastUpdatedDateTime: "2020-01-01T00:00:00.000Z", + }; + + const validationResult = makeEntityValidationResult( + "generateShipLabels", + { shipment: entity }, + {}, + { packageIds: trulyNonMatching }, + { shipmentId }, + ); + + const result = await generateShipLabelsHandler(validationResult, { get: () => "localhost:9001" } as never); + + expect(result.statusCode).toBe(200); + + const responseBody = result.data.body as { packageShipLabelList: Array> }; + + // Every non-matching ID still gets a SUCCESS entry + expect(responseBody.packageShipLabelList).toHaveLength(trulyNonMatching.length); + for (let i = 0; i < trulyNonMatching.length; i++) { + expect(responseBody.packageShipLabelList[i].packageId).toBe(trulyNonMatching[i]); + expect(responseBody.packageShipLabelList[i].status).toBe("SUCCESS"); + } + + // Entity status still updated + expect(entity.status).toBe("SHIPLABEL_GENERATED"); + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.test.ts b/local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.test.ts new file mode 100644 index 000000000..917f61f65 --- /dev/null +++ b/local-ai-sandbox/test/operation/extFulfillmentShipmentsOperations.test.ts @@ -0,0 +1,559 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request } from "express"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; + +// Mock find and put functions — tests set the return value via mockFind/mockPut +const mockFind = vi.fn<() => Record[]>().mockReturnValue([]); +const mockPut = vi.fn(); + +/** Creates a minimal Express-like request mock with a `get` method. */ +function mockRequest(headers: Record = {}): Request { + return { get: (name: string) => headers[name.toLowerCase()] } as unknown as Request; +} + +// Mock the Context singleton so engine.find/put returns our controlled data +vi.mock("../../src/database/Context.js", () => ({ + Api: { EXT_FULFILLMENT_SHIPMENTS: "extFulfillmentShipments" }, + Context: { + get instance() { + return { + engine: { + find: mockFind, + put: mockPut, + }, + }; + }, + }, +})); + +import { + getShipmentHandler, + processShipmentHandler, + retrieveShippingOptionsHandler, + generateInvoiceHandler, + retrieveInvoiceHandler, + generateShipLabelsHandler, +} from "../../src/operation/extFulfillmentShipmentsOperations.js"; + +/** + * Creates a minimal valid UnifiedValidationPass object for testing. + */ +function makeValidationResult(overrides: Partial = {}): UnifiedValidationPass { + return { + pass: true, + operationId: "testOp", + apiName: "External Fulfillment Shipments", + apiVersion: "2024-09-11", + pathParams: {}, + queryParams: {}, + body: undefined, + resolvedEntities: {}, + operation: {}, + ...overrides, + }; +} + +describe("retrieveShippingOptionsHandler", () => { + beforeEach(() => { + mockFind.mockReturnValue([]); + mockPut.mockClear(); + }); + + it("returns shipping option with deterministic ID for MARKETPLACE type", async () => { + const entity = { + id: "ship-1", + _key: "ship-1", + shippingInfo: { shippingType: "MARKETPLACE" }, + }; + + const result = await retrieveShippingOptionsHandler( + makeValidationResult({ + pathParams: { shipmentId: "ship-1", packageId: "pkg-1" }, + resolvedEntities: { shipment: entity }, + }), + {} as any, + ); + + expect(result.statusCode).toBe(200); + const body = result.data.body as any; + expect(body.shippingOptions).toHaveLength(1); + expect(body.shippingOptions[0].shippingOptionId).toBe("so-ship-1-pkg-1"); + expect(body.shippingOptions[0].carrierName).toBe("ATS"); + }); + + it("includes recommendedShippingOption for MARKETPLACE", async () => { + const entity = { + id: "ship-1", + _key: "ship-1", + shippingInfo: { shippingType: "MARKETPLACE" }, + }; + + const result = await retrieveShippingOptionsHandler( + makeValidationResult({ + pathParams: { shipmentId: "ship-1", packageId: "pkg-1" }, + resolvedEntities: { shipment: entity }, + }), + {} as any, + ); + + const body = result.data.body as any; + expect(body.recommendedShippingOption).toBeDefined(); + expect(body.recommendedShippingOption.shippingOptionId).toBe("so-ship-1-pkg-1"); + expect(body.recommendedShippingOption).toEqual(body.shippingOptions[0]); + }); + + it("returns empty shippingOptions for non-MARKETPLACE type", async () => { + const entity = { + id: "ship-1", + _key: "ship-1", + shippingInfo: { shippingType: "SELF_SHIP" }, + }; + + const result = await retrieveShippingOptionsHandler( + makeValidationResult({ + pathParams: { shipmentId: "ship-1", packageId: "pkg-1" }, + resolvedEntities: { shipment: entity }, + }), + {} as any, + ); + + expect(result.statusCode).toBe(200); + const body = result.data.body as any; + expect(body.shippingOptions).toEqual([]); + }); + + it("does not include recommendedShippingOption for non-MARKETPLACE", async () => { + const entity = { + id: "ship-1", + _key: "ship-1", + shippingInfo: { shippingType: "SELF_SHIP" }, + }; + + const result = await retrieveShippingOptionsHandler( + makeValidationResult({ + pathParams: { shipmentId: "ship-1", packageId: "pkg-1" }, + resolvedEntities: { shipment: entity }, + }), + {} as any, + ); + + const body = result.data.body as any; + expect(body.recommendedShippingOption).toBeUndefined(); + }); +}); + +describe("generateInvoiceHandler", () => { + beforeEach(() => { + mockFind.mockReturnValue([]); + mockPut.mockClear(); + }); + + it("returns correct document format and content", async () => { + const entity = { id: "ship-1", _key: "ship-1", status: "CONFIRMED" }; + + const result = await generateInvoiceHandler( + makeValidationResult({ + resolvedEntities: { shipment: entity }, + }), + mockRequest({ host: "localhost:9001" }), + ); + + expect(result.statusCode).toBe(200); + const body = result.data.body as any; + expect(body.document).toEqual({ format: "PDF", content: "http://localhost:9001/invoice.pdf" }); + }); + + it("sets shipmentRequirements.invoice.status to AVAILABLE", async () => { + const entity: Record = { id: "ship-1", _key: "ship-1", status: "CONFIRMED" }; + + await generateInvoiceHandler( + makeValidationResult({ + resolvedEntities: { shipment: entity }, + }), + mockRequest({ host: "localhost:9001" }), + ); + + const requirements = entity.shipmentRequirements as any; + expect(requirements.invoice.status).toBe("AVAILABLE"); + }); + + it("creates intermediate objects when shipmentRequirements is absent", async () => { + const entity: Record = { id: "ship-1", _key: "ship-1", status: "CONFIRMED" }; + // Ensure shipmentRequirements is not present + expect(entity.shipmentRequirements).toBeUndefined(); + + await generateInvoiceHandler( + makeValidationResult({ + resolvedEntities: { shipment: entity }, + }), + mockRequest({ host: "localhost:9001" }), + ); + + // Intermediate objects should be created + expect(entity.shipmentRequirements).toBeDefined(); + const requirements = entity.shipmentRequirements as any; + expect(requirements.invoice).toBeDefined(); + expect(requirements.invoice.status).toBe("AVAILABLE"); + }); + + it("calls put to persist changes", async () => { + const entity = { id: "ship-1", _key: "ship-1", status: "CONFIRMED" }; + + await generateInvoiceHandler( + makeValidationResult({ + resolvedEntities: { shipment: entity }, + }), + mockRequest({ host: "localhost:9001" }), + ); + + expect(mockPut).toHaveBeenCalledWith("extFulfillmentShipments", "ship-1", expect.objectContaining({ id: "ship-1" })); + }); + + it("returns 500 when resolvedEntities is undefined", async () => { + const result = await generateInvoiceHandler( + makeValidationResult({ + resolvedEntities: {}, + }), + mockRequest(), + ); + + expect(result.statusCode).toBe(500); + const body = result.data.body as any; + expect(body.errors[0].code).toBe("InternalError"); + }); +}); + +describe("retrieveInvoiceHandler", () => { + beforeEach(() => { + mockFind.mockReturnValue([]); + mockPut.mockClear(); + }); + + it("returns same document format and content", async () => { + const entity = { id: "ship-1", _key: "ship-1", status: "CONFIRMED" }; + + const result = await retrieveInvoiceHandler( + makeValidationResult({ + resolvedEntities: { shipment: entity }, + }), + mockRequest({ host: "localhost:9001" }), + ); + + expect(result.statusCode).toBe(200); + const body = result.data.body as any; + expect(body.document).toEqual({ format: "PDF", content: "http://localhost:9001/invoice.pdf" }); + }); + + it("does not call put (no DB write)", async () => { + const entity = { id: "ship-1", _key: "ship-1", status: "CONFIRMED" }; + + await retrieveInvoiceHandler( + makeValidationResult({ + resolvedEntities: { shipment: entity }, + }), + mockRequest({ host: "localhost:9001" }), + ); + + expect(mockPut).not.toHaveBeenCalled(); + }); + + it("returns 500 when resolvedEntities is undefined", async () => { + const result = await retrieveInvoiceHandler( + makeValidationResult({ + resolvedEntities: {}, + }), + mockRequest(), + ); + + expect(result.statusCode).toBe(500); + const body = result.data.body as any; + expect(body.errors[0].code).toBe("InternalError"); + }); +}); + +describe("generateShipLabelsHandler", () => { + beforeEach(() => { + mockFind.mockReturnValue([]); + mockPut.mockClear(); + }); + + it("generates label entry for each packageId", async () => { + const entity = { + id: "ship-1", + _key: "ship-1", + status: "CONFIRMED", + packages: [{ id: "pkg-1" }, { id: "pkg-2" }], + }; + + const result = await generateShipLabelsHandler( + makeValidationResult({ + resolvedEntities: { shipment: entity }, + body: { packageIds: ["pkg-1", "pkg-2"], courierSupportedAttributes: { carrierName: "UPS", trackingId: "TRACK123" } }, + }), + mockRequest({ host: "localhost:9001" }), + ); + + expect(result.statusCode).toBe(200); + const body = result.data.body as any; + expect(body.packageShipLabelList).toHaveLength(2); + expect(body.packageShipLabelList[0].packageId).toBe("pkg-1"); + expect(body.packageShipLabelList[1].packageId).toBe("pkg-2"); + expect(body.packageShipLabelList[0].fileData).toEqual({ url: "http://localhost:9001/label.png" }); + expect(body.packageShipLabelList[0].status).toBe("SUCCESS"); + expect(body.packageShipLabelList[1].status).toBe("SUCCESS"); + }); + + it("uses carrierName and trackingId from courierSupportedAttributes", async () => { + const entity = { id: "ship-1", _key: "ship-1", status: "CONFIRMED", packages: [{ id: "pkg-1" }] }; + + const result = await generateShipLabelsHandler( + makeValidationResult({ + resolvedEntities: { shipment: entity }, + body: { packageIds: ["pkg-1"], courierSupportedAttributes: { carrierName: "UPS", trackingId: "TRACK123" } }, + }), + mockRequest({ host: "localhost:9001" }), + ); + + const body = result.data.body as any; + expect(body.packageShipLabelList[0].shipLabelMetadata).toEqual({ carrierName: "UPS", trackingId: "TRACK123" }); + }); + + it("defaults to empty strings when courierSupportedAttributes is absent", async () => { + const entity = { id: "ship-1", _key: "ship-1", status: "CONFIRMED", packages: [{ id: "pkg-1" }] }; + + const result = await generateShipLabelsHandler( + makeValidationResult({ + resolvedEntities: { shipment: entity }, + body: { packageIds: ["pkg-1"] }, + }), + mockRequest({ host: "localhost:9001" }), + ); + + const body = result.data.body as any; + expect(body.packageShipLabelList[0].shipLabelMetadata).toEqual({ carrierName: "", trackingId: "" }); + }); + + it("sets status to SHIPLABEL_GENERATED", async () => { + const entity: Record = { id: "ship-1", _key: "ship-1", status: "CONFIRMED", packages: [{ id: "pkg-1" }] }; + + await generateShipLabelsHandler( + makeValidationResult({ + resolvedEntities: { shipment: entity }, + body: { packageIds: ["pkg-1"], courierSupportedAttributes: { carrierName: "UPS", trackingId: "TRACK123" } }, + }), + mockRequest({ host: "localhost:9001" }), + ); + + expect(entity.status).toBe("SHIPLABEL_GENERATED"); + expect(mockPut).toHaveBeenCalled(); + }); + + it("non-matching packageIds still get SUCCESS entries", async () => { + const entity = { id: "ship-1", _key: "ship-1", status: "CONFIRMED", packages: [{ id: "pkg-1" }] }; + + const result = await generateShipLabelsHandler( + makeValidationResult({ + resolvedEntities: { shipment: entity }, + body: { packageIds: ["pkg-1", "pkg-nonexistent"], courierSupportedAttributes: { carrierName: "DHL", trackingId: "T999" } }, + }), + mockRequest({ host: "localhost:9001" }), + ); + + const body = result.data.body as any; + expect(body.packageShipLabelList).toHaveLength(2); + expect(body.packageShipLabelList[1].packageId).toBe("pkg-nonexistent"); + expect(body.packageShipLabelList[1].status).toBe("SUCCESS"); + }); +}); + +/** + * Creates a minimal valid UnifiedValidationPass for entity-based handler tests. + */ +function makeEntityValidationResult( + operationId: string, + resolvedEntities: Record> = {}, + queryParams: Record = {}, + body?: Record, + pathParams: Record = {}, +): UnifiedValidationPass { + return { + pass: true, + operationId, + apiName: "External Fulfillment Shipments", + apiVersion: "2024-09-11", + pathParams, + queryParams, + body, + resolvedEntities, + operation: {}, + }; +} + +describe("getShipmentHandler", () => { + beforeEach(() => { + mockFind.mockReturnValue([]); + mockPut.mockReset(); + }); + + it("returns entity without _key field", async () => { + const shipment = { + _key: "ship-001", + id: "ship-001", + status: "CREATED", + locationId: "LOC-1", + lineItems: [], + lastUpdatedDateTime: "2024-06-15T10:00:00Z", + }; + + const validationResult = makeEntityValidationResult("getShipment", { shipment }, {}, undefined, { shipmentId: "ship-001" }); + + const result = await getShipmentHandler(validationResult, {} as any); + + expect(result.statusCode).toBe(200); + expect(result.data.body).not.toHaveProperty("_key"); + expect(result.data.body).toEqual({ + id: "ship-001", + status: "CREATED", + locationId: "LOC-1", + lineItems: [], + lastUpdatedDateTime: "2024-06-15T10:00:00Z", + }); + }); + + it("returns 500 InternalError when resolvedEntities is undefined", async () => { + const validationResult = makeEntityValidationResult("getShipment", {}, {}, undefined, { shipmentId: "ship-001" }); + + const result = await getShipmentHandler(validationResult, {} as any); + + expect(result.statusCode).toBe(500); + const body = result.data.body as { errors: Array<{ code: string; message: string }> }; + expect(body.errors).toHaveLength(1); + expect(body.errors[0].code).toBe("InternalError"); + expect(body.errors[0].message).toContain("shipment"); + }); +}); + +describe("processShipmentHandler", () => { + beforeEach(() => { + mockFind.mockReturnValue([]); + mockPut.mockReset(); + }); + + it("CONFIRM sets status to CONFIRMED and returns 204", async () => { + const shipment = { + _key: "ship-001", + id: "ship-001", + status: "CREATED", + lineItems: [], + lastUpdatedDateTime: "2024-06-15T10:00:00Z", + }; + + const validationResult = makeEntityValidationResult("processShipment", { shipment }, { operation: "CONFIRM" }, undefined, { + shipmentId: "ship-001", + }); + + const result = await processShipmentHandler(validationResult, {} as any); + + expect(result.statusCode).toBe(204); + expect(shipment.status).toBe("CONFIRMED"); + expect(mockPut).toHaveBeenCalledWith("extFulfillmentShipments", "ship-001", shipment); + }); + + it("REJECT sets status to CANCELLED and returns 204", async () => { + const shipment = { + _key: "ship-002", + id: "ship-002", + status: "CREATED", + lineItems: [{ id: "li-1", quantity: 2 }], + lastUpdatedDateTime: "2024-06-15T10:00:00Z", + }; + + const validationResult = makeEntityValidationResult("processShipment", { shipment }, { operation: "REJECT" }, undefined, { + shipmentId: "ship-002", + }); + + const result = await processShipmentHandler(validationResult, {} as any); + + expect(result.statusCode).toBe(204); + expect(shipment.status).toBe("CANCELLED"); + expect(mockPut).toHaveBeenCalledWith("extFulfillmentShipments", "ship-002", shipment); + }); + + it("REJECT appends cancellation to matching lineItems", async () => { + const shipment = { + _key: "ship-003", + id: "ship-003", + status: "CREATED", + lineItems: [ + { id: "li-1", quantity: 5, cancellations: [] }, + { id: "li-2", quantity: 3, cancellations: [] }, + ], + lastUpdatedDateTime: "2024-06-15T10:00:00Z", + }; + + const body = { + lineItems: [{ lineItem: { id: "li-1", quantity: 2 }, reason: "OUT_OF_STOCK" }], + }; + + const validationResult = makeEntityValidationResult("processShipment", { shipment }, { operation: "REJECT" }, body, { + shipmentId: "ship-003", + }); + + const result = await processShipmentHandler(validationResult, {} as any); + + expect(result.statusCode).toBe(204); + expect(shipment.status).toBe("CANCELLED"); + + const li1 = shipment.lineItems[0] as Record; + const cancellations = li1.cancellations as Array>; + expect(cancellations).toHaveLength(1); + expect(cancellations[0].reason).toBe("OUT_OF_STOCK"); + expect(cancellations[0].cancelledQuantity).toBe(2); + expect(cancellations[0]).toHaveProperty("cancelledAt"); + + // li-2 should not have cancellations added + const li2 = shipment.lineItems[1] as Record; + expect((li2.cancellations as unknown[]).length).toBe(0); + }); + + it("REJECT skips non-matching lineItem IDs silently", async () => { + const shipment = { + _key: "ship-004", + id: "ship-004", + status: "CREATED", + lineItems: [{ id: "li-1", quantity: 5, cancellations: [] }], + lastUpdatedDateTime: "2024-06-15T10:00:00Z", + }; + + const body = { + lineItems: [{ lineItem: { id: "non-existent-id", quantity: 1 }, reason: "CUSTOMER_REQUESTED" }], + }; + + const validationResult = makeEntityValidationResult("processShipment", { shipment }, { operation: "REJECT" }, body, { + shipmentId: "ship-004", + }); + + const result = await processShipmentHandler(validationResult, {} as any); + + expect(result.statusCode).toBe(204); + expect(shipment.status).toBe("CANCELLED"); + + // li-1 should have no cancellations since the body referenced a non-matching ID + const li1 = shipment.lineItems[0] as Record; + expect((li1.cancellations as unknown[]).length).toBe(0); + }); + + it("returns 500 InternalError when resolvedEntities is undefined", async () => { + const validationResult = makeEntityValidationResult("processShipment", {}, { operation: "CONFIRM" }, undefined, { + shipmentId: "ship-001", + }); + + const result = await processShipmentHandler(validationResult, {} as any); + + expect(result.statusCode).toBe(500); + const body = result.data.body as { errors: Array<{ code: string; message: string }> }; + expect(body.errors).toHaveLength(1); + expect(body.errors[0].code).toBe("InternalError"); + expect(body.errors[0].message).toContain("shipment"); + }); +}); diff --git a/local-ai-sandbox/test/operation/extFulfillmentShipmentsRegistration.test.ts b/local-ai-sandbox/test/operation/extFulfillmentShipmentsRegistration.test.ts new file mode 100644 index 000000000..49cc372fb --- /dev/null +++ b/local-ai-sandbox/test/operation/extFulfillmentShipmentsRegistration.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { OPERATIONS_REGISTRY } from "../../src/registry/operationRegistry.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; + +describe("External Fulfillment Shipments registration", () => { + const operations = [ + "getShipments", + "getShipment", + "processShipment", + "createPackages", + "updatePackage", + "updatePackageStatus", + "retrieveShippingOptions", + "generateInvoice", + "retrieveInvoice", + "generateShipLabels", + ]; + + it.each(operations)("registers %s handler", (operationId) => { + const key = `External Fulfillment Shipments:2024-09-11:${operationId}`; + expect(OPERATIONS_REGISTRY.get(key)).toBeDefined(); + }); + + // The default test environment has MODE: "Seller", so isAllowedInCurrentMode returns true. + // Vendor mode restriction is enforced by the `supportedModes: ["Seller"]` registration — + // all 10 handlers are registered with only ["Seller"], meaning they return false for Vendor. + it.each(operations)("%s is allowed in Seller mode", (operationId) => { + const key = `External Fulfillment Shipments:2024-09-11:${operationId}`; + expect(OPERATIONS_REGISTRY.isAllowedInCurrentMode(key)).toBe(true); + }); + + it("getShipments validation pipeline has marketplaceIdValidation rule", () => { + const pipeline = VALIDATION_REGISTRY.get("External Fulfillment Shipments:2024-09-11:getShipments"); + expect(pipeline).toBeDefined(); + expect(pipeline).toEqual([ + { + checkType: "marketplaceIdValidation", + marketplaceIdsParam: { name: "marketplaceId", source: "query" }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "The marketplace ID is not valid for the configured region", + }, + }, + ]); + }); +}); diff --git a/local-ai-sandbox/test/operation/fbaInventoryOperations.integration.test.ts b/local-ai-sandbox/test/operation/fbaInventoryOperations.integration.test.ts new file mode 100644 index 000000000..1cdb40504 --- /dev/null +++ b/local-ai-sandbox/test/operation/fbaInventoryOperations.integration.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { executeValidation } from "../../src/service/validationEngine.js"; +import { RequestContext, ValidationFail } from "../../src/validation/validationTypes.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; +import { buildKey, OPERATIONS_REGISTRY } from "../../src/registry/operationRegistry.js"; + +// Mock the Context singleton (needed by the validation engine but not exercised by these tests) +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { + get instance() { + return { + engine: { + get: () => null, + find: () => [], + }, + }; + }, + }, + }; +}); + +const FBA_KEY = "FBA Inventory:v1:getInventorySummaries"; + +describe("FBA Inventory getInventorySummaries Integration Tests", () => { + describe("OPERATIONS_REGISTRY registration", () => { + it("handler is registered with correct composite key", () => { + const handler = OPERATIONS_REGISTRY.get(FBA_KEY); + expect(handler).toBeDefined(); + expect(typeof handler).toBe("function"); + }); + }); + + describe("Validation pipeline rejects invalid marketplace IDs with 400", () => { + afterEach(() => { + delete process.env.REGION; + }); + + it("rejects an invalid marketplace ID for NA region (default) with 400 and code InvalidInput", async () => { + delete process.env.REGION; + + const context: RequestContext = { + apiName: "FBA Inventory", + apiVersion: "v1", + operationId: "getInventorySummaries", + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: ["INVALID_MARKETPLACE_ID"] }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("INVALID_MARKETPLACE_ID"); + }); + + it("rejects an EU marketplace ID in NA region with 400", async () => { + delete process.env.REGION; + + const context: RequestContext = { + apiName: "FBA Inventory", + apiVersion: "v1", + operationId: "getInventorySummaries", + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: ["A1F83G8C2ARO7P"] }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + }); + + it("passes validation when a valid NA marketplace ID is provided", async () => { + delete process.env.REGION; + + const context: RequestContext = { + apiName: "FBA Inventory", + apiVersion: "v1", + operationId: "getInventorySummaries", + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: ["ATVPDKIKX0DER"] }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + }); + + describe("Validation pipeline rejects startDateTime older than 18 months", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-06-15T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("rejects startDateTime older than 18 months with 400 and code InvalidInput", async () => { + // 19 months ago from the fake now (2024-06-15) is well before the 18-month boundary + const tooOldDate = new Date(Date.now() - 19 * 30 * 24 * 60 * 60 * 1000).toISOString(); + + const context: RequestContext = { + apiName: "FBA Inventory", + apiVersion: "v1", + operationId: "getInventorySummaries", + method: "GET", + pathParams: {}, + queryParams: { + marketplaceIds: ["ATVPDKIKX0DER"], + startDateTime: tooOldDate, + }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("startDateTime"); + }); + + it("passes when startDateTime is within the 18-month window", async () => { + // 3 months ago — well within the 18-month window + const recentDate = new Date(Date.now() - 3 * 30 * 24 * 60 * 60 * 1000).toISOString(); + + const context: RequestContext = { + apiName: "FBA Inventory", + apiVersion: "v1", + operationId: "getInventorySummaries", + method: "GET", + pathParams: {}, + queryParams: { + marketplaceIds: ["ATVPDKIKX0DER"], + startDateTime: recentDate, + }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("rejects when startDateTime is exactly at the 18-month boundary (boundary test)", async () => { + // Exactly 18 months + 1ms ago should fail (just barely too old) + const boundaryDate = new Date(Date.now() - (18 * 30 * 24 * 60 * 60 * 1000) - 1).toISOString(); + + const context: RequestContext = { + apiName: "FBA Inventory", + apiVersion: "v1", + operationId: "getInventorySummaries", + method: "GET", + pathParams: {}, + queryParams: { + marketplaceIds: ["ATVPDKIKX0DER"], + startDateTime: boundaryDate, + }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + }); + }); + + describe("End-to-end request flow from validation to handler response", () => { + it("valid request passes validation and handler returns 200 with correct response shape", async () => { + // First verify the validation pipeline passes for a valid request + const context: RequestContext = { + apiName: "FBA Inventory", + apiVersion: "v1", + operationId: "getInventorySummaries", + method: "GET", + pathParams: {}, + queryParams: { + marketplaceIds: ["ATVPDKIKX0DER"], + granularityType: "Marketplace", + granularityId: "ATVPDKIKX0DER", + }, + body: undefined, + }; + + const validationResult = await executeValidation(context); + expect(validationResult.pass).toBe(true); + + // Then invoke the handler directly (as the controller would do after validation passes) + const handler = OPERATIONS_REGISTRY.get(FBA_KEY); + expect(handler).toBeDefined(); + + const handlerResult = await handler!( + { + pass: true, + operationId: "getInventorySummaries", + apiName: "FBA Inventory", + apiVersion: "v1", + pathParams: {}, + queryParams: { + marketplaceIds: ["ATVPDKIKX0DER"], + granularityType: "Marketplace", + granularityId: "ATVPDKIKX0DER", + }, + body: undefined, + resolvedEntities: {}, + operation: {}, + }, + {} as any, + ); + + expect(handlerResult.statusCode).toBe(200); + const body = handlerResult.data.body as Record; + expect(body).toHaveProperty("payload"); + const payload = body.payload as Record; + expect(payload).toHaveProperty("granularity"); + expect(payload).toHaveProperty("inventorySummaries"); + expect(payload.granularity).toEqual({ + granularityType: "Marketplace", + granularityId: "ATVPDKIKX0DER", + }); + expect(Array.isArray(payload.inventorySummaries)).toBe(true); + }); + + it("invalid marketplace ID is rejected at validation before handler executes", async () => { + const context: RequestContext = { + apiName: "FBA Inventory", + apiVersion: "v1", + operationId: "getInventorySummaries", + method: "GET", + pathParams: {}, + queryParams: { + marketplaceIds: ["COMPLETELY_FAKE_ID"], + granularityType: "Marketplace", + granularityId: "ATVPDKIKX0DER", + startDateTime: new Date().toISOString(), + }, + body: undefined, + }; + + const validationResult = await executeValidation(context); + // Marketplace validation fires FIRST (before dateComparison), so it should fail here + expect(validationResult.pass).toBe(false); + const fail = validationResult as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("COMPLETELY_FAKE_ID"); + }); + }); +}); diff --git a/local-ai-sandbox/test/operation/fbaInventoryOperations.test.ts b/local-ai-sandbox/test/operation/fbaInventoryOperations.test.ts new file mode 100644 index 000000000..3df25129b --- /dev/null +++ b/local-ai-sandbox/test/operation/fbaInventoryOperations.test.ts @@ -0,0 +1,863 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fc from "fast-check"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; +import { encodePageToken } from "../../src/service/Paginator.js"; + +// Mock find function — tests set the return value via mockFind.mockReturnValue(...) +const mockFind = vi.fn<() => Record[]>().mockReturnValue([]); + +// Mock the Context singleton so engine.find returns our controlled data +vi.mock("../../src/database/Context.js", () => ({ + Api: { INVENTORY: "inventory" }, + Context: { + get instance() { + return { + engine: { + find: mockFind, + }, + }; + }, + }, +})); + +import { getInventorySummariesHandler } from "../../src/operation/fbaInventoryOperations.js"; + +/** + * Creates a minimal valid UnifiedValidationPass object for testing the handler. + */ +function makeValidationResult(queryParams: Record = {}): UnifiedValidationPass { + return { + pass: true, + operationId: "getInventorySummaries", + apiName: "FBA Inventory", + apiVersion: "v1", + pathParams: {}, + queryParams: { + granularityType: "Marketplace", + granularityId: "ATVPDKIKX0DER", + ...queryParams, + }, + body: undefined, + resolvedEntities: {}, + operation: {}, + }; +} + +/** + * Creates a mock inventory item with sensible defaults. + */ +function makeInventoryItem(overrides: Partial> = {}): Record { + return { + sellerSku: "SKU-001", + asin: "B08N5WRWNW", + fnSku: "FN-001", + productName: "Test Product", + condition: "NewItem", + totalQuantity: 100, + lastUpdatedTime: "2024-06-15T10:00:00Z", + inventoryDetails: { + fulfillableQuantity: 80, + inboundWorkingQuantity: 10, + inboundShippedQuantity: 5, + inboundReceivingQuantity: 5, + }, + ...overrides, + }; +} + +describe("getInventorySummariesHandler", () => { + beforeEach(() => { + mockFind.mockReturnValue([]); + }); + + describe("response shape", () => { + it("returns correct response shape with payload containing granularity and inventorySummaries, HTTP 200", async () => { + mockFind.mockReturnValue([makeInventoryItem()]); + + const result = await getInventorySummariesHandler(makeValidationResult({ details: "true" }), {} as any); + + expect(result.statusCode).toBe(200); + const body = result.data.body as Record; + expect(body).toHaveProperty("payload"); + const payload = body.payload as Record; + expect(payload).toHaveProperty("granularity"); + expect(payload).toHaveProperty("inventorySummaries"); + expect(payload.granularity).toEqual({ + granularityType: "Marketplace", + granularityId: "ATVPDKIKX0DER", + }); + expect(Array.isArray(payload.inventorySummaries)).toBe(true); + }); + }); + + describe("empty database", () => { + it("returns empty inventorySummaries array with HTTP 200", async () => { + mockFind.mockReturnValue([]); + + const result = await getInventorySummariesHandler(makeValidationResult(), {} as any); + + expect(result.statusCode).toBe(200); + const body = result.data.body as Record; + const payload = body.payload as Record; + expect(payload.inventorySummaries).toEqual([]); + }); + }); + + describe("sellerSkus filter", () => { + it("returns only items matching the sellerSkus list", async () => { + mockFind.mockReturnValue([ + makeInventoryItem({ sellerSku: "SKU-A" }), + makeInventoryItem({ sellerSku: "SKU-B" }), + makeInventoryItem({ sellerSku: "SKU-C" }), + ]); + + const result = await getInventorySummariesHandler(makeValidationResult({ sellerSkus: "SKU-A,SKU-C" }), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + expect(summaries).toHaveLength(2); + const skus = summaries.map((s) => s.sellerSku); + expect(skus).toContain("SKU-A"); + expect(skus).toContain("SKU-C"); + expect(skus).not.toContain("SKU-B"); + }); + + it("returns only items matching when sellerSkus is an array", async () => { + mockFind.mockReturnValue([ + makeInventoryItem({ sellerSku: "SKU-A" }), + makeInventoryItem({ sellerSku: "SKU-B" }), + makeInventoryItem({ sellerSku: "SKU-C" }), + ]); + + const result = await getInventorySummariesHandler(makeValidationResult({ sellerSkus: ["SKU-B"] }), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + expect(summaries).toHaveLength(1); + expect(summaries[0].sellerSku).toBe("SKU-B"); + }); + }); + + describe("sellerSku filter", () => { + it("returns only the matching item", async () => { + mockFind.mockReturnValue([ + makeInventoryItem({ sellerSku: "SKU-A" }), + makeInventoryItem({ sellerSku: "SKU-B" }), + makeInventoryItem({ sellerSku: "SKU-C" }), + ]); + + const result = await getInventorySummariesHandler(makeValidationResult({ sellerSku: "SKU-B" }), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + expect(summaries).toHaveLength(1); + expect(summaries[0].sellerSku).toBe("SKU-B"); + }); + }); + + describe("sellerSkus precedence over sellerSku", () => { + it("sellerSkus takes precedence when both are provided", async () => { + mockFind.mockReturnValue([ + makeInventoryItem({ sellerSku: "SKU-A" }), + makeInventoryItem({ sellerSku: "SKU-B" }), + makeInventoryItem({ sellerSku: "SKU-C" }), + ]); + + const result = await getInventorySummariesHandler( + makeValidationResult({ sellerSkus: "SKU-A,SKU-C", sellerSku: "SKU-B" }), + {} as any, + ); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + expect(summaries).toHaveLength(2); + const skus = summaries.map((s) => s.sellerSku); + expect(skus).toContain("SKU-A"); + expect(skus).toContain("SKU-C"); + expect(skus).not.toContain("SKU-B"); + }); + }); + + describe("startDateTime filter", () => { + it("returns only items with lastUpdatedTime strictly after startDateTime", async () => { + mockFind.mockReturnValue([ + makeInventoryItem({ sellerSku: "SKU-OLD", lastUpdatedTime: "2024-01-01T00:00:00Z" }), + makeInventoryItem({ sellerSku: "SKU-EXACT", lastUpdatedTime: "2024-06-01T00:00:00Z" }), + makeInventoryItem({ sellerSku: "SKU-NEW", lastUpdatedTime: "2024-06-15T10:00:00Z" }), + ]); + + const result = await getInventorySummariesHandler(makeValidationResult({ startDateTime: "2024-06-01T00:00:00Z" }), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + // Only items strictly after startDateTime + expect(summaries).toHaveLength(1); + expect(summaries[0].sellerSku).toBe("SKU-NEW"); + }); + + it("excludes items without lastUpdatedTime", async () => { + mockFind.mockReturnValue([ + makeInventoryItem({ sellerSku: "SKU-NO-DATE", lastUpdatedTime: undefined }), + makeInventoryItem({ sellerSku: "SKU-WITH-DATE", lastUpdatedTime: "2024-07-01T00:00:00Z" }), + ]); + + const result = await getInventorySummariesHandler(makeValidationResult({ startDateTime: "2024-06-01T00:00:00Z" }), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + expect(summaries).toHaveLength(1); + expect(summaries[0].sellerSku).toBe("SKU-WITH-DATE"); + }); + }); + + describe("startDateTime precedence over SKU params", () => { + it("startDateTime takes precedence — ignores sellerSkus and sellerSku", async () => { + mockFind.mockReturnValue([ + makeInventoryItem({ sellerSku: "SKU-A", lastUpdatedTime: "2024-01-01T00:00:00Z" }), + makeInventoryItem({ sellerSku: "SKU-B", lastUpdatedTime: "2024-07-01T00:00:00Z" }), + makeInventoryItem({ sellerSku: "SKU-C", lastUpdatedTime: "2024-08-01T00:00:00Z" }), + ]); + + // Provide startDateTime AND sellerSkus (which should be ignored) + const result = await getInventorySummariesHandler( + makeValidationResult({ + startDateTime: "2024-06-01T00:00:00Z", + sellerSkus: "SKU-A", + sellerSku: "SKU-A", + }), + {} as any, + ); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + // startDateTime filter returns SKU-B and SKU-C (both after 2024-06-01), ignoring SKU filters + expect(summaries).toHaveLength(2); + const skus = summaries.map((s) => s.sellerSku); + expect(skus).toContain("SKU-B"); + expect(skus).toContain("SKU-C"); + expect(skus).not.toContain("SKU-A"); + }); + }); + + describe("details toggle", () => { + it("includes inventoryDetails when details=true", async () => { + mockFind.mockReturnValue([ + makeInventoryItem({ + sellerSku: "SKU-A", + inventoryDetails: { fulfillableQuantity: 50 }, + }), + ]); + + const result = await getInventorySummariesHandler(makeValidationResult({ details: "true" }), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + expect(summaries[0]).toHaveProperty("inventoryDetails"); + expect(summaries[0].inventoryDetails).toEqual({ fulfillableQuantity: 50 }); + }); + + it("omits inventoryDetails when details is absent", async () => { + mockFind.mockReturnValue([ + makeInventoryItem({ + sellerSku: "SKU-A", + inventoryDetails: { fulfillableQuantity: 50 }, + }), + ]); + + const result = await getInventorySummariesHandler(makeValidationResult({}), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + expect(summaries[0]).not.toHaveProperty("inventoryDetails"); + }); + + it("omits inventoryDetails when details=false", async () => { + mockFind.mockReturnValue([ + makeInventoryItem({ + sellerSku: "SKU-A", + inventoryDetails: { fulfillableQuantity: 50 }, + }), + ]); + + const result = await getInventorySummariesHandler(makeValidationResult({ details: "false" }), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + expect(summaries[0]).not.toHaveProperty("inventoryDetails"); + }); + }); + + describe("granularity echo", () => { + it("echoes granularityType and granularityId in response payload.granularity", async () => { + mockFind.mockReturnValue([]); + + const result = await getInventorySummariesHandler( + makeValidationResult({ granularityType: "Marketplace", granularityId: "A1F83G8C2ARO7P" }), + {} as any, + ); + + const body = result.data.body as Record; + const payload = body.payload as Record; + expect(payload.granularity).toEqual({ + granularityType: "Marketplace", + granularityId: "A1F83G8C2ARO7P", + }); + }); + }); + + describe("invalid nextToken", () => { + it("returns empty inventorySummaries for invalid nextToken (graceful degradation)", async () => { + mockFind.mockReturnValue([makeInventoryItem({ sellerSku: "SKU-A" })]); + + const result = await getInventorySummariesHandler(makeValidationResult({ nextToken: "totally-invalid-token" }), {} as any); + + expect(result.statusCode).toBe(200); + const body = result.data.body as Record; + const payload = body.payload as Record; + expect(payload.inventorySummaries).toEqual([]); + }); + + it("returns empty inventorySummaries for out-of-range nextToken", async () => { + mockFind.mockReturnValue([makeInventoryItem({ sellerSku: "SKU-A" })]); + + // Encode an offset beyond the items length + const result = await getInventorySummariesHandler(makeValidationResult({ nextToken: encodePageToken(999) }), {} as any); + + expect(result.statusCode).toBe(200); + const body = result.data.body as Record; + const payload = body.payload as Record; + expect(payload.inventorySummaries).toEqual([]); + }); + }); + + // Feature: fba-inventory-api, Property 5: Details Toggle + describe("Property 5: Details Toggle Controls inventoryDetails Inclusion", () => { + it("when details='true', inventoryDetails is present on every returned item", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + sellerSku: fc.string({ minLength: 1, maxLength: 20 }), + asin: fc.string({ minLength: 1, maxLength: 10 }), + inventoryDetails: fc.record({ + fulfillableQuantity: fc.nat(1000), + inboundWorkingQuantity: fc.nat(500), + }), + }), + { minLength: 1, maxLength: 30 }, + ), + async (items) => { + const dbItems = items.map((item) => makeInventoryItem(item)); + mockFind.mockReturnValue(dbItems); + + const result = await getInventorySummariesHandler(makeValidationResult({ details: "true" }), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + + // Every returned item should have inventoryDetails + for (const summary of summaries) { + expect(summary).toHaveProperty("inventoryDetails"); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it("when details is not 'true', inventoryDetails is omitted from every returned item", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + sellerSku: fc.string({ minLength: 1, maxLength: 20 }), + asin: fc.string({ minLength: 1, maxLength: 10 }), + inventoryDetails: fc.record({ + fulfillableQuantity: fc.nat(1000), + inboundWorkingQuantity: fc.nat(500), + }), + }), + { minLength: 1, maxLength: 30 }, + ), + // Generate a details value that is NOT "true" + fc.oneof(fc.constant(undefined), fc.constant("false"), fc.constant(""), fc.constant("FALSE"), fc.constant("0")), + async (items, detailsValue) => { + const dbItems = items.map((item) => makeInventoryItem(item)); + mockFind.mockReturnValue(dbItems); + + const qp: Record = {}; + if (detailsValue !== undefined) { + qp.details = detailsValue; + } + + const result = await getInventorySummariesHandler(makeValidationResult(qp), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + + // No returned item should have inventoryDetails + for (const summary of summaries) { + expect(summary).not.toHaveProperty("inventoryDetails"); + } + }, + ), + { numRuns: 100 }, + ); + }); + }); + // **Validates: Requirements 1.7** + + describe("pagination", () => { + it("returns nextToken when results exceed page size (50)", async () => { + // Create 55 items + mockFind.mockReturnValue(Array.from({ length: 55 }, (_, i) => makeInventoryItem({ sellerSku: `SKU-${String(i).padStart(3, "0")}` }))); + + const result = await getInventorySummariesHandler(makeValidationResult({ details: "true" }), {} as any); + + expect(result.statusCode).toBe(200); + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + expect(summaries).toHaveLength(50); + expect(body).toHaveProperty("pagination"); + const pagination = (body as any).pagination; + expect(pagination).toHaveProperty("nextToken"); + expect(typeof pagination.nextToken).toBe("string"); + }); + + it("does not include pagination when results fit in one page", async () => { + mockFind.mockReturnValue(Array.from({ length: 30 }, (_, i) => makeInventoryItem({ sellerSku: `SKU-${i}` }))); + + const result = await getInventorySummariesHandler(makeValidationResult({ details: "true" }), {} as any); + + const body = result.data.body as Record; + expect(body).not.toHaveProperty("pagination"); + }); + + it("pagination traverses all items without duplicates or omissions", async () => { + const totalItems = 125; + mockFind.mockReturnValue( + Array.from({ length: totalItems }, (_, i) => makeInventoryItem({ sellerSku: `SKU-${String(i).padStart(3, "0")}` })), + ); + + const allCollected: Record[] = []; + let nextToken: string | undefined = undefined; + + // Traverse all pages + for (let page = 0; page < 10; page++) { + const qp: Record = { details: "true" }; + if (nextToken) qp.nextToken = nextToken; + + const result = await getInventorySummariesHandler(makeValidationResult(qp), {} as any); + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + allCollected.push(...summaries); + + const pagination = (body as any).pagination; + if (pagination?.nextToken) { + nextToken = pagination.nextToken as string; + } else { + break; + } + } + + // Verify completeness: all 125 items collected + expect(allCollected).toHaveLength(totalItems); + + // Verify no duplicates + const skuSet = new Set(allCollected.map((item) => item.sellerSku)); + expect(skuSet.size).toBe(totalItems); + + // Verify all expected SKUs are present + for (let i = 0; i < totalItems; i++) { + expect(skuSet.has(`SKU-${String(i).padStart(3, "0")}`)).toBe(true); + } + }); + }); + + // Feature: fba-inventory-api, Property 1: SKU Filtering Correctness + // Validates: Requirements 1.1, 1.2, 1.3 + describe("Property 1: SKU Filtering Correctness", () => { + // Arbitrary for generating unique SKU identifiers (no commas, non-empty) + const skuArb = fc.stringMatching(/^[A-Za-z0-9_-]{1,20}$/); + + // Arbitrary for a list of items with unique SKUs and a non-empty subset of those SKUs + const itemsAndSkuSubsetArb = fc + .uniqueArray(skuArb, { minLength: 1, maxLength: 30, comparator: (a, b) => a === b }) + .chain((skus) => { + const items = skus.map((sku) => makeInventoryItem({ sellerSku: sku })); + // Generate a non-empty subset of SKUs (1 to min(skus.length, 50)) + return fc.subarray(skus, { minLength: 1, maxLength: Math.min(skus.length, 50) }).map((subset) => ({ + items, + selectedSkus: subset, + })); + }); + + it("returned items are exactly those whose sellerSku is in the provided SKU list (comma-separated string)", async () => { + await fc.assert( + fc.asyncProperty(itemsAndSkuSubsetArb, async ({ items, selectedSkus }) => { + mockFind.mockReturnValue(items); + + const result = await getInventorySummariesHandler( + makeValidationResult({ sellerSkus: selectedSkus.join(",") }), + {} as any, + ); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + + // Assert: returned items are exactly those whose sellerSku is in the selected list + const returnedSkus = summaries.map((s) => s.sellerSku as string).sort(); + const expectedSkus = [...selectedSkus].sort(); + + expect(returnedSkus).toEqual(expectedSkus); + }), + { numRuns: 100 }, + ); + }); + + it("when no SKU filter and no startDateTime, all items are returned", async () => { + await fc.assert( + fc.asyncProperty( + fc.uniqueArray(skuArb, { minLength: 0, maxLength: 40, comparator: (a, b) => a === b }), + async (skus) => { + const items = skus.map((sku) => makeInventoryItem({ sellerSku: sku })); + mockFind.mockReturnValue(items); + + const result = await getInventorySummariesHandler(makeValidationResult({}), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + + // All items should be returned (up to page size) + const expectedCount = Math.min(items.length, 50); + expect(summaries).toHaveLength(expectedCount); + + // If items fit in one page, all should be present + if (items.length <= 50) { + const returnedSkus = summaries.map((s) => s.sellerSku as string).sort(); + const allSkus = [...skus].sort(); + expect(returnedSkus).toEqual(allSkus); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it("returned items are exactly those whose sellerSku is in the provided SKU list (array)", async () => { + await fc.assert( + fc.asyncProperty(itemsAndSkuSubsetArb, async ({ items, selectedSkus }) => { + mockFind.mockReturnValue(items); + + // Pass sellerSkus as an array (the handler supports both string and string[]) + const result = await getInventorySummariesHandler( + makeValidationResult({ sellerSkus: selectedSkus }), + {} as any, + ); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + + const returnedSkus = summaries.map((s) => s.sellerSku as string).sort(); + const expectedSkus = [...selectedSkus].sort(); + + expect(returnedSkus).toEqual(expectedSkus); + }), + { numRuns: 100 }, + ); + }); + }); + + // Feature: fba-inventory-api, Property 4: Granularity Metadata Echo + // **Validates: Requirements 1.6** + describe("Property 4: Granularity Metadata Echo", () => { + it("response payload.granularity exactly matches provided granularityType and granularityId query params", async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 50 }).filter((s) => s.trim().length > 0), + fc.string({ minLength: 1, maxLength: 50 }).filter((s) => s.trim().length > 0), + async (granularityType, granularityId) => { + mockFind.mockReturnValue([]); + + const result = await getInventorySummariesHandler(makeValidationResult({ granularityType, granularityId }), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const granularity = payload.granularity as Record; + + expect(granularity).toEqual({ + granularityType, + granularityId, + }); + }, + ), + { numRuns: 100 }, + ); + }); + + it("granularity echo works regardless of inventory items in database", async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 50 }).filter((s) => s.trim().length > 0), + fc.string({ minLength: 1, maxLength: 50 }).filter((s) => s.trim().length > 0), + fc.array(fc.record({ sellerSku: fc.string({ minLength: 1, maxLength: 20 }) }), { minLength: 0, maxLength: 10 }), + async (granularityType, granularityId, items) => { + mockFind.mockReturnValue(items.map((item) => makeInventoryItem(item))); + + const result = await getInventorySummariesHandler(makeValidationResult({ granularityType, granularityId }), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const granularity = payload.granularity as Record; + + expect(granularity.granularityType).toBe(granularityType); + expect(granularity.granularityId).toBe(granularityId); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + // Feature: fba-inventory-api, Property 2: Parameter Precedence + // **Validates: Requirements 1.4** + describe("Property 2: Parameter Precedence — sellerSkus Overrides sellerSku", () => { + it("sellerSkus takes priority over sellerSku for any combination of SKUs", async () => { + await fc.assert( + fc.asyncProperty( + // Generate a pool of unique SKU strings (at least 3 so we can split them) + fc.uniqueArray(fc.string({ minLength: 1, maxLength: 20 }).filter((s) => !s.includes(",")), { minLength: 3, maxLength: 20 }), + // Generate a seed to determine the split point + fc.nat(), + async (skuPool, splitSeed) => { + // Split: pick at least 1 SKU for sellerSkus, keep at least 1 for sellerSku (not in sellerSkus) + const sellerSkusCount = (splitSeed % (skuPool.length - 1)) + 1; // 1 to pool.length - 1 + const sellerSkusList = skuPool.slice(0, sellerSkusCount); + // Pick a SKU NOT in sellerSkusList to use as sellerSku + const remainingSkus = skuPool.slice(sellerSkusCount); + const sellerSku = remainingSkus[0]; + + // Create inventory items for all SKUs in the pool + const allItems = skuPool.map((sku) => makeInventoryItem({ sellerSku: sku })); + mockFind.mockReturnValue(allItems); + + // Provide both sellerSkus (comma-separated) and sellerSku + const result = await getInventorySummariesHandler( + makeValidationResult({ + sellerSkus: sellerSkusList.join(","), + sellerSku: sellerSku, + }), + {} as any, + ); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + const returnedSkus = summaries.map((s) => s.sellerSku as string); + + // Assert: results match only the sellerSkus list + expect(returnedSkus.length).toBe(sellerSkusList.length); + expect(returnedSkus.sort()).toEqual(sellerSkusList.sort()); + + // Assert: sellerSku value has no effect (not in results since it's not in sellerSkusList) + expect(returnedSkus).not.toContain(sellerSku); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + // Feature: fba-inventory-api, Property 6: Pagination Completeness + // **Validates: Requirements 1.9** + describe("Property 6: Pagination Completeness", () => { + it("iterating all pages produces the full result set with no duplicates and no omissions", async () => { + await fc.assert( + fc.asyncProperty( + // Generate between 51 and 200 unique SKUs to ensure multiple pages + fc.integer({ min: 51, max: 200 }), + async (itemCount) => { + // Create items with unique SKUs + const items = Array.from({ length: itemCount }, (_, i) => + makeInventoryItem({ sellerSku: `SKU-${String(i).padStart(4, "0")}` }), + ); + mockFind.mockReturnValue(items); + + const allCollected: Record[] = []; + let nextToken: string | undefined = undefined; + const maxPages = Math.ceil(itemCount / 50) + 1; // safety bound + + // Iterate through all pages following pagination.nextToken + for (let page = 0; page < maxPages; page++) { + const qp: Record = {}; + if (nextToken) qp.nextToken = nextToken; + + const result = await getInventorySummariesHandler(makeValidationResult(qp), {} as any); + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + + allCollected.push(...summaries); + + const pagination = (body as any).pagination; + if (pagination?.nextToken) { + nextToken = pagination.nextToken as string; + } else { + break; + } + } + + // Assert: concatenated results equal the full set (no omissions) + expect(allCollected).toHaveLength(itemCount); + + // Assert: no duplicates + const skuSet = new Set(allCollected.map((item) => item.sellerSku as string)); + expect(skuSet.size).toBe(itemCount); + + // Assert: every expected SKU is present + for (let i = 0; i < itemCount; i++) { + expect(skuSet.has(`SKU-${String(i).padStart(4, "0")}`)).toBe(true); + } + }, + ), + { numRuns: 100 }, + ); + }); + }); + + // Feature: fba-inventory-api, Property 3: startDateTime Filtering and SKU Exclusion + // **Validates: Requirements 1.5** + describe("Property 3: startDateTime Filtering and SKU Exclusion", () => { + it("returns only items with lastUpdatedTime strictly after startDateTime", async () => { + await fc.assert( + fc.asyncProperty( + // Generate 1–20 inventory items with random lastUpdatedTime values (some may be undefined) + fc.array( + fc.record({ + sellerSku: fc.stringMatching(/^[A-Z0-9]{3,10}$/), + lastUpdatedTime: fc.option( + fc.integer({ min: 1577836800000, max: 1798761600000 }).map((ms) => new Date(ms).toISOString()), + { nil: undefined }, + ), + }), + { minLength: 1, maxLength: 20 }, + ), + // Generate a startDateTime within a reasonable range + fc.integer({ min: 1609459200000, max: 1767225600000 }).map((ms) => new Date(ms).toISOString()), + async (items, startDateTime) => { + // Build inventory items with full structure + const inventoryItems = items.map((item) => + makeInventoryItem({ + sellerSku: item.sellerSku, + lastUpdatedTime: item.lastUpdatedTime, + }), + ); + + mockFind.mockReturnValue(inventoryItems); + + const result = await getInventorySummariesHandler(makeValidationResult({ startDateTime }), {} as any); + + const body = result.data.body as Record; + const payload = body.payload as Record; + const summaries = payload.inventorySummaries as Record[]; + + // Compute expected: only items with lastUpdatedTime strictly after startDateTime + const expected = inventoryItems.filter((item) => { + const lastUpdated = item.lastUpdatedTime as string | undefined; + if (!lastUpdated) return false; + return lastUpdated > startDateTime; + }); + + // Assert: correct number of items returned + expect(summaries).toHaveLength(expected.length); + + // Assert: every returned item has lastUpdatedTime strictly after startDateTime + for (const summary of summaries) { + const lastUpdated = summary.lastUpdatedTime as string; + expect(lastUpdated).toBeDefined(); + expect(lastUpdated > startDateTime).toBe(true); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it("sellerSkus and sellerSku params have no effect when startDateTime is present", async () => { + await fc.assert( + fc.asyncProperty( + // Generate 1–15 inventory items with random lastUpdatedTime values + fc.array( + fc.record({ + sellerSku: fc.stringMatching(/^[A-Z0-9]{3,10}$/), + lastUpdatedTime: fc.option( + fc.integer({ min: 1577836800000, max: 1798761600000 }).map((ms) => new Date(ms).toISOString()), + { nil: undefined }, + ), + }), + { minLength: 1, maxLength: 15 }, + ), + // Generate a startDateTime as a timestamp in range then convert to ISO + fc.integer({ min: 1609459200000, max: 1767225600000 }).map((ms) => new Date(ms).toISOString()), + // Generate sellerSkus param (comma-separated) + fc.stringMatching(/^[A-Z0-9]{3,10}(,[A-Z0-9]{3,10}){0,4}$/), + // Generate sellerSku param + fc.stringMatching(/^[A-Z0-9]{3,10}$/), + async (items, startDateTime, sellerSkus, sellerSku) => { + const inventoryItems = items.map((item) => + makeInventoryItem({ + sellerSku: item.sellerSku, + lastUpdatedTime: item.lastUpdatedTime, + }), + ); + + mockFind.mockReturnValue(inventoryItems); + + // Call WITH SKU params + const resultWithSkus = await getInventorySummariesHandler( + makeValidationResult({ startDateTime, sellerSkus, sellerSku }), + {} as any, + ); + + // Call WITHOUT SKU params (only startDateTime) + const resultWithoutSkus = await getInventorySummariesHandler( + makeValidationResult({ startDateTime }), + {} as any, + ); + + const bodyWith = resultWithSkus.data.body as Record; + const payloadWith = bodyWith.payload as Record; + const summariesWith = payloadWith.inventorySummaries as Record[]; + + const bodyWithout = resultWithoutSkus.data.body as Record; + const payloadWithout = bodyWithout.payload as Record; + const summariesWithout = payloadWithout.inventorySummaries as Record[]; + + // Assert: both calls produce the same results (SKU params had no effect) + const skusWithParams = summariesWith.map((s) => s.sellerSku as string).sort(); + const skusWithoutParams = summariesWithout.map((s) => s.sellerSku as string).sort(); + expect(skusWithParams).toEqual(skusWithoutParams); + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/local-ai-sandbox/test/operation/listingsOperations.test.ts b/local-ai-sandbox/test/operation/listingsOperations.test.ts new file mode 100644 index 000000000..1d07abe32 --- /dev/null +++ b/local-ai-sandbox/test/operation/listingsOperations.test.ts @@ -0,0 +1,1118 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Context, Api } from "../../src/database/Context.js"; +import { listingKey } from "../../src/operation/listingsItemModel.js"; +import { + getListingsItemHandler, + searchListingsItemsHandler, + putListingsItemHandler, + patchListingsItemHandler, + deleteListingsItemHandler, +} from "../../src/operation/listingsOperations.js"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; +import type { Request } from "express"; + +const MP = "ATVPDKIKX0DER"; + +function makeValidationResult(overrides: Partial = {}): UnifiedValidationPass { + return { + pass: true, + operationId: "getListingsItem", + apiName: "Listings", + apiVersion: "2021-08-01", + pathParams: { sellerId: "SELLER1", sku: "SKU-001" }, + queryParams: { marketplaceIds: MP }, + body: undefined, + resolvedEntities: {}, + operation: {}, + ...overrides, + }; +} + +const SELLER_ID = "AMY6FKRUBY7XV"; // merchant-format seller ID + +/** + * A proxied SP-API request carrying an LWA token. putListingsItem validates + * every submission against production, so it always needs one. + */ +function makeRequest(body?: Record, sku = "SKU-001", sellerId = SELLER_ID): Request { + const path = `/listings/2021-08-01/items/${sellerId}/${sku}`; + return { + body, + path, + originalUrl: `${path}?marketplaceIds=${MP}`, + header: (name: string) => (name === "x-amz-access-token" ? "Atza|token" : undefined), + } as unknown as Request; +} + +/** A request without credentials, for the unauthenticated PUT path. */ +function makeAnonymousRequest(body?: Record): Request { + return { + body, + path: "/listings/2021-08-01/items/S/SKU", + originalUrl: "/listings/2021-08-01/items/S/SKU", + header: () => undefined, + } as unknown as Request; +} + +const realFetch = globalThis.fetch; + +/** + * Stubs the one upstream call a PUT makes: production's validation preview. + * Defaults to VALID — the status the real API returns under + * `mode=VALIDATION_PREVIEW` — so tests exercise the actual contract. + */ +function mockPreview(status: number, body?: unknown) { + const mock = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }); + globalThis.fetch = mock; + return mock; +} + +beforeEach(() => { + Context.reset(); + mockPreview(200, { status: "VALID", issues: [] }); +}); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** Yields to the event loop so detached trigger processing completes. */ +const flushTriggers = () => new Promise((resolve) => setImmediate(resolve)); + +/** Product facts + offer baseline used as the default submission. */ +const BASE_ATTRS: Record = { + item_name: [{ value: "Base Widget" }], + brand: [{ value: "TestBrand" }], + bullet_point: [{ value: "Great quality" }], + color: [{ value: "Black" }], + country_of_origin: [{ value: "US" }], + product_description: [{ value: "A fine widget for testing." }], + supplier_declared_dg_hz_regulation: [{ value: "not_applicable" }], + externally_assigned_product_identifier: [{ type: "upc", value: "714532191586" }], + condition_type: [{ value: "new_new" }], + fulfillment_availability: [{ fulfillment_channel_code: "DEFAULT", quantity: 5 }], + merchant_shipping_group: [{ value: "legacy-template-id" }], +}; + +/** Physical package data required whenever an FBA (AMAZON_*) channel is used. */ +const FBA_PHYSICAL_ATTRS: Record = { + batteries_required: [{ value: false }], + item_package_dimensions: [ + { length: { value: 10, unit: "centimeters" }, width: { value: 5, unit: "centimeters" }, height: { value: 3, unit: "centimeters" } }, + ], + item_package_weight: [{ value: 250, unit: "grams" }], +}; + +/** Puts with BASE_ATTRS merged under the given attributes. */ +async function put(sku: string, attributes: Record, requirements?: string, sellerId = SELLER_ID) { + return rawPut(sku, { ...BASE_ATTRS, ...attributes }, requirements, sellerId); +} + +/** Puts exactly the given attributes. */ +async function rawPut(sku: string, attributes: Record, requirements?: string, sellerId = SELLER_ID) { + return putListingsItemHandler( + makeValidationResult({ + operationId: "putListingsItem", + pathParams: { sellerId, sku }, + queryParams: { marketplaceIds: MP }, + }), + makeRequest({ productType: "PRODUCT", ...(requirements ? { requirements } : {}), attributes }, sku, sellerId), + ); +} + +function getStored(sku: string, sellerId = SELLER_ID) { + const doc = Context.instance.engine.get(Api.LISTINGS, listingKey(sellerId, sku)); + if (!doc) throw new Error(`Expected listing '${sku}' to exist for seller '${sellerId}'`); + return doc; +} + +/** Writes a listing fixture under its composite key, without firing triggers. */ +function seedListing(sku: string, doc: Record, sellerId = SELLER_ID) { + seed(Api.LISTINGS, listingKey(sellerId, sku), doc); +} + +/** Writes fixture data without firing triggers. */ +function seed(domain: Api, key: string, doc: Record) { + Context.instance.engine.put(domain, key, doc, { silent: true }); +} + +const OFFER = [{ marketplace_id: MP, currency: "USD", audience: "ALL", our_price: [{ schedule: [{ value_with_tax: 30 }] }] }]; +const MFN_5 = [{ fulfillment_channel_code: "DEFAULT", quantity: 5 }]; + +describe("putListingsItemHandler", () => { + beforeEach(() => { + Context.reset(); + }); + + it("creates a listing, returns ACCEPTED with issues[] and generates an ASIN", async () => { + const result = await put("NEW-SKU", { item_name: [{ value: "Widget" }] }); + const body = result.data.body as Record; + + expect(result.statusCode).toBe(200); + expect(body.status).toBe("ACCEPTED"); + expect(body.issues).toEqual([]); + expect(body.submissionId).toMatch(/^[0-9a-f]{32}$/); + + const stored = getStored("NEW-SKU"); + expect(stored.asin).toMatch(/^B0[0-9A-F]{8}$/); + expect(stored.sellerId).toBe(SELLER_ID); + }); + + it("uses merchant_suggested_asin as the listing ASIN when provided", async () => { + seed(Api.CATALOG, "B0EXISTING", { asin: "B0EXISTING", attributes: {} }); + await put("SKU-OFFER", { merchant_suggested_asin: [{ value: "B0EXISTING" }], purchasable_offer: OFFER }, "LISTING_OFFER_ONLY"); + expect(getStored("SKU-OFFER").asin).toBe("B0EXISTING"); + }); + + it("replaces product facts but merges sales terms on seller re-put", async () => { + await put("SKU-001", { item_name: [{ value: "Original" }], special_feature: [{ value: "SF" }], purchasable_offer: OFFER }); + // Re-put without special_feature (product fact) and without purchasable_offer (sales term). + await put("SKU-001", { item_name: [{ value: "Renamed" }] }); + + const attrs = getStored("SKU-001").attributes as Record; + expect(attrs.item_name).toEqual([{ value: "Renamed" }]); + expect(attrs.special_feature).toBeUndefined(); // product fact dropped + expect(attrs.purchasable_offer).toEqual(OFFER); // sales term retained + }); + + it("seeds the MFN ledger from submitted quantities and resets it on re-submission", async () => { + await put("SKU-001", { fulfillment_availability: MFN_5 }); + expect(getStored("SKU-001").mfnAvailability).toEqual([{ fulfillmentChannelCode: "DEFAULT", quantity: 5 }]); + + await put("SKU-001", { fulfillment_availability: [{ fulfillment_channel_code: "DEFAULT", quantity: 42 }] }); + expect(getStored("SKU-001").mfnAvailability).toEqual([{ fulfillmentChannelCode: "DEFAULT", quantity: 42 }]); + }); + + it("keeps the live ledger on a product-facts-only re-put (no fulfillment submitted)", async () => { + await put("SKU-001", { fulfillment_availability: MFN_5 }); + // Simulate an order reducing live inventory. + const stored = getStored("SKU-001"); + (stored.mfnAvailability as { quantity: number }[])[0].quantity = 3; + seedListing("SKU-001", stored); + + // Product-facts-only update: fulfillment_availability legitimately absent. + const productFacts = Object.fromEntries(Object.entries(BASE_ATTRS).filter(([k]) => k !== "condition_type" && k !== "fulfillment_availability")); + await rawPut("SKU-001", { ...productFacts, item_name: [{ value: "Renamed" }] }, "LISTING_PRODUCT_ONLY"); + + expect((getStored("SKU-001").mfnAvailability as { quantity: number }[])[0].quantity).toBe(3); + expect((getStored("SKU-001").attributes as Record).item_name).toEqual([{ value: "Renamed" }]); + }); +}); + +describe("getListingsItemHandler (derived sections)", () => { + beforeEach(() => { + Context.reset(); + }); + + async function get(sku: string, includedData: string[]) { + const result = await getListingsItemHandler( + makeValidationResult({ + pathParams: { sellerId: "SELLER1", sku }, + queryParams: { marketplaceIds: MP, includedData }, + resolvedEntities: { listing: getStored(sku) }, + }), + makeRequest(), + ); + return result.data.body as Record; + } + + it("derives fulfillmentAvailability (camelCase) from the live ledger, not attributes", async () => { + await put("SKU-001", { fulfillment_availability: MFN_5, purchasable_offer: OFFER }); + // Order reduces live inventory to 3; attributes still show 5. + const stored = getStored("SKU-001"); + (stored.mfnAvailability as { quantity: number }[])[0].quantity = 3; + seedListing("SKU-001", stored); + + const body = await get("SKU-001", ["attributes", "fulfillmentAvailability"]); + expect(body.fulfillmentAvailability).toEqual([{ fulfillmentChannelCode: "DEFAULT", quantity: 3 }]); + expect((body.attributes as Record).fulfillment_availability).toEqual(MFN_5); + }); + + it("reports an FBA channel without a quantity, which Amazon owns", async () => { + await put("SKU-FBA", { ...FBA_PHYSICAL_ATTRS, fulfillment_availability: [{ fulfillment_channel_code: "AMAZON_NA" }] }); + seed(Api.INVENTORY, "SKU-FBA", { sellerSku: "SKU-FBA", fulfillableQuantity: 80 }); + + const body = await get("SKU-FBA", ["fulfillmentAvailability"]); + expect(body.fulfillmentAvailability).toEqual([{ fulfillmentChannelCode: "AMAZON_NA" }]); + }); + + it("reports only the FBA channel for a hybrid listing, as production does", async () => { + await put("SKU-HYBRID", { + ...FBA_PHYSICAL_ATTRS, + fulfillment_availability: [{ fulfillment_channel_code: "DEFAULT", quantity: 7 }, { fulfillment_channel_code: "AMAZON_NA" }], + }); + seed(Api.INVENTORY, "SKU-HYBRID", { sellerSku: "SKU-HYBRID", fulfillableQuantity: 12 }); + + const body = await get("SKU-HYBRID", ["attributes", "fulfillmentAvailability"]); + + // Amazon fulfils from its own inventory first, so the merchant channel is + // not reported — even though the seller submitted it and the ledger holds it. + expect(body.fulfillmentAvailability).toEqual([{ fulfillmentChannelCode: "AMAZON_NA" }]); + // The submission layer still shows exactly what was submitted. + expect((body.attributes as Record).fulfillment_availability).toEqual([ + { fulfillment_channel_code: "DEFAULT", quantity: 7 }, + { fulfillment_channel_code: "AMAZON_NA" }, + ]); + }); + + it("keeps a hybrid listing BUYABLE from FBA stock when the MFN ledger is empty", async () => { + await put("SKU-HYBRID-2", { + ...FBA_PHYSICAL_ATTRS, + purchasable_offer: OFFER, + fulfillment_availability: [{ fulfillment_channel_code: "DEFAULT", quantity: 0 }, { fulfillment_channel_code: "AMAZON_NA" }], + }); + seed(Api.INVENTORY, "SKU-HYBRID-2", { sellerSku: "SKU-HYBRID-2", fulfillableQuantity: 4 }); + + const body = await get("SKU-HYBRID-2", ["summaries"]); + expect((body.summaries as { status: string[] }[])[0].status).toEqual(["BUYABLE", "DISCOVERABLE"]); + }); + + it("falls back to the merchant channel once FBA stock is exhausted", async () => { + await put("SKU-HYBRID-3", { + ...FBA_PHYSICAL_ATTRS, + purchasable_offer: OFFER, + fulfillment_availability: [{ fulfillment_channel_code: "DEFAULT", quantity: 9 }, { fulfillment_channel_code: "AMAZON_NA" }], + }); + seed(Api.INVENTORY, "SKU-HYBRID-3", { sellerSku: "SKU-HYBRID-3", fulfillableQuantity: 0 }); + + const body = await get("SKU-HYBRID-3", ["summaries", "fulfillmentAvailability"]); + + // FBA can no longer fulfil, so the merchant channel becomes the tip of the + // ledger and is reported with its live quantity. + expect(body.fulfillmentAvailability).toEqual([{ fulfillmentChannelCode: "DEFAULT", quantity: 9 }]); + expect((body.summaries as { status: string[] }[])[0].status).toEqual(["BUYABLE", "DISCOVERABLE"]); + }); + + it("keeps reporting the primary channel when nothing is in stock anywhere", async () => { + await put("SKU-HYBRID-4", { + ...FBA_PHYSICAL_ATTRS, + purchasable_offer: OFFER, + fulfillment_availability: [{ fulfillment_channel_code: "DEFAULT", quantity: 0 }, { fulfillment_channel_code: "AMAZON_NA" }], + }); + seed(Api.INVENTORY, "SKU-HYBRID-4", { sellerSku: "SKU-HYBRID-4", fulfillableQuantity: 0 }); + + const body = await get("SKU-HYBRID-4", ["summaries", "fulfillmentAvailability"]); + + expect(body.fulfillmentAvailability).toEqual([{ fulfillmentChannelCode: "AMAZON_NA" }]); + expect((body.summaries as { status: string[] }[])[0].status).toEqual(["DISCOVERABLE"]); + }); + + it("derives summaries with status BUYABLE+DISCOVERABLE for an offered, stocked listing", async () => { + await put("SKU-001", { item_name: [{ value: "Widget" }], purchasable_offer: OFFER, fulfillment_availability: MFN_5 }); + + const body = await get("SKU-001", ["summaries"]); + const summary = (body.summaries as Record[])[0]; + expect(summary.status).toEqual(["BUYABLE", "DISCOVERABLE"]); + expect(summary.itemName).toBe("Widget"); + expect(summary.productType).toBe("PRODUCT"); + expect(summary.asin).toBeDefined(); + }); + + it("drops BUYABLE when live inventory is depleted", async () => { + await put("SKU-001", { purchasable_offer: OFFER, fulfillment_availability: [{ fulfillment_channel_code: "DEFAULT", quantity: 0 }] }); + + const body = await get("SKU-001", ["summaries"]); + expect((body.summaries as Record[])[0].status).toEqual(["DISCOVERABLE"]); + }); + + it("drops BUYABLE when there is no purchasable offer", async () => { + await put("SKU-001", { item_name: [{ value: "No offer yet" }], fulfillment_availability: MFN_5 }); + + const body = await get("SKU-001", ["summaries"]); + expect((body.summaries as Record[])[0].status).toEqual(["DISCOVERABLE"]); + }); + + it("derives offers from purchasable_offer (B2C + B2B)", async () => { + await put("SKU-001", { + purchasable_offer: [...OFFER, { marketplace_id: MP, currency: "USD", audience: "B2B", our_price: [{ schedule: [{ value_with_tax: 28 }] }] }], + fulfillment_availability: MFN_5, + }); + + const body = await get("SKU-001", ["offers"]); + const offers = body.offers as Record[]; + expect(offers).toHaveLength(2); + expect(offers[0]).toMatchObject({ marketplaceId: MP, offerType: "B2C", price: { currencyCode: "USD", amount: "30" } }); + expect(offers[1]).toMatchObject({ offerType: "B2B", audience: { value: "B2B" } }); + }); +}); + +/** + * `procurement` is the vendor counterpart of `offers`: the cost Amazon pays for + * the product. Derivation is mode-independent — the includedData section is + * gated to Vendor mode by a validation rule, not by the handler. + */ +describe("procurement derivation", () => { + beforeEach(() => { + Context.reset(); + }); + + async function getProcurement(sku: string) { + const result = await getListingsItemHandler( + makeValidationResult({ + pathParams: { sellerId: SELLER_ID, sku }, + queryParams: { marketplaceIds: MP, includedData: ["procurement"] }, + resolvedEntities: { listing: getStored(sku) }, + }), + makeRequest(), + ); + return (result.data.body as Record).procurement; + } + + it("derives costPrice from a submitted cost_price attribute", async () => { + await put("SKU-VENDOR", { cost_price: [{ marketplace_id: MP, currency: "USD", value: 67 }] }); + expect(await getProcurement("SKU-VENDOR")).toEqual([{ costPrice: { currencyCode: "USD", amount: "67" } }]); + }); + + it("returns an empty section when no cost was submitted, as for a merchant listing", async () => { + await put("SKU-MERCHANT", { purchasable_offer: OFFER }); + expect(await getProcurement("SKU-MERCHANT")).toEqual([]); + }); + + it("skips a cost_price instance carrying no value", async () => { + await put("SKU-NO-VALUE", { cost_price: [{ marketplace_id: MP, currency: "EUR" }] }); + expect(await getProcurement("SKU-NO-VALUE")).toEqual([]); + }); +}); + +/** + * Relationships are the one section where half the data lives on the listing at + * the other end. Shapes here follow a production `includedData=relationships` + * response: an unrelated listing reports `[]`, a variation child reports its + * parent plus a `{ attributes, theme }` variationTheme, and a packaged unit + * reports its case as parent with no theme. + */ +describe("relationships derivation", () => { + beforeEach(() => { + Context.reset(); + }); + + async function getRelationships(sku: string) { + const result = await getListingsItemHandler( + makeValidationResult({ + pathParams: { sellerId: SELLER_ID, sku }, + queryParams: { marketplaceIds: MP, includedData: ["relationships"] }, + resolvedEntities: { listing: getStored(sku) }, + }), + makeRequest(), + ); + return (result.data.body as Record).relationships; + } + + it("reports an empty section for an unrelated listing rather than omitting it", async () => { + await put("SKU-ALONE", { item_name: [{ value: "Standalone" }] }); + expect(await getRelationships("SKU-ALONE")).toEqual([]); + }); + + it("reports the parent and variation theme of a variation child", async () => { + await put("variatione123456", { + parentage_level: [{ value: "child" }], + child_parent_sku_relationship: [{ child_relationship_type: "variation", parent_sku: "test1234567" }], + variation_theme: [{ name: "SIZE/COLOR/NUMBER_OF_ITEMS" }], + }); + + expect(await getRelationships("variatione123456")).toEqual([ + { + marketplaceId: MP, + relationships: [ + { + type: "VARIATION", + parentSkus: ["test1234567"], + // Theme order is SIZE/COLOR/NUMBER_OF_ITEMS; production reports the + // attribute names alphabetically. + variationTheme: { attributes: ["color", "number_of_items", "size"], theme: "SIZE/COLOR/NUMBER_OF_ITEMS" }, + }, + ], + }, + ]); + }); + + it("reports children on the variation parent, which never names them itself", async () => { + await put("test1234567", { parentage_level: [{ value: "parent" }] }); + await put("child-red", { child_parent_sku_relationship: [{ child_relationship_type: "variation", parent_sku: "test1234567" }] }); + await put("child-blue", { child_parent_sku_relationship: [{ child_relationship_type: "variation", parent_sku: "test1234567" }] }); + + expect(await getRelationships("test1234567")).toEqual([ + { marketplaceId: MP, relationships: [{ type: "VARIATION", childSkus: ["child-red", "child-blue"] }] }, + ]); + }); + + it("reports the containing case as the parent of a packaged unit, with no theme", async () => { + // The container declares what it contains, so the unit's parent is found by + // looking at the case, not on the unit itself. + await put("TG-CANDLE-UNIT", { package_level: [{ value: "unit" }] }); + await put("TG-CANDLE-CASE", { package_level: [{ value: "case" }], package_contains_sku: [{ sku: "TG-CANDLE-UNIT", quantity: 12 }] }); + + expect(await getRelationships("TG-CANDLE-UNIT")).toEqual([ + { marketplaceId: MP, relationships: [{ type: "PACKAGE_HIERARCHY", parentSkus: ["TG-CANDLE-CASE"] }] }, + ]); + expect(await getRelationships("TG-CANDLE-CASE")).toEqual([ + { marketplaceId: MP, relationships: [{ type: "PACKAGE_HIERARCHY", childSkus: ["TG-CANDLE-UNIT"] }] }, + ]); + }); + + it("reports both ends on a listing in the middle of a package hierarchy", async () => { + await put("PALLET", { package_level: [{ value: "pallet" }], package_contains_sku: [{ sku: "CASE", quantity: 10 }] }); + await put("CASE", { package_level: [{ value: "case" }], package_contains_sku: [{ sku: "UNIT", quantity: 12 }] }); + await put("UNIT", { package_level: [{ value: "unit" }] }); + + expect(await getRelationships("CASE")).toEqual([ + { marketplaceId: MP, relationships: [{ type: "PACKAGE_HIERARCHY", parentSkus: ["PALLET"], childSkus: ["UNIT"] }] }, + ]); + }); + + it("does not resolve a relationship across sellers", async () => { + await put("SHARED-CHILD", { child_parent_sku_relationship: [{ child_relationship_type: "variation", parent_sku: "OTHER-PARENT" }] }, undefined, "A1OTHERSELLER"); + await put("OTHER-PARENT", { parentage_level: [{ value: "parent" }] }); + + // The child belongs to another seller, so this seller's parent has none. + expect(await getRelationships("OTHER-PARENT")).toEqual([]); + }); +}); + +describe("patchListingsItemHandler", () => { + beforeEach(() => { + Context.reset(); + }); + + async function patch(sku: string, patches: unknown[]) { + return patchListingsItemHandler( + makeValidationResult({ + operationId: "patchListingsItem", + pathParams: { sellerId: SELLER_ID, sku }, + queryParams: { marketplaceIds: MP }, + resolvedEntities: { listing: getStored(sku) }, + }), + makeRequest({ productType: "PRODUCT", patches }), + ); + } + + it("replaces a whole attribute when the value carries no selectors", async () => { + await put("SKU-001", { item_name: [{ value: "Old" }], color: [{ value: "Red" }] }); + await patch("SKU-001", [{ op: "replace", path: "/attributes/item_name", value: [{ value: "New" }] }]); + + const attrs = getStored("SKU-001").attributes as Record; + expect(attrs.item_name).toEqual([{ value: "New" }]); + expect(attrs.color).toEqual([{ value: "Red" }]); + }); + + it("replaces only the selector-addressed instance, dropping its omitted sub-attributes", async () => { + await put("SKU-001", { + fulfillment_availability: [ + { fulfillment_channel_code: "DEFAULT", quantity: 3, lead_time_to_ship_max_days: 5 }, + { fulfillment_channel_code: "AMAZON_NA" }, + ], + }); + await patch("SKU-001", [ + { op: "replace", path: "/attributes/fulfillment_availability", value: [{ fulfillment_channel_code: "DEFAULT", quantity: 10 }] }, + ]); + + // The addressed instance is replaced wholesale — lead_time_to_ship_max_days + // is gone because replace, unlike merge, does not preserve omitted fields. + // The channel the patch did not address survives untouched. + expect((getStored("SKU-001").attributes as Record).fulfillment_availability).toEqual([ + { fulfillment_channel_code: "DEFAULT", quantity: 10 }, + { fulfillment_channel_code: "AMAZON_NA" }, + ]); + }); + + it("appends a selector-addressed instance that matches nothing yet", async () => { + await put("SKU-001", { purchasable_offer: OFFER }); + const b2b = { marketplace_id: MP, currency: "USD", audience: "B2B", our_price: [{ schedule: [{ value_with_tax: 27 }] }] }; + await patch("SKU-001", [{ op: "replace", path: "/attributes/purchasable_offer", value: [b2b] }]); + + const offers = (getStored("SKU-001").attributes as Record).purchasable_offer as { audience: string }[]; + expect(offers.map((o) => o.audience)).toEqual(["ALL", "B2B"]); + }); + + it("upserts: patching an unknown SKU creates the listing", async () => { + const result = await patchListingsItemHandler( + makeValidationResult({ + operationId: "patchListingsItem", + pathParams: { sellerId: SELLER_ID, sku: "SKU-BRANDNEW" }, + queryParams: { marketplaceIds: MP }, + }), + makeRequest({ productType: "PRODUCT", patches: [{ op: "replace", path: "/attributes/color", value: [{ value: "Blue" }] }] }), + ); + + expect((result.data.body as Record).status).toBe("ACCEPTED"); + const stored = getStored("SKU-BRANDNEW"); + expect(stored.sellerId).toBe(SELLER_ID); + expect((stored.attributes as Record).color).toEqual([{ value: "Blue" }]); + }); + + it("upserted listing goes through catalog processing like any other submission", async () => { + await patchListingsItemHandler( + makeValidationResult({ + operationId: "patchListingsItem", + pathParams: { sellerId: SELLER_ID, sku: "SKU-SPARSE" }, + queryParams: { marketplaceIds: MP }, + }), + makeRequest({ productType: "PRODUCT", patches: [{ op: "replace", path: "/attributes/country_of_origin", value: [{ value: "FR" }] }] }), + ); + await flushTriggers(); + + // A full submission this sparse still mints its own ASIN in the sandbox. + expect(Context.instance.engine.get(Api.CATALOG, getStored("SKU-SPARSE").asin as string)).not.toBeNull(); + }); + + it("merges quantity into a fulfillment_availability instance by selector, keeping other fields", async () => { + await put("SKU-001", { + fulfillment_availability: [{ fulfillment_channel_code: "DEFAULT", quantity: 5, lead_time_to_ship_max_days: 3 }], + }); + await patch("SKU-001", [ + { op: "merge", path: "/attributes/fulfillment_availability", value: [{ fulfillment_channel_code: "DEFAULT", quantity: 20 }] }, + ]); + + const attrs = getStored("SKU-001").attributes as Record; + expect(attrs.fulfillment_availability).toEqual([{ fulfillment_channel_code: "DEFAULT", quantity: 20, lead_time_to_ship_max_days: 3 }]); + // Live ledger reset by the fulfillment patch: + expect(getStored("SKU-001").mfnAvailability).toEqual([{ fulfillmentChannelCode: "DEFAULT", quantity: 20 }]); + }); + + it("merge with null deletes a sub-attribute of the selector-matched offer", async () => { + await put("SKU-001", { + purchasable_offer: [ + { + marketplace_id: MP, + currency: "USD", + audience: "B2B", + our_price: [{ schedule: [{ value_with_tax: 28 }] }], + quantity_discount_plan: [{ schedule: [] }], + }, + ], + }); + await patch("SKU-001", [ + { + op: "merge", + path: "/attributes/purchasable_offer", + value: [{ marketplace_id: MP, currency: "USD", audience: "B2B", quantity_discount_plan: null }], + }, + ]); + + const offer = (getStored("SKU-001").attributes as Record).purchasable_offer as Record[]; + expect(offer[0].quantity_discount_plan).toBeUndefined(); + expect(offer[0].our_price).toBeDefined(); + }); + + it("rejects merge on unsupported attributes with status INVALID", async () => { + await put("SKU-001", { item_name: [{ value: "Widget" }] }); + const result = await patch("SKU-001", [{ op: "merge", path: "/attributes/item_name", value: [{ value: "Nope" }] }]); + + const body = result.data.body as Record; + expect(body.status).toBe("INVALID"); + expect((body.issues as { message: string }[])[0].message).toContain("merge"); + // Attribute unchanged: + expect((getStored("SKU-001").attributes as Record).item_name).toEqual([{ value: "Widget" }]); + }); + + it("rejects merge-null on our_price with status INVALID", async () => { + await put("SKU-001", { purchasable_offer: OFFER }); + const result = await patch("SKU-001", [ + { op: "merge", path: "/attributes/purchasable_offer", value: [{ marketplace_id: MP, currency: "USD", audience: "ALL", our_price: null }] }, + ]); + + expect((result.data.body as Record).status).toBe("INVALID"); + }); + + it("deletes a selector-matched offer instance, keeping the others", async () => { + await put("SKU-001", { + purchasable_offer: [...OFFER, { marketplace_id: MP, currency: "USD", audience: "B2B", our_price: [{ schedule: [{ value_with_tax: 28 }] }] }], + }); + await patch("SKU-001", [ + { op: "delete", path: "/attributes/purchasable_offer", value: [{ marketplace_id: MP, currency: "USD", audience: "B2B" }] }, + ]); + + const offers = (getStored("SKU-001").attributes as Record).purchasable_offer as Record[]; + expect(offers).toHaveLength(1); + expect(offers[0].audience).toBe("ALL"); + }); + + it("adds a second offer audience and a second fulfillment channel via merge", async () => { + await put("SKU-001", { purchasable_offer: OFFER, fulfillment_availability: MFN_5 }); + + // merge appends instances whose selectors match nothing yet, so a B2B + // offer joins the B2C one and an FBA channel joins the merchant channel. + await patch("SKU-001", [ + { + op: "merge", + path: "/attributes/purchasable_offer", + value: [{ marketplace_id: MP, currency: "USD", audience: "B2B", our_price: [{ schedule: [{ value_with_tax: 27 }] }] }], + }, + { op: "merge", path: "/attributes/fulfillment_availability", value: [{ fulfillment_channel_code: "AMAZON_NA" }] }, + ]); + + const attrs = getStored("SKU-001").attributes as Record; + expect((attrs.purchasable_offer as { audience: string }[]).map((o) => o.audience)).toEqual(["ALL", "B2B"]); + expect((attrs.fulfillment_availability as { fulfillment_channel_code: string }[]).map((f) => f.fulfillment_channel_code)).toEqual([ + "DEFAULT", + "AMAZON_NA", + ]); + + // Both audiences surface as separate derived offers. + const get = await getListingsItemHandler( + makeValidationResult({ + pathParams: { sellerId: SELLER_ID, sku: "SKU-001" }, + queryParams: { marketplaceIds: MP, includedData: ["offers"] }, + resolvedEntities: { listing: getStored("SKU-001") }, + }), + makeRequest(), + ); + const offers = (get.data.body as Record).offers as { offerType: string }[]; + expect(offers.map((o) => o.offerType)).toEqual(["B2C", "B2B"]); + }); + + it("deletes a whole attribute when no selector value is provided", async () => { + await put("SKU-001", { item_name: [{ value: "Widget" }], color: [{ value: "Blue" }] }); + await patch("SKU-001", [{ op: "delete", path: "/attributes/color" }]); + + expect((getStored("SKU-001").attributes as Record).color).toBeUndefined(); + }); +}); + +describe("searchListingsItemsHandler", () => { + beforeEach(() => { + Context.reset(); + }); + + async function search(queryParams: Record) { + const result = await searchListingsItemsHandler( + makeValidationResult({ + operationId: "searchListingsItems", + pathParams: { sellerId: SELLER_ID }, + queryParams: { marketplaceIds: MP, ...queryParams }, + }), + makeRequest(), + ); + return result.data.body as Record; + } + + it("filters by SKU identifiers", async () => { + await put("SKU-A", {}); + await put("SKU-B", {}); + const body = await search({ identifiers: "SKU-A", identifiersType: "SKU" }); + expect(body.numberOfResults).toBe(1); + expect((body.items as { sku: string }[])[0].sku).toBe("SKU-A"); + }); + + /** SKUs in a search response, sorted so assertions do not depend on ordering. */ + function skus(body: Record): string[] { + return (body.items as { sku: string }[]).map((i) => i.sku).sort((a, b) => a.localeCompare(b)); + } + + describe("relationship filters", () => { + it("returns the variation children of the given parent, and not the parent", async () => { + await put("V-PARENT", { parentage_level: [{ value: "parent" }] }); + await put("V-RED", { child_parent_sku_relationship: [{ child_relationship_type: "variation", parent_sku: "V-PARENT" }] }); + await put("V-BLUE", { child_parent_sku_relationship: [{ child_relationship_type: "variation", parent_sku: "V-PARENT" }] }); + await put("UNRELATED", {}); + + const body = await search({ variationParentSku: "V-PARENT" }); + expect(body.numberOfResults).toBe(2); + expect(skus(body)).toEqual(["V-BLUE", "V-RED"]); + }); + + it("returns nothing for a parent SKU with no children", async () => { + await put("LONELY", {}); + expect(await search({ variationParentSku: "LONELY" }).then((b) => b.numberOfResults)).toBe(0); + }); + + it("returns both the container and the contents of the given SKU, but not itself", async () => { + // PALLET contains CASE, CASE contains UNIT. Anchored on CASE, the + // qualifying listings are the one that contains it and the one it + // contains. + await put("PALLET", { package_contains_sku: [{ sku: "CASE", quantity: 10 }] }); + await put("CASE", { package_contains_sku: [{ sku: "UNIT", quantity: 12 }] }); + await put("UNIT", {}); + await put("UNRELATED", {}); + + const body = await search({ packageHierarchySku: "CASE" }); + expect(body.numberOfResults).toBe(2); + expect(skus(body)).toEqual(["PALLET", "UNIT"]); + }); + + it("returns only the container when the given SKU contains nothing", async () => { + await put("CASE", { package_contains_sku: [{ sku: "UNIT", quantity: 12 }] }); + await put("UNIT", {}); + + const body = await search({ packageHierarchySku: "UNIT" }); + expect(skus(body)).toEqual(["CASE"]); + }); + + it("does not match a relationship filter across sellers", async () => { + await put("X-CHILD", { child_parent_sku_relationship: [{ child_relationship_type: "variation", parent_sku: "X-PARENT" }] }, undefined, "A1OTHERSELLER"); + await put("X-PARENT", {}); + + expect(await search({ variationParentSku: "X-PARENT" }).then((b) => b.numberOfResults)).toBe(0); + }); + }); + + it("returns only the requesting seller's listings", async () => { + await put("SKU-MINE", {}); + await put("SKU-THEIRS", {}, undefined, "A5BCM0NLAH8KY"); + + const body = await search({}); + expect(body.numberOfResults).toBe(1); + expect((body.items as { sku: string }[])[0].sku).toBe("SKU-MINE"); + }); + + it("filters by ASIN identifiers", async () => { + await put("SKU-A", { merchant_suggested_asin: [{ value: "B0AAAAAAA1" }] }); + await put("SKU-B", {}); + const body = await search({ identifiers: "B0AAAAAAA1", identifiersType: "ASIN" }); + expect(body.numberOfResults).toBe(1); + expect((body.items as { sku: string }[])[0].sku).toBe("SKU-A"); + }); + + it("filters by UPC via externally_assigned_product_identifier", async () => { + await put("SKU-A", { externally_assigned_product_identifier: [{ type: "upc", value: "887276302195" }] }); + await put("SKU-B", {}); + const body = await search({ identifiers: "887276302195", identifiersType: "UPC" }); + expect(body.numberOfResults).toBe(1); + expect((body.items as { sku: string }[])[0].sku).toBe("SKU-A"); + }); + + it("filters by withStatus / withoutStatus using derived status", async () => { + await put("SKU-LIVE", { purchasable_offer: OFFER, fulfillment_availability: MFN_5 }); + await put("SKU-OOS", { purchasable_offer: OFFER, fulfillment_availability: [{ fulfillment_channel_code: "DEFAULT", quantity: 0 }] }); + + const buyable = await search({ withStatus: "BUYABLE" }); + expect((buyable.items as { sku: string }[]).map((i) => i.sku)).toEqual(["SKU-LIVE"]); + + const notBuyable = await search({ withStatus: "DISCOVERABLE", withoutStatus: "BUYABLE" }); + expect((notBuyable.items as { sku: string }[]).map((i) => i.sku)).toEqual(["SKU-OOS"]); + }); + + it("filters by withIssueSeverity", async () => { + await put("SKU-A", {}); + const stored = getStored("SKU-A"); + stored.issues = [{ code: "X", message: "m", severity: "ERROR", categories: [] }]; + seedListing("SKU-A", stored); + await put("SKU-B", {}); + + const body = await search({ withIssueSeverity: "ERROR" }); + expect((body.items as { sku: string }[]).map((i) => i.sku)).toEqual(["SKU-A"]); + }); + + it("sorts by sku ASC", async () => { + await put("SKU-B", {}); + await put("SKU-A", {}); + const body = await search({ sortBy: "sku", sortOrder: "ASC" }); + expect((body.items as { sku: string }[]).map((i) => i.sku)).toEqual(["SKU-A", "SKU-B"]); + }); + + it("paginates with nextToken/previousToken", async () => { + for (let i = 0; i < 5; i++) await put(`SKU-${String(i)}`, {}); + const first = await search({ pageSize: "2", sortBy: "sku", sortOrder: "ASC" }); + expect((first.items as unknown[]).length).toBe(2); + const next = (first.pagination as { nextToken: string }).nextToken; + + const second = await search({ pageSize: "2", sortBy: "sku", sortOrder: "ASC", pageToken: next }); + expect((second.items as { sku: string }[]).map((i) => i.sku)).toEqual(["SKU-2", "SKU-3"]); + expect((second.pagination as { previousToken?: string }).previousToken).toBeDefined(); + }); +}); + +describe("deleteListingsItemHandler", () => { + beforeEach(() => { + Context.reset(); + }); + + it("removes the listing and returns ACCEPTED with issues[]", async () => { + await put("SKU-001", {}); + const result = await deleteListingsItemHandler( + makeValidationResult({ + operationId: "deleteListingsItem", + pathParams: { sellerId: SELLER_ID, sku: "SKU-001" }, + resolvedEntities: { listing: getStored("SKU-001") }, + }), + makeRequest(), + ); + + const body = result.data.body as Record; + expect(body.status).toBe("ACCEPTED"); + expect(body.issues).toEqual([]); + expect(Context.instance.engine.get(Api.LISTINGS, listingKey(SELLER_ID, "SKU-001"))).toBeNull(); + }); +}); + +describe("catalog linkage", () => { + beforeEach(() => { + Context.reset(); + }); + + /** An offer-only submission: sales terms and identity only, no product facts. */ + const OFFER_ONLY_ATTRS: Record = { + condition_type: [{ value: "new_new" }], + fulfillment_availability: MFN_5, + merchant_shipping_group: [{ value: "legacy-template-id" }], + purchasable_offer: OFFER, + }; + + function issueCodes(sku: string): string[] { + return (getStored(sku).issues as { code: string }[]).map((i) => i.code); + } + + it("accepts the submission first and reports matching asynchronously", async () => { + // Production accepts it; the mismatch is a downstream outcome. + const result = await rawPut("SKU-OFFER", { ...OFFER_ONLY_ATTRS, merchant_suggested_asin: [{ value: "B0UNKNOWN9" }] }, "LISTING_OFFER_ONLY"); + expect((result.data.body as Record).status).toBe("ACCEPTED"); + expect(issueCodes("SKU-OFFER")).toEqual([]); + + await flushTriggers(); + expect(issueCodes("SKU-OFFER")).toEqual(["4005015"]); + }); + + it("reports an unmatchable offer-only submission with no suggested ASIN", async () => { + await rawPut("SKU-NOMATCH", OFFER_ONLY_ATTRS, "LISTING_OFFER_ONLY"); + await flushTriggers(); + + expect(issueCodes("SKU-NOMATCH")).toEqual(["8560"]); + }); + + it("leaves an offer-only submission alone when it matches a catalog item by ASIN", async () => { + seed(Api.CATALOG, "B0CATALOG1", { asin: "B0CATALOG1", attributes: { brand: [{ value: "CatalogBrand" }] } }); + + await rawPut("SKU-OFFER", { ...OFFER_ONLY_ATTRS, merchant_suggested_asin: [{ value: "B0CATALOG1" }] }, "LISTING_OFFER_ONLY"); + await flushTriggers(); + + expect(issueCodes("SKU-OFFER")).toEqual([]); + // The listing returns only what was submitted: no catalog data leaks in. + expect((getStored("SKU-OFFER").attributes as Record).brand).toBeUndefined(); + }); + + it("matches an offer-only submission by external product identifier", async () => { + seed(Api.CATALOG, "B0BYUPC001", { + asin: "B0BYUPC001", + identifiers: [{ marketplaceId: MP, identifiers: [{ identifierType: "UPC", identifier: "714532191586" }] }], + }); + + await rawPut( + "SKU-UPC", + { ...OFFER_ONLY_ATTRS, externally_assigned_product_identifier: [{ type: "upc", value: "714532191586" }] }, + "LISTING_OFFER_ONLY", + ); + await flushTriggers(); + + expect(issueCodes("SKU-UPC")).toEqual([]); + }); + + it("clears a matching issue once the catalog item appears", async () => { + await rawPut("SKU-LATER", { ...OFFER_ONLY_ATTRS, merchant_suggested_asin: [{ value: "B0LATER001" }] }, "LISTING_OFFER_ONLY"); + await flushTriggers(); + expect(issueCodes("SKU-LATER")).toEqual(["4005015"]); + + seed(Api.CATALOG, "B0LATER001", { asin: "B0LATER001" }); + await rawPut("SKU-LATER", { ...OFFER_ONLY_ATTRS, merchant_suggested_asin: [{ value: "B0LATER001" }] }, "LISTING_OFFER_ONLY"); + await flushTriggers(); + + expect(issueCodes("SKU-LATER")).toEqual([]); + }); + + it("creates a catalog item for a net-new full submission", async () => { + await put("SKU-NEW", { item_name: [{ value: "Net New Widget" }] }); + await flushTriggers(); + + const asin = getStored("SKU-NEW").asin as string; + const catalogItem = Context.instance.engine.get(Api.CATALOG, asin); + expect(catalogItem).not.toBeNull(); + expect(catalogItem?.productTypes).toEqual([{ marketplaceId: MP, productType: "PRODUCT" }]); + expect((catalogItem?.summaries as { itemName?: string }[])[0].itemName).toBe("Net New Widget"); + // Only product facts are contributed; the seller's offer is not. + const catalogAttrs = catalogItem?.attributes as Record; + expect(catalogAttrs.item_name).toEqual([{ value: "Net New Widget" }]); + expect(catalogAttrs.condition_type).toBeUndefined(); + }); + + it("creates the catalog item under the seller's suggested ASIN when it is net-new", async () => { + await put("SKU-SUGGEST", { merchant_suggested_asin: [{ value: "B0BRANDNEW" }] }); + await flushTriggers(); + + expect(getStored("SKU-SUGGEST").asin).toBe("B0BRANDNEW"); + expect(Context.instance.engine.get(Api.CATALOG, "B0BRANDNEW")).not.toBeNull(); + }); + + it("does not touch a catalog item that already exists", async () => { + seed(Api.CATALOG, "B0EXISTING", { asin: "B0EXISTING", attributes: { brand: [{ value: "Untouched" }] } }); + await put("SKU-EXIST", { merchant_suggested_asin: [{ value: "B0EXISTING" }] }); + await flushTriggers(); + + const catalogAttrs = Context.instance.engine.get(Api.CATALOG, "B0EXISTING")?.attributes as Record; + expect(catalogAttrs.brand).toEqual([{ value: "Untouched" }]); + }); +}); + +describe("offer status derivation", () => { + it("treats skip_offer=true as never BUYABLE", async () => { + const productFacts = Object.fromEntries(Object.entries(BASE_ATTRS).filter(([k]) => k !== "condition_type" && k !== "fulfillment_availability")); + const result = await rawPut("SKU-SKIP", { ...productFacts, skip_offer: [{ value: true }], purchasable_offer: OFFER }); + expect((result.data.body as Record).status).toBe("ACCEPTED"); + + const get = await getListingsItemHandler( + makeValidationResult({ + pathParams: { sellerId: SELLER_ID, sku: "SKU-SKIP" }, + queryParams: { marketplaceIds: MP, includedData: ["summaries"] }, + resolvedEntities: { listing: getStored("SKU-SKIP") }, + }), + makeRequest(), + ); + const summaries = (get.data.body as Record).summaries as { status: string[] }[]; + expect(summaries[0].status).toEqual(["DISCOVERABLE"]); + }); + + it("treats is_inventory_available inventory as in stock for BUYABLE", async () => { + await put("SKU-ALWAYS", { + purchasable_offer: OFFER, + fulfillment_availability: [{ fulfillment_channel_code: "DEFAULT", is_inventory_available: true }], + }); + + const get = await getListingsItemHandler( + makeValidationResult({ + pathParams: { sellerId: SELLER_ID, sku: "SKU-ALWAYS" }, + queryParams: { marketplaceIds: MP, includedData: ["summaries"] }, + resolvedEntities: { listing: getStored("SKU-ALWAYS") }, + }), + makeRequest(), + ); + const summaries = (get.data.body as Record).summaries as { status: string[] }[]; + expect(summaries[0].status).toEqual(["BUYABLE", "DISCOVERABLE"]); + }); +}); + +describe("put attribute semantics", () => { + beforeEach(() => { + Context.reset(); + }); + + it("re-put drops an omitted product fact and keeps an omitted sales term", async () => { + await put("SKU-S1", { special_feature: [{ value: "SF" }], purchasable_offer: OFFER }); + await put("SKU-S1", { item_name: [{ value: "Renamed" }] }); + + const attrs = getStored("SKU-S1").attributes as Record; + expect(attrs.special_feature).toBeUndefined(); + expect(attrs.purchasable_offer).toEqual(OFFER); + }); +}); + +describe("production VALIDATION_PREVIEW delegation", () => { + it("calls production with mode=VALIDATION_PREVIEW and the caller's token", async () => { + const mock = mockPreview(200, { status: "VALID", issues: [] }); + await rawPut("SKU-PREV", BASE_ATTRS); + + const previewCall = mock.mock.calls.find(([url]) => !(url as string).includes("/catalog/")); + expect(previewCall).toBeDefined(); + const [url, options] = previewCall as [string, RequestInit]; + expect(url).toContain("mode=VALIDATION_PREVIEW"); + expect(url).toContain(`/listings/2021-08-01/items/${SELLER_ID}/SKU-PREV`); + expect(options.method).toBe("PUT"); + expect((options.headers as Record)["x-amz-access-token"]).toBe("Atza|token"); + }); + + it("does not forward the caller's own mode parameter", async () => { + const mock = mockPreview(200, { status: "VALID", issues: [] }); + await putListingsItemHandler( + makeValidationResult({ + operationId: "putListingsItem", + pathParams: { sellerId: SELLER_ID, sku: "SKU-MODE" }, + queryParams: { marketplaceIds: MP, mode: "VALIDATION_PREVIEW" }, + }), + { + body: { productType: "PRODUCT", attributes: BASE_ATTRS }, + path: `/listings/2021-08-01/items/${SELLER_ID}/SKU-MODE`, + originalUrl: `/listings/2021-08-01/items/${SELLER_ID}/SKU-MODE?marketplaceIds=${MP}&mode=VALIDATION_PREVIEW`, + header: (name: string) => (name === "x-amz-access-token" ? "Atza|token" : undefined), + } as unknown as Request, + ); + + // The sandbox has no dry run: the submission is persisted regardless. + expect(getStored("SKU-MODE")).toBeDefined(); + const [url] = mock.mock.calls[0] as [string]; + expect(url.match(/mode=/g)).toHaveLength(1); + }); + + it("returns preview INVALID issues synchronously without persisting", async () => { + const previewIssues = [{ code: "90244", message: "Invalid enumerated value.", severity: "ERROR" }]; + mockPreview(200, { status: "INVALID", issues: previewIssues }); + + const result = await rawPut("SKU-PREV", {}); + const body = result.data.body as Record; + + expect(body.status).toBe("INVALID"); + expect(body.issues).toEqual(previewIssues); + expect(Context.instance.engine.get(Api.LISTINGS, listingKey(SELLER_ID, "SKU-PREV"))).toBeNull(); + }); + + it("stores non-blocking preview issues on the accepted listing", async () => { + const warning = { code: "90197", message: "Value is greater than the allowed maximum.", severity: "WARNING" }; + mockPreview(200, { status: "VALID", issues: [warning] }); + + const result = await rawPut("SKU-PREV", BASE_ATTRS); + expect((result.data.body as Record).status).toBe("ACCEPTED"); + expect(getStored("SKU-PREV").issues).toEqual([warning]); + }); + + it("ignores matching issues from production, which cannot see sandbox ASINs", async () => { + // Production only objected to matching an ASIN it does not know about. + mockPreview(200, { status: "INVALID", issues: [{ code: "4005015", message: "ASIN does not match.", severity: "ERROR" }] }); + + const result = await rawPut("SKU-SANDBOXASIN", { ...BASE_ATTRS, merchant_suggested_asin: [{ value: "B0SANDBOX1" }] }); + + expect((result.data.body as Record).status).toBe("ACCEPTED"); + expect(getStored("SKU-SANDBOXASIN").issues).toEqual([]); + }); + + it("rejects a PUT without credentials (403), since validation cannot be faked locally", async () => { + const result = await putListingsItemHandler( + makeValidationResult({ + operationId: "putListingsItem", + pathParams: { sellerId: SELLER_ID, sku: "SKU-NOTOKEN" }, + queryParams: { marketplaceIds: MP }, + }), + makeAnonymousRequest({ productType: "PRODUCT", attributes: BASE_ATTRS }), + ); + + expect(result.statusCode).toBe(403); + expect(Context.instance.engine.get(Api.LISTINGS, listingKey(SELLER_ID, "SKU-NOTOKEN"))).toBeNull(); + }); + + it("answers 502 when production cannot be reached, rather than a false ACCEPTED", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + + const result = await rawPut("SKU-DOWN", BASE_ATTRS); + + expect(result.statusCode).toBe(502); + expect(Context.instance.engine.get(Api.LISTINGS, listingKey(SELLER_ID, "SKU-DOWN"))).toBeNull(); + }); + + it("answers 502 when the preview is throttled", async () => { + mockPreview(429); + + const result = await rawPut("SKU-THROTTLED", BASE_ATTRS); + expect(result.statusCode).toBe(502); + }); + + it("treats VALID as the pass status, the only one a preview returns", async () => { + mockPreview(200, { status: "VALID", issues: [] }); + + const result = await rawPut("SKU-VALID", BASE_ATTRS); + + expect(result.statusCode).toBe(200); + // The sandbox does persist, so its own submission response says ACCEPTED. + expect((result.data.body as Record).status).toBe("ACCEPTED"); + expect(getStored("SKU-VALID")).toBeDefined(); + }); + + it("answers 502 on a status it does not understand, naming the status", async () => { + mockPreview(200, { status: "SOMETHING_NEW", issues: [] }); + + const result = await rawPut("SKU-UNKNOWN", BASE_ATTRS); + + expect(result.statusCode).toBe(502); + const errors = (result.data.body as { errors: { message: string }[] }).errors; + expect(errors[0].message).toContain("SOMETHING_NEW"); + expect(Context.instance.engine.get(Api.LISTINGS, listingKey(SELLER_ID, "SKU-UNKNOWN"))).toBeNull(); + }); +}); diff --git a/local-ai-sandbox/test/operation/listingsRestrictionsOperations.test.ts b/local-ai-sandbox/test/operation/listingsRestrictionsOperations.test.ts new file mode 100644 index 000000000..3af8f1567 --- /dev/null +++ b/local-ai-sandbox/test/operation/listingsRestrictionsOperations.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Context, Api } from "../../src/database/Context.js"; +import { getListingsRestrictionsHandler } from "../../src/operation/listingsRestrictionsOperations.js"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; +import type { Request } from "express"; + +const MP = "ATVPDKIKX0DER"; + +function makeValidationResult(queryParams: Record): UnifiedValidationPass { + return { + pass: true, + operationId: "getListingsRestrictions", + apiName: "Listings Restrictions", + apiVersion: "2021-08-01", + pathParams: {}, + queryParams: queryParams as Record, + body: undefined, + resolvedEntities: {}, + operation: {}, + }; +} + +async function call(queryParams: Record) { + const result = await getListingsRestrictionsHandler(makeValidationResult(queryParams), {} as unknown as Request); + return result.data.body as { restrictions: Record[] }; +} + +describe("getListingsRestrictionsHandler", () => { + beforeEach(() => { + Context.reset(); + }); + + it("returns empty restrictions for a catalog item without seeded restrictions", async () => { + Context.instance.engine.put(Api.CATALOG, "B0FREE00001", { asin: "B0FREE00001" }, { silent: true }); + const body = await call({ asin: "B0FREE00001", sellerId: "SELLER1", marketplaceIds: MP }); + expect(body.restrictions).toEqual([]); + }); + + it("returns seeded restrictions filtered by marketplace", async () => { + Context.instance.engine.put(Api.CATALOG, "B0LOCKED001", { asin: "B0LOCKED001" }, { silent: true }); + Context.instance.engine.put( + Api.LISTINGS_RESTRICTIONS, + "B0LOCKED001", + { + restrictions: [ + { marketplaceId: MP, conditionType: "new_new", reasons: [{ reasonCode: "APPROVAL_REQUIRED", message: "Approval required." }] }, + { marketplaceId: "A1F83G8C2ARO7P", conditionType: "new_new", reasons: [{ reasonCode: "NOT_ELIGIBLE" }] }, + ], + }, + { silent: true }, + ); + + const body = await call({ asin: "B0LOCKED001", sellerId: "SELLER1", marketplaceIds: MP }); + expect(body.restrictions).toHaveLength(1); + expect(body.restrictions[0].marketplaceId).toBe(MP); + }); + + it("filters by conditionType when provided", async () => { + Context.instance.engine.put(Api.CATALOG, "B0COND00001", { asin: "B0COND00001" }, { silent: true }); + Context.instance.engine.put( + Api.LISTINGS_RESTRICTIONS, + "B0COND00001", + { + restrictions: [ + { marketplaceId: MP, conditionType: "used_good", reasons: [{ reasonCode: "NOT_ELIGIBLE" }] }, + { marketplaceId: MP, conditionType: "new_new", reasons: [{ reasonCode: "APPROVAL_REQUIRED" }] }, + ], + }, + { silent: true }, + ); + + const body = await call({ asin: "B0COND00001", sellerId: "SELLER1", marketplaceIds: MP, conditionType: "used_good" }); + expect(body.restrictions).toHaveLength(1); + expect(body.restrictions[0].conditionType).toBe("used_good"); + }); + + it("answers ASIN_NOT_FOUND for an ASIN unknown to the catalog", async () => { + const body = await call({ asin: "B0GHOST0001", sellerId: "SELLER1", marketplaceIds: MP }); + expect(body.restrictions).toHaveLength(1); + const reasons = body.restrictions[0].reasons as { reasonCode: string }[]; + expect(reasons[0].reasonCode).toBe("ASIN_NOT_FOUND"); + }); +}); diff --git a/local-ai-sandbox/test/operation/listingsSellerIsolation.prop.test.ts b/local-ai-sandbox/test/operation/listingsSellerIsolation.prop.test.ts new file mode 100644 index 000000000..7fa354bf0 --- /dev/null +++ b/local-ai-sandbox/test/operation/listingsSellerIsolation.prop.test.ts @@ -0,0 +1,134 @@ +import fc from "fast-check"; +import type { Request } from "express"; +import { Context, Api } from "../../src/database/Context.js"; +import { patchListingsItemHandler, getListingsItemHandler } from "../../src/operation/listingsOperations.js"; +import { listingKey } from "../../src/operation/listingsItemModel.js"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; + +/** + * Feature: listings-items-api, Property 13: A listing is addressable only by its own seller and SKU + * + * A SKU is unique per selling partner, not globally, so two sellers using the + * same SKU must hold independent listings. Exercised through patch, which needs + * no credentials and upserts, so it both creates and mutates. + */ +const MP = "ATVPDKIKX0DER"; + +/** Seller IDs are opaque identifiers; the key separator must never appear in one. */ +const sellerArb = fc + .string({ minLength: 1, maxLength: 14 }) + .filter((s) => s.trim() === s && s.length > 0 && !s.includes("|")) + .map((s) => `A${s}`); + +const skuArb = fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim() === s && s.length > 0 && !s.includes("|")); + +function validationResult(sellerId: string, sku: string, overrides: Partial = {}): UnifiedValidationPass { + return { + pass: true, + operationId: "patchListingsItem", + apiName: "Listings", + apiVersion: "2021-08-01", + pathParams: { sellerId, sku }, + queryParams: { marketplaceIds: MP }, + body: undefined, + resolvedEntities: {}, + operation: {}, + ...overrides, + }; +} + +function request(body: Record): Request { + return { + body, + path: "/listings/2021-08-01/items", + originalUrl: `/listings/2021-08-01/items?marketplaceIds=${MP}`, + header: () => undefined, + } as unknown as Request; +} + +function patch(sellerId: string, sku: string, name: string) { + return patchListingsItemHandler( + validationResult(sellerId, sku), + request({ productType: "PRODUCT", patches: [{ op: "replace", path: "/attributes/item_name", value: [{ value: name }] }] }), + ); +} + +function storedName(sellerId: string, sku: string): unknown { + const doc = Context.instance.engine.get(Api.LISTINGS, listingKey(sellerId, sku)); + return (doc?.attributes as { item_name?: { value?: string }[] } | undefined)?.item_name?.[0]?.value; +} + +describe("Feature: listings-items-api, Property 13: A listing is addressable only by its own seller and SKU", () => { + beforeEach(() => { + Context.reset(); + }); + + it("keeps two sellers' listings independent when they share a SKU", async () => { + await fc.assert( + fc.asyncProperty(sellerArb, sellerArb, skuArb, async (sellerA, sellerB, sku) => { + fc.pre(sellerA !== sellerB); + Context.reset(); + + await patch(sellerA, sku, "A-original"); + await patch(sellerB, sku, "B-original"); + + // Each seller's write lands on its own record. + expect(storedName(sellerA, sku)).toBe("A-original"); + expect(storedName(sellerB, sku)).toBe("B-original"); + + // Mutating one leaves the other untouched. + await patch(sellerB, sku, "B-updated"); + expect(storedName(sellerA, sku)).toBe("A-original"); + expect(storedName(sellerB, sku)).toBe("B-updated"); + }), + { numRuns: 100 }, + ); + }); + + it("returns each seller its own listing, and reports the bare SKU", async () => { + await fc.assert( + fc.asyncProperty(sellerArb, sellerArb, skuArb, async (sellerA, sellerB, sku) => { + fc.pre(sellerA !== sellerB); + Context.reset(); + + await patch(sellerA, sku, "A-only"); + await patch(sellerB, sku, "B-only"); + + for (const [seller, expected] of [ + [sellerA, "A-only"], + [sellerB, "B-only"], + ] as const) { + const stored = Context.instance.engine.get(Api.LISTINGS, listingKey(seller, sku)); + const result = await getListingsItemHandler( + validationResult(seller, sku, { + operationId: "getListingsItem", + queryParams: { marketplaceIds: MP, includedData: ["attributes"] }, + resolvedEntities: { listing: stored as Record }, + }), + request({}), + ); + + const item = result.data.body as { sku: string; attributes: { item_name?: { value?: string }[] } }; + // The composite key never leaks into the response. + expect(item.sku).toBe(sku); + expect(item.attributes.item_name?.[0].value).toBe(expected); + } + }), + { numRuns: 100 }, + ); + }); + + it("does not resolve one seller's SKU under another seller", async () => { + await fc.assert( + fc.asyncProperty(sellerArb, sellerArb, skuArb, async (sellerA, sellerB, sku) => { + fc.pre(sellerA !== sellerB); + Context.reset(); + + await patch(sellerA, sku, "A-only"); + + expect(Context.instance.engine.get(Api.LISTINGS, listingKey(sellerB, sku))).toBeNull(); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/operation/listingsVendorMode.test.ts b/local-ai-sandbox/test/operation/listingsVendorMode.test.ts new file mode 100644 index 000000000..0e33e0400 --- /dev/null +++ b/local-ai-sandbox/test/operation/listingsVendorMode.test.ts @@ -0,0 +1,192 @@ +/** + * Vendor-mode behaviour for the Listings Items API. + * + * Listings Items is documented "Sellers and Vendors", so every operation is + * callable in either mode. What differs is the data and a few submission + * features. MODE is read once at module load, so these tests reset the module + * graph and re-import with MODE=Vendor rather than mutating a live constant. + */ +import { describe, it, expect, afterEach, vi } from "vitest"; +import type { Request } from "express"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; + +const MP = "ATVPDKIKX0DER"; +const VENDOR_ID = "AMY6FKRUBY7XV"; + +/** Loads the registry and listings handlers under a given MODE. */ +async function loadUnderMode(mode: string) { + process.env.MODE = mode; + vi.resetModules(); + const registry = await import("../../src/registry/operationRegistry.js"); + const listings = await import("../../src/operation/listingsOperations.js"); + const context = await import("../../src/database/Context.js"); + return { registry, listings, context }; +} + +const originalMode = process.env.MODE; + +afterEach(() => { + if (originalMode === undefined) delete process.env.MODE; + else process.env.MODE = originalMode; + vi.resetModules(); +}); + +describe("Listings Items availability by mode", () => { + const listingsOps = ["getListingsItem", "searchListingsItems", "putListingsItem", "patchListingsItem", "deleteListingsItem"]; + + /** Maps each Listings Items operation to whether the current mode allows it. */ + async function allowanceByOperation(mode: string) { + const { registry } = await loadUnderMode(mode); + return Object.fromEntries( + listingsOps.map((op) => [op, registry.OPERATIONS_REGISTRY.isAllowedInCurrentMode(registry.buildKey("Listings", "2021-08-01", op))]), + ); + } + + const allAllowed = { + getListingsItem: true, + searchListingsItems: true, + putListingsItem: true, + patchListingsItem: true, + deleteListingsItem: true, + }; + + it("allows every Listings Items operation in Vendor mode", async () => { + expect(await allowanceByOperation("Vendor")).toEqual(allAllowed); + }); + + it("allows every Listings Items operation in Seller mode", async () => { + expect(await allowanceByOperation("Seller")).toEqual(allAllowed); + }); + + // Listings Restrictions is documented "Sellers only", unlike Listings Items. + it("refuses Listings Restrictions in Vendor mode but allows it in Seller mode", async () => { + const vendor = await loadUnderMode("Vendor"); + const restrictionsKey = vendor.registry.buildKey("Listings Restrictions", "2021-08-01", "getListingsRestrictions"); + expect(vendor.registry.OPERATIONS_REGISTRY.isAllowedInCurrentMode(restrictionsKey)).toBe(false); + + const seller = await loadUnderMode("Seller"); + expect(seller.registry.OPERATIONS_REGISTRY.isAllowedInCurrentMode(seller.registry.buildKey("Listings Restrictions", "2021-08-01", "getListingsRestrictions"))).toBe( + true, + ); + }); +}); + +describe("selling-partner-specific datasets in Vendor mode", () => { + /** Runs the real request pipeline for a GET carrying the given includedData. */ + async function validateIncludedData(mode: string, includedData: string) { + const { context } = await loadUnderMode(mode); + const { validateRequest } = await import("../../src/service/validationEngine.js"); + const { listingKey } = await import("../../src/operation/listingsItemModel.js"); + context.Context.reset(); + context.Context.instance.engine.put( + context.Api.LISTINGS, + listingKey(VENDOR_ID, "SKU-1"), + { sku: "SKU-1", sellerId: VENDOR_ID, attributes: {}, issues: [] }, + { silent: true }, + ); + return validateRequest({ + method: "GET", + path: `/listings/2021-08-01/items/${VENDOR_ID}/SKU-1`, + query: { marketplaceIds: MP, includedData }, + headers: {}, + body: undefined, + } as never); + } + + it("rejects the seller-only offers section for a vendor", async () => { + const result = (await validateIncludedData("Vendor", "offers")) as { + pass: boolean; + statusCode?: number; + body?: { errors: { message: string }[] }; + }; + expect(result.pass).toBe(false); + expect(result.statusCode).toBe(400); + expect(result.body?.errors[0].message).toContain("only available to sellers"); + }); + + it("rejects the seller-only fulfillmentAvailability section for a vendor", async () => { + const result = (await validateIncludedData("Vendor", "fulfillmentAvailability")) as { pass: boolean; statusCode?: number }; + expect(result.pass).toBe(false); + expect(result.statusCode).toBe(400); + }); + + it("accepts the vendor-only procurement section for a vendor", async () => { + const result = await validateIncludedData("Vendor", "procurement"); + expect(result.pass).toBe(true); + }); +}); + +describe("LISTING_OFFER_ONLY submissions by mode", () => { + /** Runs the real request pipeline for a PUT carrying the given requirements. */ + async function validatePut(mode: string, requirements: string) { + await loadUnderMode(mode); + const { validateRequest } = await import("../../src/service/validationEngine.js"); + return validateRequest({ + method: "PUT", + path: `/listings/2021-08-01/items/${VENDOR_ID}/SKU-1`, + query: { marketplaceIds: MP }, + headers: {}, + body: { productType: "PRODUCT", requirements, attributes: {} }, + } as never); + } + + // A vendor supplies the product itself, so it never lists against an ASIN + // owned by someone else. + it("rejects LISTING_OFFER_ONLY for a vendor", async () => { + const result = (await validatePut("Vendor", "LISTING_OFFER_ONLY")) as { + pass: boolean; + statusCode?: number; + body?: { errors: { message: string }[] }; + }; + expect(result.pass).toBe(false); + expect(result.statusCode).toBe(400); + expect(result.body?.errors[0].message).toContain("only available to sellers"); + }); + + it("accepts LISTING_PRODUCT_ONLY for a vendor", async () => { + expect((await validatePut("Vendor", "LISTING_PRODUCT_ONLY")).pass).toBe(true); + }); +}); + +describe("patchListingsItem delete operation by mode", () => { + /** A patch request body carrying a single operation. */ + function patchBody(op: string) { + return { + productType: "PRODUCT", + patches: [{ op, path: "/attributes/item_name", value: [{ value: "Renamed", marketplace_id: MP, language_tag: "en_US" }] }], + }; + } + + async function runPatch(mode: string, op: string) { + const { listings, context } = await loadUnderMode(mode); + context.Context.reset(); + const body = patchBody(op); + const validationResult: UnifiedValidationPass = { + pass: true, + operationId: "patchListingsItem", + apiName: "Listings", + apiVersion: "2021-08-01", + pathParams: { sellerId: VENDOR_ID, sku: "SKU-V1" }, + queryParams: { marketplaceIds: MP }, + body, + resolvedEntities: {}, + operation: {}, + }; + const result = await listings.patchListingsItemHandler(validationResult, { body } as Request); + return result.data as { body: { status: string; issues: { message: string }[] } }; + } + + it("rejects a delete patch in Vendor mode with an INVALID submission", async () => { + const body = (await runPatch("Vendor", "delete")).body; + expect(body.status).toBe("INVALID"); + expect(body.issues[0].message).toContain("not supported for vendors"); + }); + + it("accepts a replace patch in Vendor mode", async () => { + expect((await runPatch("Vendor", "replace")).body.status).toBe("ACCEPTED"); + }); + + it("accepts a delete patch in Seller mode", async () => { + expect((await runPatch("Seller", "delete")).body.status).toBe("ACCEPTED"); + }); +}); diff --git a/local-ai-sandbox/test/operation/notificationsDestinationRoundTrip.prop.test.ts b/local-ai-sandbox/test/operation/notificationsDestinationRoundTrip.prop.test.ts new file mode 100644 index 000000000..962c9e0ce --- /dev/null +++ b/local-ai-sandbox/test/operation/notificationsDestinationRoundTrip.prop.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import fc from "fast-check"; +import { Context, Api } from "../../src/database/Context.js"; +import { createDestinationHandler, getDestinationHandler } from "../../src/operation/notificationsOperations.js"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; +import type { Request } from "express"; + +function makeValidationResult(overrides: Partial = {}): UnifiedValidationPass { + return { + pass: true, + operationId: "testOp", + apiName: "Notifications", + apiVersion: "v1", + pathParams: {}, + queryParams: {}, + body: undefined, + operation: {}, + resolvedEntities: {}, + ...overrides, + }; +} + +describe("Property 1: Destination create-then-get round trip", () => { + beforeEach(() => { + Context.reset(); + }); + + // Feature: notifications-api, Property 1: Destination create-then-get round trip + it("creating a destination and then getting it returns identical name and resource", async () => { + /** + * Validates: Requirements 3.1, 3.6, 7.1, 8.3 + * + * For any valid destination name (1–256 characters) and valid SQS resource specification + * (ARN matching the arn:aws:sqs:*:*:* pattern), creating a destination via createDestination + * and then retrieving it via getDestination using the returned destinationId SHALL return + * a destination object with identical name and resource fields. + */ + await fc.assert( + fc.asyncProperty( + // Generate random valid names (1-256 chars, printable ASCII excluding control chars) + fc.string({ minLength: 1, maxLength: 256 }), + // Generate random SQS ARN parts + fc.tuple( + fc.stringMatching(/^[a-z]{2}-[a-z]+-[0-9]$/), // region (e.g. us-east-1) + fc.stringMatching(/^[0-9]{12}$/), // account id (12 digits) + fc.stringMatching(/^[a-zA-Z0-9_-]{1,80}$/), // queue name + ), + async (name, [region, accountId, queueName]) => { + Context.reset(); + const arn = `arn:aws:sqs:${region}:${accountId}:${queueName}`; + const resourceSpecification = { sqs: { arn } }; + + // Create + const createResult = await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + { body: { name, resourceSpecification } } as unknown as Request, + ); + + expect(createResult.statusCode).toBe(200); + const payload = (createResult.data.body as Record).payload as Record; + const destinationId = payload.destinationId as string; + + // Get — simulate the resolved entity (as the entityExistence validation would do) + const stored = Context.instance.engine.get(Api.NOTIFICATIONS, destinationId); + const getResult = await getDestinationHandler( + makeValidationResult({ + operationId: "getDestination", + pathParams: { destinationId }, + resolvedEntities: { destination: stored! }, + }), + {} as unknown as Request, + ); + + expect(getResult.statusCode).toBe(200); + const getPayload = (getResult.data.body as Record).payload as Record; + expect(getPayload.name).toBe(name); + expect(getPayload.resource).toEqual(resourceSpecification); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/operation/notificationsOperations.test.ts b/local-ai-sandbox/test/operation/notificationsOperations.test.ts new file mode 100644 index 000000000..7dd5902c2 --- /dev/null +++ b/local-ai-sandbox/test/operation/notificationsOperations.test.ts @@ -0,0 +1,522 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Context, Api } from "../../src/database/Context.js"; +import { + createDestinationHandler, + getDestinationsHandler, + getDestinationHandler, + deleteDestinationHandler, + createSubscriptionHandler, + getSubscriptionHandler, + getSubscriptionByIdHandler, + deleteSubscriptionByIdHandler, +} from "../../src/operation/notificationsOperations.js"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; +import type { Request } from "express"; + +function makeValidationResult(overrides: Partial = {}): UnifiedValidationPass { + return { + pass: true, + operationId: "testOp", + apiName: "Notifications", + apiVersion: "v1", + pathParams: {}, + queryParams: {}, + body: undefined, + operation: {}, + resolvedEntities: {}, + ...overrides, + }; +} + +function makeRequest(body: Record = {}): Request { + return { body } as unknown as Request; +} + +describe("notificationsOperations", () => { + beforeEach(() => { + Context.reset(); + }); + + // --- createDestination --- + + describe("createDestinationHandler", () => { + it("creates a destination with SQS resource successfully", async () => { + const result = await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ + name: "test-dest", + resourceSpecification: { sqs: { arn: "arn:aws:sqs:us-east-1:123456789:queue" } }, + }), + ); + + expect(result.statusCode).toBe(200); + const payload = (result.data.body as Record).payload as Record; + expect(payload.name).toBe("test-dest"); + expect(payload.destinationId).toBeDefined(); + expect(payload.resource).toEqual({ sqs: { arn: "arn:aws:sqs:us-east-1:123456789:queue" } }); + }); + + it("returns 501 for EventBridge resource", async () => { + const result = await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ + name: "eb-dest", + resourceSpecification: { eventBridge: { name: "my-bus", region: "us-east-1", accountId: "123" } }, + }), + ); + + expect(result.statusCode).toBe(501); + const body = result.data.body as { errors: Array<{ code: string }> }; + expect(body.errors[0].code).toBe("NotImplemented"); + }); + + it("returns 400 for invalid resource specification (no sqs or eventBridge)", async () => { + const result = await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ + name: "bad-dest", + resourceSpecification: {}, + }), + ); + + expect(result.statusCode).toBe(400); + const body = result.data.body as { errors: Array<{ code: string }> }; + expect(body.errors[0].code).toBe("InvalidInput"); + }); + + it("returns 409 when creating a destination with a duplicate name", async () => { + // Create first destination + await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ + name: "duplicate-name", + resourceSpecification: { sqs: { arn: "arn:aws:sqs:us-east-1:123456789:queue1" } }, + }), + ); + + // Attempt to create another with the same name — the handler enforces + // name uniqueness itself (mirroring the notificationType + payloadVersion + // uniqueness check in createSubscriptionHandler), so this must be rejected. + const result = await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ + name: "duplicate-name", + resourceSpecification: { sqs: { arn: "arn:aws:sqs:us-east-1:123456789:queue2" } }, + }), + ); + + expect(result.statusCode).toBe(409); + const body = result.data.body as { errors: Array<{ code: string; message: string }> }; + expect(body.errors[0].code).toBe("Conflict"); + expect(body.errors[0].message).toContain("duplicate-name"); + }); + }); + + // --- getDestinations --- + + describe("getDestinationsHandler", () => { + it("returns empty array when no destinations exist", async () => { + const result = await getDestinationsHandler(makeValidationResult({ operationId: "getDestinations" }), {} as unknown as Request); + + expect(result.statusCode).toBe(200); + const body = result.data.body as { payload: unknown[] }; + expect(body.payload).toEqual([]); + }); + + it("returns multiple destinations", async () => { + // Create two destinations directly + await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ name: "dest-1", resourceSpecification: { sqs: { arn: "arn:aws:sqs:us-east-1:111:q1" } } }), + ); + await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ name: "dest-2", resourceSpecification: { sqs: { arn: "arn:aws:sqs:us-east-1:222:q2" } } }), + ); + + const result = await getDestinationsHandler(makeValidationResult({ operationId: "getDestinations" }), {} as unknown as Request); + + expect(result.statusCode).toBe(200); + const body = result.data.body as { payload: unknown[] }; + expect(body.payload).toHaveLength(2); + }); + }); + + // --- getDestination --- + + describe("getDestinationHandler", () => { + it("returns a destination from resolvedEntities", async () => { + const destination = { + _key: "dest-123", + _type: "destination", + destinationId: "dest-123", + name: "my-dest", + resource: { sqs: { arn: "arn:aws:sqs:us-east-1:123:queue" } }, + }; + + const result = await getDestinationHandler( + makeValidationResult({ + operationId: "getDestination", + pathParams: { destinationId: "dest-123" }, + resolvedEntities: { destination }, + }), + {} as unknown as Request, + ); + + expect(result.statusCode).toBe(200); + const payload = (result.data.body as Record).payload as Record; + expect(payload.destinationId).toBe("dest-123"); + expect(payload.name).toBe("my-dest"); + expect(payload).not.toHaveProperty("_key"); + expect(payload).not.toHaveProperty("_type"); + }); + }); + + // --- deleteDestination --- + + describe("deleteDestinationHandler", () => { + it("deletes a destination with no subscriptions referencing it", async () => { + // Create a destination + const createResult = await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ name: "to-delete", resourceSpecification: { sqs: { arn: "arn:aws:sqs:us-east-1:123:q" } } }), + ); + const destId = ((createResult.data.body as Record).payload as Record).destinationId as string; + + const result = await deleteDestinationHandler( + makeValidationResult({ + operationId: "deleteDestination", + pathParams: { destinationId: destId }, + resolvedEntities: { destination: { _key: destId, destinationId: destId } }, + }), + {} as unknown as Request, + ); + + expect(result.statusCode).toBe(200); + }); + + it("returns 409 when active subscriptions exist for the destination", async () => { + // Create a destination + const createResult = await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ name: "dest-with-sub", resourceSpecification: { sqs: { arn: "arn:aws:sqs:us-east-1:123:q" } } }), + ); + const destId = ((createResult.data.body as Record).payload as Record).destinationId as string; + + // Create a subscription referencing the destination + await createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + }), + makeRequest({ payloadVersion: "1.0", destinationId: destId }), + ); + + // Try to delete the destination + const result = await deleteDestinationHandler( + makeValidationResult({ + operationId: "deleteDestination", + pathParams: { destinationId: destId }, + resolvedEntities: { destination: { _key: destId, destinationId: destId } }, + }), + {} as unknown as Request, + ); + + expect(result.statusCode).toBe(409); + const body = result.data.body as { errors: Array<{ code: string }> }; + expect(body.errors[0].code).toBe("Conflict"); + }); + }); + + // --- createSubscription --- + + describe("createSubscriptionHandler", () => { + let destinationId: string; + + beforeEach(async () => { + const createResult = await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ name: "sub-test-dest", resourceSpecification: { sqs: { arn: "arn:aws:sqs:us-east-1:123:q" } } }), + ); + destinationId = ((createResult.data.body as Record).payload as Record).destinationId as string; + }); + + it("creates a subscription successfully", async () => { + const result = await createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + }), + makeRequest({ payloadVersion: "1.0", destinationId }), + ); + + expect(result.statusCode).toBe(200); + const payload = (result.data.body as Record).payload as Record; + expect(payload.subscriptionId).toBeDefined(); + expect(payload.notificationType).toBe("ORDER_CHANGE"); + expect(payload.payloadVersion).toBe("1.0"); + expect(payload.destinationId).toBe(destinationId); + }); + + it("returns 400 for unsupported notificationType", async () => { + const result = await createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "INVALID_TYPE" }, + }), + makeRequest({ payloadVersion: "1.0", destinationId }), + ); + + expect(result.statusCode).toBe(400); + const body = result.data.body as { errors: Array<{ code: string; message: string }> }; + expect(body.errors[0].code).toBe("InvalidInput"); + expect(body.errors[0].message).toContain("INVALID_TYPE"); + }); + + it("returns 400 for unsupported payloadVersion", async () => { + const result = await createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + }), + makeRequest({ payloadVersion: "2.0", destinationId }), + ); + + expect(result.statusCode).toBe(400); + const body = result.data.body as { errors: Array<{ code: string; message: string }> }; + expect(body.errors[0].code).toBe("InvalidInput"); + expect(body.errors[0].message).toContain("2.0"); + }); + + it("returns 409 for duplicate subscription (same notificationType + payloadVersion)", async () => { + // First create succeeds + await createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + }), + makeRequest({ payloadVersion: "1.0", destinationId }), + ); + + // Second create with same type + version should fail + const result = await createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + }), + makeRequest({ payloadVersion: "1.0", destinationId }), + ); + + expect(result.statusCode).toBe(409); + const body = result.data.body as { errors: Array<{ code: string }> }; + expect(body.errors[0].code).toBe("Conflict"); + }); + + it("returns 400 for notificationType not available in current mode", async () => { + // createSubscriptionHandler reads the canonical CURRENT_MODE constant (resolved once at + // module load) rather than process.env.MODE directly, so exercising the "wrong mode" path + // requires mocking that constant and re-importing the module under a fresh module registry. + vi.resetModules(); + vi.doMock("../../src/registry/operationRegistry.js", async () => { + const actual = await vi.importActual("../../src/registry/operationRegistry.js"); + return { ...actual, CURRENT_MODE: "Vendor" }; + }); + + try { + const vendorModeOps = await import("../../src/operation/notificationsOperations.js"); + + const vendorDestResult = await vendorModeOps.createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ name: "vendor-mode-dest", resourceSpecification: { sqs: { arn: "arn:aws:sqs:us-east-1:123:q" } } }), + ); + const vendorDestId = ((vendorDestResult.data.body as Record).payload as Record).destinationId as string; + + const result = await vendorModeOps.createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + }), + makeRequest({ payloadVersion: "1.0", destinationId: vendorDestId }), + ); + + expect(result.statusCode).toBe(400); + const body = result.data.body as { errors: Array<{ code: string; message: string }> }; + expect(body.errors[0].code).toBe("InvalidInput"); + expect(body.errors[0].message).toContain("Vendor"); + } finally { + vi.doUnmock("../../src/registry/operationRegistry.js"); + vi.resetModules(); + } + }); + + it("returns 400 for missing payloadVersion", async () => { + const result = await createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + }), + makeRequest({ destinationId }), + ); + + expect(result.statusCode).toBe(400); + const body = result.data.body as { errors: Array<{ code: string; message: string }> }; + expect(body.errors[0].code).toBe("InvalidInput"); + expect(body.errors[0].message).toContain("payloadVersion"); + }); + + it("returns 400 for missing destinationId", async () => { + const result = await createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + }), + makeRequest({ payloadVersion: "1.0" }), + ); + + expect(result.statusCode).toBe(400); + const body = result.data.body as { errors: Array<{ code: string; message: string }> }; + expect(body.errors[0].code).toBe("InvalidInput"); + expect(body.errors[0].message).toContain("destinationId"); + }); + }); + + // --- getSubscription --- + + describe("getSubscriptionHandler", () => { + let destinationId: string; + + beforeEach(async () => { + const createResult = await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ name: "get-sub-dest", resourceSpecification: { sqs: { arn: "arn:aws:sqs:us-east-1:123:q" } } }), + ); + destinationId = ((createResult.data.body as Record).payload as Record).destinationId as string; + }); + + it("returns subscription for a given notificationType (latest version)", async () => { + await createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + }), + makeRequest({ payloadVersion: "1.0", destinationId }), + ); + + const result = await getSubscriptionHandler( + makeValidationResult({ + operationId: "getSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + queryParams: {}, + }), + {} as unknown as Request, + ); + + expect(result.statusCode).toBe(200); + const payload = (result.data.body as Record).payload as Record; + expect(payload.notificationType).toBe("ORDER_CHANGE"); + expect(payload.payloadVersion).toBe("1.0"); + }); + + it("returns subscription filtered by specific payloadVersion query param", async () => { + await createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + }), + makeRequest({ payloadVersion: "1.0", destinationId }), + ); + + const result = await getSubscriptionHandler( + makeValidationResult({ + operationId: "getSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + queryParams: { payloadVersion: "1.0" }, + }), + {} as unknown as Request, + ); + + expect(result.statusCode).toBe(200); + const payload = (result.data.body as Record).payload as Record; + expect(payload.payloadVersion).toBe("1.0"); + }); + + it("returns 404 when no subscription exists for the notificationType", async () => { + const result = await getSubscriptionHandler( + makeValidationResult({ + operationId: "getSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + queryParams: {}, + }), + {} as unknown as Request, + ); + + expect(result.statusCode).toBe(404); + const body = result.data.body as { errors: Array<{ code: string }> }; + expect(body.errors[0].code).toBe("NotFound"); + }); + }); + + // --- getSubscriptionById --- + + describe("getSubscriptionByIdHandler", () => { + it("returns a subscription from resolvedEntities", async () => { + const subscription = { + _key: "sub-456", + _type: "subscription", + subscriptionId: "sub-456", + notificationType: "ORDER_CHANGE", + payloadVersion: "1.0", + destinationId: "dest-789", + }; + + const result = await getSubscriptionByIdHandler( + makeValidationResult({ + operationId: "getSubscriptionById", + pathParams: { subscriptionId: "sub-456" }, + resolvedEntities: { subscription }, + }), + {} as unknown as Request, + ); + + expect(result.statusCode).toBe(200); + const payload = (result.data.body as Record).payload as Record; + expect(payload.subscriptionId).toBe("sub-456"); + expect(payload.notificationType).toBe("ORDER_CHANGE"); + expect(payload).not.toHaveProperty("_key"); + expect(payload).not.toHaveProperty("_type"); + }); + }); + + // --- deleteSubscriptionById --- + + describe("deleteSubscriptionByIdHandler", () => { + it("deletes a subscription successfully", async () => { + // Create a destination and subscription first + const destResult = await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }), + makeRequest({ name: "del-sub-dest", resourceSpecification: { sqs: { arn: "arn:aws:sqs:us-east-1:123:q" } } }), + ); + const destId = ((destResult.data.body as Record).payload as Record).destinationId as string; + + const subResult = await createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + }), + makeRequest({ payloadVersion: "1.0", destinationId: destId }), + ); + const subId = ((subResult.data.body as Record).payload as Record).subscriptionId as string; + + const result = await deleteSubscriptionByIdHandler( + makeValidationResult({ + operationId: "deleteSubscriptionById", + pathParams: { subscriptionId: subId }, + }), + {} as unknown as Request, + ); + + expect(result.statusCode).toBe(200); + expect(result.data.body).toEqual({}); + }); + }); +}); diff --git a/local-ai-sandbox/test/operation/notificationsSubscriptionDeleteQuery.prop.test.ts b/local-ai-sandbox/test/operation/notificationsSubscriptionDeleteQuery.prop.test.ts new file mode 100644 index 000000000..ab11fd013 --- /dev/null +++ b/local-ai-sandbox/test/operation/notificationsSubscriptionDeleteQuery.prop.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import fc from "fast-check"; +import { Api, Context } from "../../src/database/Context.js"; +import { + createDestinationHandler, + createSubscriptionHandler, + deleteSubscriptionByIdHandler, +} from "../../src/operation/notificationsOperations.js"; +import type { Request } from "express"; + +function makeValidationResult(overrides: Record = {}): Record { + return { + pass: true, + operationId: "testOp", + apiName: "Notifications", + apiVersion: "v1", + pathParams: {}, + queryParams: {}, + body: undefined, + operation: {}, + resolvedEntities: {}, + ...overrides, + }; +} + +describe("Property 6: Subscription delete-then-query", () => { + beforeEach(() => { + Context.reset(); + }); + + it("deleting a subscription and then querying it returns 404 NotFound", async () => { + /** + * Validates: Requirements 4.12, 7.4, 8.4 + * + * For any valid subscription created via createSubscription, calling deleteSubscriptionById + * with the returned subscriptionId and then querying the database for that subscription + * SHALL result in the document being absent (null), which is the condition that causes + * the entityExistence validation rule to return a 404 NotFound response. + */ + await fc.assert( + fc.asyncProperty( + fc.option( + fc.record({ + eventFilter: fc.option( + fc.record({ marketplaceId: fc.string({ minLength: 1, maxLength: 20 }) }), + { nil: undefined }, + ), + }), + { nil: undefined }, + ), + async (processingDirective) => { + // Reset for each run since notificationType + payloadVersion uniqueness constraint + Context.reset(); + + // Create a destination first + const destResult = await createDestinationHandler( + makeValidationResult({ operationId: "createDestination" }) as never, + { + body: { + name: "prop-test-dest", + resourceSpecification: { sqs: { arn: "arn:aws:sqs:us-east-1:123456789012:test-queue" } }, + }, + } as unknown as Request, + ); + expect(destResult.statusCode).toBe(200); + const destinationId = ((destResult.data.body as Record).payload as Record).destinationId as string; + + // Build subscription request body + const body: Record = { + payloadVersion: "1.0", + destinationId, + }; + if (processingDirective !== undefined) { + body.processingDirective = processingDirective; + } + + // Create subscription + const createResult = await createSubscriptionHandler( + makeValidationResult({ + operationId: "createSubscription", + pathParams: { notificationType: "ORDER_CHANGE" }, + }) as never, + { body } as unknown as Request, + ); + expect(createResult.statusCode).toBe(200); + const subscriptionId = ((createResult.data.body as Record).payload as Record).subscriptionId as string; + + // Delete subscription + const deleteResult = await deleteSubscriptionByIdHandler( + makeValidationResult({ + operationId: "deleteSubscriptionById", + pathParams: { subscriptionId }, + }) as never, + {} as unknown as Request, + ); + expect(deleteResult.statusCode).toBe(200); + + // Verify the subscription is no longer in the database. + // In the real request flow, the entityExistence validation rule would + // look up the subscriptionId and return 404 "NotFound" when it's absent. + const stored = Context.instance.engine.get(Api.NOTIFICATIONS, subscriptionId); + expect(stored).toBeNull(); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/operation/pricingOperations.integration.test.ts b/local-ai-sandbox/test/operation/pricingOperations.integration.test.ts new file mode 100644 index 000000000..edea4f193 --- /dev/null +++ b/local-ai-sandbox/test/operation/pricingOperations.integration.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { executeValidation } from "../../src/service/validationEngine.js"; +import type { RequestContext, UnifiedValidationPass } from "../../src/validation/validationTypes.js"; +import { OPERATIONS_REGISTRY } from "../../src/registry/operationRegistry.js"; +import { Context, Api } from "../../src/database/Context.js"; + +const PRICING_KEY = "Product Pricing:2022-05-01:getFeaturedOfferExpectedPriceBatch"; + +describe("Product Pricing getFeaturedOfferExpectedPriceBatch Integration Tests", () => { + beforeEach(() => { + Context.reset(); + process.env.REGION = "NA"; + process.env.MODE = "Seller"; + }); + + afterEach(() => { + Context.instance.engine.getCollection(Api.LISTINGS)?.clear(); + delete process.env.REGION; + }); + + describe("End-to-end request flow: validation → handler with mixed batch", () => { + it("returns outer HTTP 200 with correct per-item responses for found SKU, not-found SKU, and invalid marketplace", async () => { + // Insert a listing for the "found" SKU + Context.instance.engine.put(Api.LISTINGS, "FOUND-SKU-001", { + _key: "FOUND-SKU-001", + sku: "FOUND-SKU-001", + purchasable_offer: [{ our_price: [{ schedule: [{ value_with_tax: 49.99 }] }] }], + externally_assigned_product_identifier: [{ value: "B0TESTASN01", type: "asin" }], + fulfillment_availability: [{ fulfillment_channel_code: "DEFAULT" }], + }); + + const requests = [ + // Item 1: valid marketplace, SKU exists in DB → should succeed + { marketplaceId: "ATVPDKIKX0DER", sku: "FOUND-SKU-001" }, + // Item 2: valid marketplace, SKU does NOT exist → should get INVALID_SKU error + { marketplaceId: "ATVPDKIKX0DER", sku: "NONEXISTENT-SKU-999" }, + // Item 3: invalid marketplace ID for NA region → should get InvalidMarketplaceId error + { marketplaceId: "INVALID_MARKETPLACE_XYZ", sku: "FOUND-SKU-001" }, + ]; + + // Step 1: Run validation pipeline + const validationContext: RequestContext = { + apiName: "Product Pricing", + apiVersion: "2022-05-01", + operationId: "getFeaturedOfferExpectedPriceBatch", + method: "POST", + pathParams: {}, + queryParams: {}, + body: { requests }, + }; + + const validationResult = await executeValidation(validationContext); + expect(validationResult.pass).toBe(true); + + // Step 2: Invoke the handler directly (as the controller would after validation passes) + const handler = OPERATIONS_REGISTRY.get(PRICING_KEY); + expect(handler).toBeDefined(); + + const handlerResult = await handler!( + { + pass: true, + operationId: "getFeaturedOfferExpectedPriceBatch", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + pathParams: {}, + queryParams: {}, + body: { requests }, + resolvedEntities: {}, + operation: {}, + }, + {} as never, + ); + + // Verify outer response is HTTP 200 + expect(handlerResult.statusCode).toBe(200); + + const body = handlerResult.data.body as { responses: Record[] }; + expect(body.responses).toBeDefined(); + expect(body.responses).toHaveLength(3); + + // Item 1: Found SKU — expect 200 with pricing data + const item1 = body.responses[0] as { + request: { marketplaceId: string; sku: string }; + status: { statusCode: number; reasonPhrase: string }; + headers: Record; + body: { + offerIdentifier: { marketplaceId: string; sku: string; asin: string; fulfillmentType: string }; + featuredOfferExpectedPriceResults: Array<{ + resultStatus: string; + featuredOfferExpectedPrice: { listingPrice: { currencyCode: string; amount: number } }; + competingFeaturedOffer: { + offerIdentifier: { marketplaceId: string; sellerId: string; asin: string; fulfillmentType: string }; + condition: string; + price: { listingPrice: { currencyCode: string; amount: number }; shippingPrice: { currencyCode: string; amount: number } }; + }; + currentFeaturedOffer: { + offerIdentifier: { marketplaceId: string; sellerId: string; asin: string; fulfillmentType: string }; + condition: string; + price: { listingPrice: { currencyCode: string; amount: number } }; + }; + }>; + }; + }; + + expect(item1.status.statusCode).toBe(200); + expect(item1.request.sku).toBe("FOUND-SKU-001"); + expect(item1.request.marketplaceId).toBe("ATVPDKIKX0DER"); + expect(item1.body.offerIdentifier).toBeDefined(); + expect(item1.body.offerIdentifier.marketplaceId).toBe("ATVPDKIKX0DER"); + expect(item1.body.offerIdentifier.sku).toBe("FOUND-SKU-001"); + expect(item1.body.offerIdentifier.asin).toBe("B0TESTASN01"); + expect(item1.body.offerIdentifier.fulfillmentType).toBe("MFN"); + expect(item1.body.featuredOfferExpectedPriceResults).toHaveLength(1); + expect(item1.body.featuredOfferExpectedPriceResults[0].resultStatus).toBe("VALID_FOEP"); + expect(item1.body.featuredOfferExpectedPriceResults[0].featuredOfferExpectedPrice.listingPrice.currencyCode).toBe("USD"); + expect(item1.body.featuredOfferExpectedPriceResults[0].featuredOfferExpectedPrice.listingPrice.amount).toBeGreaterThan(0); + expect(item1.body.featuredOfferExpectedPriceResults[0].competingFeaturedOffer.condition).toBe("New"); + expect(item1.body.featuredOfferExpectedPriceResults[0].competingFeaturedOffer.price.listingPrice.currencyCode).toBe("USD"); + expect(item1.body.featuredOfferExpectedPriceResults[0].competingFeaturedOffer.price.shippingPrice.currencyCode).toBe("USD"); + expect(item1.body.featuredOfferExpectedPriceResults[0].currentFeaturedOffer.price.listingPrice.currencyCode).toBe("USD"); + + // Item 2: Not-found SKU — expect 400 with INVALID_SKU + const item2 = body.responses[1] as { + request: { marketplaceId: string; sku: string }; + status: { statusCode: number; reasonPhrase: string }; + body: { errors: Array<{ code: string; message: string }> }; + }; + + expect(item2.status.statusCode).toBe(400); + expect(item2.request.sku).toBe("NONEXISTENT-SKU-999"); + expect(item2.body.errors).toHaveLength(1); + expect(item2.body.errors[0].code).toBe("INVALID_SKU"); + + // Item 3: Invalid marketplace ID — expect 400 with InvalidMarketplaceId + const item3 = body.responses[2] as { + request: { marketplaceId: string; sku: string }; + status: { statusCode: number; reasonPhrase: string }; + body: { errors: Array<{ code: string; message: string }> }; + }; + + expect(item3.status.statusCode).toBe(400); + expect(item3.request.marketplaceId).toBe("INVALID_MARKETPLACE_XYZ"); + expect(item3.body.errors).toHaveLength(1); + expect(item3.body.errors[0].code).toBe("InvalidMarketplaceId"); + }); + + it("response shape matches OpenAPI spec structure for success items", async () => { + // Insert a listing with full data + Context.instance.engine.put(Api.LISTINGS, "SHAPE-TEST-SKU", { + _key: "SHAPE-TEST-SKU", + sku: "SHAPE-TEST-SKU", + purchasable_offer: [{ our_price: [{ schedule: [{ value_with_tax: 99.99 }] }] }], + externally_assigned_product_identifier: [{ value: "B0SHAPEASIN", type: "asin" }], + fulfillment_availability: [{ fulfillment_channel_code: "AMAZON_NA" }], + }); + + const handler = OPERATIONS_REGISTRY.get(PRICING_KEY)!; + const result = await handler( + { + pass: true, + operationId: "getFeaturedOfferExpectedPriceBatch", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + pathParams: {}, + queryParams: {}, + body: { requests: [{ marketplaceId: "ATVPDKIKX0DER", sku: "SHAPE-TEST-SKU" }] }, + resolvedEntities: {}, + operation: {}, + }, + {} as never, + ); + + const body = result.data.body as { responses: Record[] }; + const subResponse = body.responses[0] as Record; + + // Verify top-level sub-response structure per OpenAPI spec + expect(subResponse).toHaveProperty("request"); + expect(subResponse).toHaveProperty("status"); + expect(subResponse).toHaveProperty("headers"); + expect(subResponse).toHaveProperty("body"); + + const status = subResponse.status as { statusCode: number; reasonPhrase: string }; + expect(status.statusCode).toBe(200); + expect(status.reasonPhrase).toBe("Success"); + + const headers = subResponse.headers as Record; + expect(headers["Content-Type"]).toBe("application/json"); + + const responseBody = subResponse.body as Record; + expect(responseBody).toHaveProperty("offerIdentifier"); + expect(responseBody).toHaveProperty("featuredOfferExpectedPriceResults"); + + // Verify offerIdentifier shape + const offerIdentifier = responseBody.offerIdentifier as Record; + expect(offerIdentifier).toHaveProperty("marketplaceId"); + expect(offerIdentifier).toHaveProperty("sku"); + expect(offerIdentifier).toHaveProperty("asin"); + expect(offerIdentifier).toHaveProperty("fulfillmentType"); + expect(offerIdentifier.fulfillmentType).toBe("AFN"); // AMAZON_NA → AFN + + // Verify featuredOfferExpectedPriceResults shape + const foepResults = responseBody.featuredOfferExpectedPriceResults as Array>; + expect(foepResults).toHaveLength(1); + + const foepResult = foepResults[0]; + expect(foepResult).toHaveProperty("resultStatus"); + expect(foepResult).toHaveProperty("featuredOfferExpectedPrice"); + expect(foepResult).toHaveProperty("competingFeaturedOffer"); + expect(foepResult).toHaveProperty("currentFeaturedOffer"); + + // Verify MoneyType shapes + const foep = foepResult.featuredOfferExpectedPrice as { listingPrice: { currencyCode: string; amount: number } }; + expect(foep.listingPrice).toHaveProperty("currencyCode"); + expect(foep.listingPrice).toHaveProperty("amount"); + expect(typeof foep.listingPrice.amount).toBe("number"); + + const competing = foepResult.competingFeaturedOffer as { + offerIdentifier: Record; + condition: string; + price: { listingPrice: { currencyCode: string; amount: number }; shippingPrice: { currencyCode: string; amount: number } }; + }; + expect(competing.offerIdentifier).toHaveProperty("marketplaceId"); + expect(competing.offerIdentifier).toHaveProperty("sellerId"); + expect(competing.offerIdentifier).toHaveProperty("asin"); + expect(competing.offerIdentifier).toHaveProperty("fulfillmentType"); + expect(competing.condition).toBe("New"); + expect(competing.price.listingPrice).toHaveProperty("currencyCode"); + expect(competing.price.listingPrice).toHaveProperty("amount"); + expect(competing.price.shippingPrice).toHaveProperty("currencyCode"); + expect(competing.price.shippingPrice).toHaveProperty("amount"); + + const current = foepResult.currentFeaturedOffer as { + offerIdentifier: Record; + condition: string; + price: { listingPrice: { currencyCode: string; amount: number } }; + }; + expect(current.offerIdentifier).toHaveProperty("marketplaceId"); + expect(current.offerIdentifier).toHaveProperty("sellerId"); + expect(current.offerIdentifier).toHaveProperty("asin"); + expect(current.offerIdentifier).toHaveProperty("fulfillmentType"); + expect(current.condition).toBe("New"); + expect(current.price.listingPrice).toHaveProperty("currencyCode"); + expect(current.price.listingPrice).toHaveProperty("amount"); + }); + + it("batch size validation rejects oversized batch before handler executes", async () => { + const oversizedRequests = Array.from({ length: 41 }, (_, i) => ({ + marketplaceId: "ATVPDKIKX0DER", + sku: `SKU-${i}`, + })); + + const validationContext: RequestContext = { + apiName: "Product Pricing", + apiVersion: "2022-05-01", + operationId: "getFeaturedOfferExpectedPriceBatch", + method: "POST", + pathParams: {}, + queryParams: {}, + body: { requests: oversizedRequests }, + }; + + const result = await executeValidation(validationContext); + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(400); + const body = result.body as { errors: Array<{ code: string; message: string }> }; + expect(body.errors[0].code).toBe("InvalidInput"); + } + }); + }); +}); diff --git a/local-ai-sandbox/test/operation/pricingOperations.prop.test.ts b/local-ai-sandbox/test/operation/pricingOperations.prop.test.ts new file mode 100644 index 000000000..ac1a0788b --- /dev/null +++ b/local-ai-sandbox/test/operation/pricingOperations.prop.test.ts @@ -0,0 +1,524 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fc from "fast-check"; +import { getFeaturedOfferExpectedPriceBatchHandler } from "../../src/operation/pricingOperations.js"; +import { Context, Api } from "../../src/database/Context.js"; +import type { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; + +describe("getFeaturedOfferExpectedPriceBatch Property-Based Tests", () => { + beforeEach(() => { + Context.reset(); + process.env.REGION = "NA"; + }); + + afterEach(() => { + Context.instance.engine.getCollection(Api.LISTINGS)?.clear(); + }); + + // Feature: product-pricing-api, Property 1: Batch response cardinality + it("Property 1: Batch response cardinality", async () => { + /** + * Validates: Requirements 3.1 + * + * For any valid batch request containing N items (1 <= N <= 40), the handler + * SHALL return a `responses` array with exactly N entries. + */ + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 40 }), async (batchSize) => { + // Build a requests array of the generated batch size + // Each item uses a valid NA marketplace ID and a unique SKU + const requests = Array.from({ length: batchSize }, (_, i) => ({ + marketplaceId: "ATVPDKIKX0DER", + sku: `BATCH-SKU-${i}`, + })); + + const validationResult: UnifiedValidationPass = { + pass: true, + operationId: "getFeaturedOfferExpectedPriceBatch", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + pathParams: {}, + queryParams: {}, + body: { requests }, + resolvedEntities: {}, + operation: {}, + }; + + // SKUs don't exist in DB — handler still produces one sub-response per item + const context = await getFeaturedOfferExpectedPriceBatchHandler(validationResult, {} as never); + const body = context.data.body as { responses: unknown[] }; + expect(body.responses).toHaveLength(batchSize); + }), + { numRuns: 100 }, + ); + }); + + // Feature: product-pricing-api, Property 3: FOEP price bound relative to competing offer + it("Property 3: FOEP price bound relative to competing offer", async () => { + /** + * Validates: Requirements 3.4 + * + * For any listing that exists in the database with a resolvable price, the + * featuredOfferExpectedPrice.listingPrice.amount SHALL be between 2% and 8% + * below the competingFeaturedOffer.price.listingPrice.amount. + */ + await fc.assert( + fc.asyncProperty( + fc.float({ min: Math.fround(0.01), max: Math.fround(9999.99), noNaN: true }), + fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0), + async (listingPrice, skuBase) => { + const sku = `FOEP-${skuBase}`; + + // Insert a listing into the DB with the generated price + Context.instance.engine.put(Api.LISTINGS, sku, { + _key: sku, + sku, + purchasable_offer: [ + { + our_price: [ + { + schedule: [{ value_with_tax: listingPrice }], + }, + ], + }, + ], + }); + + const validationResult: UnifiedValidationPass = { + pass: true, + operationId: "getFeaturedOfferExpectedPriceBatch", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + pathParams: {}, + queryParams: {}, + body: { + requests: [{ marketplaceId: "ATVPDKIKX0DER", sku, uri: "/products/pricing/v0/items", method: "GET" }], + }, + resolvedEntities: {}, + operation: {}, + }; + + const context = await getFeaturedOfferExpectedPriceBatchHandler(validationResult, {} as never); + + const responses = (context.data.body as { responses: Record[] }).responses; + const subResponse = responses[0] as { + body: { + featuredOfferExpectedPriceResults: Array<{ + featuredOfferExpectedPrice: { listingPrice: { amount: number } }; + competingFeaturedOffer: { price: { listingPrice: { amount: number } } }; + }>; + }; + }; + + const foepPrice = subResponse.body.featuredOfferExpectedPriceResults[0].featuredOfferExpectedPrice.listingPrice.amount; + const competingPrice = subResponse.body.featuredOfferExpectedPriceResults[0].competingFeaturedOffer.price.listingPrice.amount; + + // FOEP should be at most 8% below competing offer (i.e., >= 92% of competing) + // Account for cent-rounding: tolerance of 0.01 (one cent) for rounding effects on small prices + expect(foepPrice).toBeGreaterThanOrEqual(competingPrice * 0.92 - 0.01); + // FOEP should be at least 2% below competing offer (i.e., <= 98% of competing) + expect(foepPrice).toBeLessThanOrEqual(competingPrice * 0.98 + 0.01); + + // Clean up after each iteration + Context.instance.engine.getCollection(Api.LISTINGS)?.clear(); + }, + ), + { numRuns: 100 }, + ); + }); + + // Feature: product-pricing-api, Property 2: Not-found SKU error response + it("Property 2: Not-found SKU error response", async () => { + /** + * Validates: Requirements 3.3 + * + * For any SKU string that does not exist as a `_key` in the `listings` namespace, + * the corresponding sub-response SHALL have `status.statusCode` 400 and a `body.errors` + * array containing an object with `code` equal to `"INVALID_SKU"`. + */ + await fc.assert( + fc.asyncProperty(fc.string({ minLength: 1 }), async (sku) => { + // DB listings are cleared in afterEach — no SKUs exist at the start of each test + const validationResult: UnifiedValidationPass = { + pass: true, + operationId: "getFeaturedOfferExpectedPriceBatch", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + pathParams: {}, + queryParams: {}, + body: { + requests: [{ marketplaceId: "ATVPDKIKX0DER", sku }], + }, + resolvedEntities: {}, + operation: {}, + }; + + const result = await getFeaturedOfferExpectedPriceBatchHandler(validationResult, {} as never); + + const responses = (result.data.body as { responses: Record[] }).responses; + expect(responses).toHaveLength(1); + + const subResponse = responses[0] as { status: { statusCode: number }; body: { errors: { code: string }[] } }; + expect(subResponse.status.statusCode).toBe(400); + expect(subResponse.body.errors).toBeInstanceOf(Array); + expect(subResponse.body.errors.length).toBeGreaterThanOrEqual(1); + expect(subResponse.body.errors[0].code).toBe("INVALID_SKU"); + }), + { numRuns: 100 }, + ); + }); + + // Feature: product-pricing-api, Property 4: Competing offer structural invariant + it("Property 4: Competing offer structural invariant", async () => { + /** + * Validates: Requirements 3.5 + * + * For any found listing, the competingFeaturedOffer SHALL have a sellerId distinct + * from any seller ID derivable from the listing, condition equal to "New", and a + * price object containing both listingPrice and shippingPrice with valid MoneyType values. + */ + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 30 }).filter((s) => s.trim().length > 0), + async (sku) => { + // Insert a minimal listing into the DB + Context.instance.engine.put(Api.LISTINGS, sku, { _key: sku, sku }); + + // Build a valid request + const validationResult: UnifiedValidationPass = { + pass: true, + operationId: "getFeaturedOfferExpectedPriceBatch", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + pathParams: {}, + queryParams: {}, + body: { + requests: [{ marketplaceId: "ATVPDKIKX0DER", sku, uri: "/products/pricing/v0/items", method: "GET" }], + }, + resolvedEntities: {}, + operation: {}, + }; + + // Call the handler + const context = await getFeaturedOfferExpectedPriceBatchHandler(validationResult, {} as never); + + const responses = (context.data.body as { responses: Record[] }).responses; + expect(responses).toHaveLength(1); + + const subResponse = responses[0] as { status: { statusCode: number }; body: Record }; + expect(subResponse.status.statusCode).toBe(200); + + const body = subResponse.body as { + featuredOfferExpectedPriceResults: Array<{ + competingFeaturedOffer: { + offerIdentifier: { sellerId: string }; + condition: string; + price: { + listingPrice: { currencyCode: string; amount: number }; + shippingPrice: { currencyCode: string; amount: number }; + }; + }; + }>; + }; + + const competingOffer = body.featuredOfferExpectedPriceResults[0].competingFeaturedOffer; + + // sellerId is a non-empty string + expect(competingOffer.offerIdentifier.sellerId).toBeTruthy(); + expect(typeof competingOffer.offerIdentifier.sellerId).toBe("string"); + expect(competingOffer.offerIdentifier.sellerId.length).toBeGreaterThan(0); + + // sellerId is distinct from the current seller + expect(competingOffer.offerIdentifier.sellerId).not.toBe("CURRENT_SELLER"); + + // condition is "New" + expect(competingOffer.condition).toBe("New"); + + // listingPrice has valid MoneyType + expect(typeof competingOffer.price.listingPrice.currencyCode).toBe("string"); + expect(competingOffer.price.listingPrice.currencyCode.length).toBeGreaterThan(0); + expect(typeof competingOffer.price.listingPrice.amount).toBe("number"); + expect(competingOffer.price.listingPrice.amount).toBeGreaterThan(0); + + // shippingPrice has valid MoneyType + expect(typeof competingOffer.price.shippingPrice.currencyCode).toBe("string"); + expect(competingOffer.price.shippingPrice.currencyCode.length).toBeGreaterThan(0); + expect(typeof competingOffer.price.shippingPrice.amount).toBe("number"); + expect(competingOffer.price.shippingPrice.amount).toBeGreaterThanOrEqual(0); + + // Clean up after each iteration + Context.instance.engine.getCollection(Api.LISTINGS)?.clear(); + }, + ), + { numRuns: 100 }, + ); + }); + + // Feature: product-pricing-api, Property 5: Offer identifier data preservation + it("Property 5: Offer identifier data preservation", async () => { + /** + * Validates: Requirements 3.6 + * + * For any found listing with known asin, sku, marketplaceId, and fulfillmentType, + * the offerIdentifier in the sub-response body SHALL contain values matching the + * listing's stored data for each of these fields. + */ + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0), + fc.stringMatching(/^[A-Z0-9]{8}$/).map((s) => "B0" + s), + fc.constantFrom("DEFAULT", "AMAZON_NA"), + async (sku, asin, channelCode) => { + // Insert a listing with the generated data + Context.instance.engine.put(Api.LISTINGS, sku, { + _key: sku, + sku, + externally_assigned_product_identifier: [{ value: asin, type: "asin" }], + fulfillment_availability: [{ fulfillment_channel_code: channelCode }], + }); + + const validationResult: UnifiedValidationPass = { + pass: true, + operationId: "getFeaturedOfferExpectedPriceBatch", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + pathParams: {}, + queryParams: {}, + body: { + requests: [{ marketplaceId: "ATVPDKIKX0DER", sku, uri: "/products/pricing/v0/items", method: "GET" }], + }, + resolvedEntities: {}, + operation: {}, + }; + + const context = await getFeaturedOfferExpectedPriceBatchHandler(validationResult, {} as never); + + const responses = (context.data.body as { responses: Record[] }).responses; + const body = responses[0].body as Record; + const offerIdentifier = body.offerIdentifier as { + sku: string; + asin: string; + marketplaceId: string; + fulfillmentType: string; + }; + + expect(offerIdentifier.sku).toBe(sku); + expect(offerIdentifier.asin).toBe(asin); + expect(offerIdentifier.marketplaceId).toBe("ATVPDKIKX0DER"); + + const expectedFulfillmentType = channelCode === "AMAZON_NA" ? "AFN" : "MFN"; + expect(offerIdentifier.fulfillmentType).toBe(expectedFulfillmentType); + + // Clean up after each iteration + Context.instance.engine.getCollection(Api.LISTINGS)?.clear(); + }, + ), + { numRuns: 100 }, + ); + }); + + // Feature: product-pricing-api, Property 6: Marketplace-correct currency codes + it("Property 6: Marketplace-correct currency codes", async () => { + /** + * Validates: Requirements 3.7 + * + * For any sub-response with status.statusCode 200, all MoneyType objects + * (in featuredOfferExpectedPrice, competingFeaturedOffer.price, and + * currentFeaturedOffer.price) SHALL use the currencyCode appropriate to the + * marketplaceId of that request item. + */ + const expectedCurrencies: Record = { + ATVPDKIKX0DER: "USD", + A2EUQ1WTGCTBG2: "CAD", + A1AM78C64UM0Y8: "MXN", + A2Q3Y263D00KWC: "BRL", + }; + + await fc.assert( + fc.asyncProperty( + fc.constantFrom("ATVPDKIKX0DER", "A2EUQ1WTGCTBG2", "A1AM78C64UM0Y8", "A2Q3Y263D00KWC"), + async (marketplaceId) => { + const sku = `CURRENCY-TEST-${marketplaceId}`; + + // Insert a listing into the DB with a known SKU + Context.instance.engine.put(Api.LISTINGS, sku, { + _key: sku, + sku, + purchasable_offer: [ + { + our_price: [ + { + schedule: [{ value_with_tax: 49.99 }], + }, + ], + }, + ], + }); + + const validationResult: UnifiedValidationPass = { + pass: true, + operationId: "getFeaturedOfferExpectedPriceBatch", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + pathParams: {}, + queryParams: {}, + body: { + requests: [{ marketplaceId, sku, uri: "/products/pricing/v0/items", method: "GET" }], + }, + resolvedEntities: {}, + operation: {}, + }; + + const context = await getFeaturedOfferExpectedPriceBatchHandler(validationResult, {} as never); + + const responses = (context.data.body as { responses: Record[] }).responses; + const subResponse = responses[0] as { + status: { statusCode: number }; + body: { + featuredOfferExpectedPriceResults: Array<{ + featuredOfferExpectedPrice: { listingPrice: { currencyCode: string; amount: number } }; + competingFeaturedOffer: { + price: { + listingPrice: { currencyCode: string; amount: number }; + shippingPrice: { currencyCode: string; amount: number }; + }; + }; + currentFeaturedOffer: { + price: { + listingPrice: { currencyCode: string; amount: number }; + }; + }; + }>; + }; + }; + + expect(subResponse.status.statusCode).toBe(200); + + const expectedCurrency = expectedCurrencies[marketplaceId]; + const result = subResponse.body.featuredOfferExpectedPriceResults[0]; + + // FOEP listingPrice currency + expect(result.featuredOfferExpectedPrice.listingPrice.currencyCode).toBe(expectedCurrency); + + // Competing offer listingPrice currency + expect(result.competingFeaturedOffer.price.listingPrice.currencyCode).toBe(expectedCurrency); + + // Competing offer shippingPrice currency + expect(result.competingFeaturedOffer.price.shippingPrice.currencyCode).toBe(expectedCurrency); + + // Current offer listingPrice currency + expect(result.currentFeaturedOffer.price.listingPrice.currencyCode).toBe(expectedCurrency); + + // Clean up after each iteration + Context.instance.engine.getCollection(Api.LISTINGS)?.clear(); + }, + ), + { numRuns: 100 }, + ); + }); + + // Feature: product-pricing-api, Property 7: Handler determinism (idempotence) + it("Property 7: Handler determinism (idempotence)", async () => { + /** + * Validates: Requirements 4.1, 4.2, 4.3 + * + * For any valid request and any database state, calling the handler twice with the + * same inputs SHALL produce deeply equal responses. + */ + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0), + fc.float({ min: Math.fround(0.01), max: Math.fround(9999.99), noNaN: true }), + fc.constantFrom("DEFAULT", "AMAZON_NA", "AMAZON_EU", "AMAZON_FE"), + async (skuBase, listingPrice, channelCode) => { + const sku = `DET-${skuBase}`; + + // Insert a listing into the DB with the generated data + Context.instance.engine.put(Api.LISTINGS, sku, { + _key: sku, + sku, + purchasable_offer: [ + { + our_price: [ + { + schedule: [{ value_with_tax: listingPrice }], + }, + ], + }, + ], + fulfillment_availability: [{ fulfillment_channel_code: channelCode }], + }); + + const validationResult: UnifiedValidationPass = { + pass: true, + operationId: "getFeaturedOfferExpectedPriceBatch", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + pathParams: {}, + queryParams: {}, + body: { + requests: [{ marketplaceId: "ATVPDKIKX0DER", sku, uri: "/products/pricing/v0/items", method: "GET" }], + }, + resolvedEntities: {}, + operation: {}, + }; + + // Call the handler twice with the same inputs + const result1 = await getFeaturedOfferExpectedPriceBatchHandler(validationResult, {} as never); + const result2 = await getFeaturedOfferExpectedPriceBatchHandler(validationResult, {} as never); + + // Verify deep equality of response bodies + expect(result1.data.body).toEqual(result2.data.body); + + // Clean up after each iteration + Context.instance.engine.getCollection(Api.LISTINGS)?.clear(); + }, + ), + { numRuns: 100 }, + ); + }); + + // Feature: product-pricing-api, Property 8: Invalid marketplace ID rejection + it("Property 8: Invalid marketplace ID rejection", async () => { + /** + * Validates: Requirements 5.1 + * + * For any marketplace ID string that is not in the set of valid IDs for the + * configured REGION, the corresponding sub-response SHALL have status.statusCode 400 + * and a body.errors array containing an object with code equal to "InvalidMarketplaceId". + */ + const validNaMarketplaceIds = ["ATVPDKIKX0DER", "A2EUQ1WTGCTBG2", "A1AM78C64UM0Y8", "A2Q3Y263D00KWC"]; + + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1 }).filter((s) => !validNaMarketplaceIds.includes(s)), + async (invalidMarketplaceId) => { + const validationResult: UnifiedValidationPass = { + pass: true, + operationId: "getFeaturedOfferExpectedPriceBatch", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + pathParams: {}, + queryParams: {}, + body: { + requests: [{ marketplaceId: invalidMarketplaceId, sku: "ANY-SKU-123" }], + }, + resolvedEntities: {}, + operation: {}, + }; + + const result = await getFeaturedOfferExpectedPriceBatchHandler(validationResult, {} as never); + + const responses = (result.data.body as { responses: Record[] }).responses; + expect(responses).toHaveLength(1); + + const subResponse = responses[0] as { status: { statusCode: number }; body: { errors: { code: string }[] } }; + expect(subResponse.status.statusCode).toBe(400); + expect(subResponse.body.errors).toBeInstanceOf(Array); + expect(subResponse.body.errors.length).toBeGreaterThanOrEqual(1); + expect(subResponse.body.errors[0].code).toBe("InvalidMarketplaceId"); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/operation/pricingOperations.test.ts b/local-ai-sandbox/test/operation/pricingOperations.test.ts new file mode 100644 index 000000000..1c470a12c --- /dev/null +++ b/local-ai-sandbox/test/operation/pricingOperations.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { OPERATIONS_REGISTRY } from "../../src/registry/operationRegistry.js"; +import { executeValidation } from "../../src/service/validationEngine.js"; +import { productionPassThroughHandler } from "../../src/operation/passThroughOperations.js"; +import type { RequestContext, UnifiedValidationPass } from "../../src/validation/validationTypes.js"; +import { getFeaturedOfferExpectedPriceBatchHandler, computeCompetingOfferPrice } from "../../src/operation/pricingOperations.js"; +import { Context, Api } from "../../src/database/Context.js"; + +describe("Handler registration and validation pipeline", () => { + describe("registry tests", () => { + it("OPERATIONS_REGISTRY.get returns a handler for getFeaturedOfferExpectedPriceBatch", () => { + const handler = OPERATIONS_REGISTRY.get("Product Pricing:2022-05-01:getFeaturedOfferExpectedPriceBatch"); + expect(handler).toBeTruthy(); + expect(typeof handler).toBe("function"); + }); + + it("isAllowedInCurrentMode returns true for Seller mode", () => { + // vitest.config.ts sets MODE=Seller + const allowed = OPERATIONS_REGISTRY.isAllowedInCurrentMode("Product Pricing:2022-05-01:getFeaturedOfferExpectedPriceBatch"); + expect(allowed).toBe(true); + }); + + it("getCompetitiveSummary registration is unchanged (still productionPassThroughHandler)", () => { + const handler = OPERATIONS_REGISTRY.get("Product Pricing:2022-05-01:getCompetitiveSummary"); + expect(handler).toBeTruthy(); + expect(handler).toBe(productionPassThroughHandler); + }); + }); + + describe("batchSizeLimit validation rule", () => { + function makeRequestContext(batchSize: number): RequestContext { + return { + operationId: "getFeaturedOfferExpectedPriceBatch", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + method: "POST", + pathParams: {}, + queryParams: {}, + body: { + requests: Array.from({ length: batchSize }, (_, i) => ({ + marketplaceId: "ATVPDKIKX0DER", + sku: `SKU-${i}`, + })), + }, + }; + } + + it("batch of size 1 passes validation", async () => { + const result = await executeValidation(makeRequestContext(1)); + expect(result.pass).toBe(true); + }); + + it("batch of exactly 40 passes validation", async () => { + const result = await executeValidation(makeRequestContext(40)); + expect(result.pass).toBe(true); + }); + + it("batch of 41 fails validation with statusCode 400 and code InvalidInput", async () => { + const result = await executeValidation(makeRequestContext(41)); + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(400); + const body = result.body as { errors: Array<{ code: string; message: string }> }; + expect(body.errors[0].code).toBe("InvalidInput"); + } + }); + }); +}); + +/** + * Creates a minimal valid UnifiedValidationPass for testing the pricing handler. + */ +function makeHandlerValidationResult(requests: Record[]): UnifiedValidationPass { + return { + pass: true, + operationId: "getFeaturedOfferExpectedPriceBatch", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + pathParams: {}, + queryParams: {}, + body: { requests }, + resolvedEntities: {}, + operation: {}, + }; +} + +describe("getFeaturedOfferExpectedPriceBatch edge cases", () => { + beforeEach(() => { + Context.reset(); + process.env.REGION = "NA"; + }); + + afterEach(() => { + Context.instance.engine.getCollection(Api.LISTINGS)?.clear(); + }); + + describe("missing purchasable_offer falls back to default price 29.99", () => { + it("competing offer price is computed from default price 29.99", async () => { + Context.instance.engine.put(Api.LISTINGS, "SKU1", { _key: "SKU1", sku: "SKU1" }); + + const result = await getFeaturedOfferExpectedPriceBatchHandler( + makeHandlerValidationResult([{ marketplaceId: "ATVPDKIKX0DER", sku: "SKU1" }]), + {} as never, + ); + + const responses = (result.data.body as { responses: Record[] }).responses; + const subResponse = responses[0] as { + status: { statusCode: number }; + body: { + featuredOfferExpectedPriceResults: Array<{ + competingFeaturedOffer: { price: { listingPrice: { amount: number } } }; + }>; + }; + }; + + expect(subResponse.status.statusCode).toBe(200); + + const expectedCompetingPrice = computeCompetingOfferPrice(29.99, "SKU1"); + const actualCompetingPrice = subResponse.body.featuredOfferExpectedPriceResults[0].competingFeaturedOffer.price.listingPrice.amount; + expect(actualCompetingPrice).toBe(expectedCompetingPrice); + }); + }); + + describe("missing externally_assigned_product_identifier generates deterministic ASIN", () => { + it("generates an ASIN starting with B0 when listing has no ASIN identifiers", async () => { + Context.instance.engine.put(Api.LISTINGS, "SKU2", { _key: "SKU2", sku: "SKU2" }); + + const result = await getFeaturedOfferExpectedPriceBatchHandler( + makeHandlerValidationResult([{ marketplaceId: "ATVPDKIKX0DER", sku: "SKU2" }]), + {} as never, + ); + + const responses = (result.data.body as { responses: Record[] }).responses; + const body = responses[0].body as { offerIdentifier: { asin: string } }; + + expect(body.offerIdentifier.asin).toMatch(/^B0/); + }); + + it("generates the same ASIN deterministically for the same SKU", async () => { + Context.instance.engine.put(Api.LISTINGS, "SKU2", { _key: "SKU2", sku: "SKU2" }); + + const result1 = await getFeaturedOfferExpectedPriceBatchHandler( + makeHandlerValidationResult([{ marketplaceId: "ATVPDKIKX0DER", sku: "SKU2" }]), + {} as never, + ); + + const result2 = await getFeaturedOfferExpectedPriceBatchHandler( + makeHandlerValidationResult([{ marketplaceId: "ATVPDKIKX0DER", sku: "SKU2" }]), + {} as never, + ); + + const asin1 = (result1.data.body as { responses: Array<{ body: { offerIdentifier: { asin: string } } }> }).responses[0].body + .offerIdentifier.asin; + const asin2 = (result2.data.body as { responses: Array<{ body: { offerIdentifier: { asin: string } } }> }).responses[0].body + .offerIdentifier.asin; + + expect(asin1).toBe(asin2); + }); + }); + + describe("fulfillment_channel_code AMAZON_NA maps to AFN", () => { + it("returns fulfillmentType AFN when fulfillment_channel_code is AMAZON_NA", async () => { + Context.instance.engine.put(Api.LISTINGS, "SKU3", { + _key: "SKU3", + sku: "SKU3", + fulfillment_availability: [{ fulfillment_channel_code: "AMAZON_NA" }], + }); + + const result = await getFeaturedOfferExpectedPriceBatchHandler( + makeHandlerValidationResult([{ marketplaceId: "ATVPDKIKX0DER", sku: "SKU3" }]), + {} as never, + ); + + const responses = (result.data.body as { responses: Record[] }).responses; + const body = responses[0].body as { offerIdentifier: { fulfillmentType: string } }; + + expect(body.offerIdentifier.fulfillmentType).toBe("AFN"); + }); + }); + + describe("default fulfillment maps to MFN", () => { + it("returns fulfillmentType MFN when listing has no fulfillment_availability", async () => { + Context.instance.engine.put(Api.LISTINGS, "SKU4", { _key: "SKU4", sku: "SKU4" }); + + const result = await getFeaturedOfferExpectedPriceBatchHandler( + makeHandlerValidationResult([{ marketplaceId: "ATVPDKIKX0DER", sku: "SKU4" }]), + {} as never, + ); + + const responses = (result.data.body as { responses: Record[] }).responses; + const body = responses[0].body as { offerIdentifier: { fulfillmentType: string } }; + + expect(body.offerIdentifier.fulfillmentType).toBe("MFN"); + }); + }); +}); diff --git a/local-ai-sandbox/test/registry/operationRegistry.test.ts b/local-ai-sandbox/test/registry/operationRegistry.test.ts new file mode 100644 index 000000000..03408144b --- /dev/null +++ b/local-ai-sandbox/test/registry/operationRegistry.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; +import * as R from "../../src/registry/operationRegistry.js"; +import { Api } from "../../src/database/Context.js"; +import { buildRegistry, computePathPrefix } from "../../scripts/generateOperationRegistry.js"; + +/** + * The four hand-maintained maps this feature replaces, captured here as fixtures so we can prove + * the registry reproduces their behavior exactly for the currently-registered APIs (Requirement 9). + */ + +// Representative request paths per currently-registered API → expected {model, name, version}. +const PATH_EXPECTATIONS: { path: string; model: string; apiName: string; apiVersion: string }[] = [ + { path: "/listings/2021-08-01/items/A1/SKU-1", model: "listingsItems_2021-08-01.json", apiName: "Listings", apiVersion: "2021-08-01" }, + { path: "/orders/v0/orders/902-1/shipmentConfirmation", model: "ordersV0.json", apiName: "Orders", apiVersion: "v0" }, + { path: "/orders/2026-01-01/orders/902-1", model: "orders_2026-01-01.json", apiName: "Orders", apiVersion: "2026-01-01" }, + { path: "/fba/inventory/v1/summaries", model: "fbaInventory.json", apiName: "FBA Inventory", apiVersion: "v1" }, + { + path: "/externalFulfillment/inventory/2024-09-11/inventories", + model: "externalFulfillmentInventory_2024-09-11.json", + apiName: "External Fulfillment Inventory", + apiVersion: "2024-09-11", + }, + { + path: "/externalFulfillment/2024-09-11/returns/R1", + model: "externalFulfillmentReturns_2024-09-11.json", + apiName: "External Fulfillment Returns", + apiVersion: "2024-09-11", + }, + { + path: "/externalFulfillment/2024-09-11/shipments/S1", + model: "externalFulfillmentShipments_2024-09-11.json", + apiName: "External Fulfillment Shipments", + apiVersion: "2024-09-11", + }, + { path: "/catalog/2022-04-01/items/B0F4X2K9LM", model: "catalogItems_2022-04-01.json", apiName: "Catalog Items", apiVersion: "2022-04-01" }, + { + path: "/batches/products/pricing/2022-05-01/items/featuredOfferExpectedPrice", + model: "productPricing_2022-05-01.json", + apiName: "Product Pricing", + apiVersion: "2022-05-01", + }, + { path: "/reports/2021-06-30/reports/REP-1", model: "reports_2021-06-30.json", apiName: "Reports", apiVersion: "2021-06-30" }, +]; + +// The previous resourceRetrievalTool modelMap (namespace → resource path). +const OLD_MODEL_MAP: Record = { + orders: "./res/models/orders_2026-01-01.json", + inventory: "./res/models/fbaInventory.json", + extFulfillmentInventory: "./res/models/externalFulfillmentInventory_2024-09-11.json", + extFulfillmentReturns: "./res/models/externalFulfillmentReturns_2024-09-11.json", + extFulfillmentShipments: "./res/models/externalFulfillmentShipments_2024-09-11.json", + catalog: "./res/models/catalogItems_2022-04-01.json", + pricing: "./res/models/productPricing_2022-05-01.json", +}; + +describe("operationRegistry — no regression vs. the old maps (Req 9)", () => { + it("loads and validates", () => { + expect(() => { + R.validate(); + }).not.toThrow(); + }); + + it("resolves model/name/version identically to the old path map", () => { + for (const e of PATH_EXPECTATIONS) { + expect({ path: e.path, model: R.identifyApiModel(e.path) }).toEqual({ path: e.path, model: e.model }); + expect({ path: e.path, name: R.identifyApiName(e.path) }).toEqual({ path: e.path, name: e.apiName }); + expect({ path: e.path, version: R.identifyApiVersion(e.path) }).toEqual({ path: e.path, version: e.apiVersion }); + } + }); + + it("resolves resource paths identically to the old modelMap", () => { + for (const [ns, expected] of Object.entries(OLD_MODEL_MAP)) { + expect({ ns, path: R.getModelPath(ns) }).toEqual({ ns, path: expected }); + } + }); + + it("returns undefined for an unknown path (unchanged 404 behavior)", () => { + expect(R.identifyApiModel("/totally/unknown/path")).toBeUndefined(); + }); +}); + +describe("operationRegistry — invariants", () => { + it("namespaces exactly equal the Api enum (Req 4.3 coverage)", () => { + expect(R.dbNamespaces()).toEqual([...Object.values(Api)].sort()); + }); + + it("has no duplicate composite keys (Req 9)", () => { + const keys = R.operationKeys(); + expect(new Set(keys).size).toBe(keys.length); + }); + + it("prefers the longer prefix when two models could match (orders v0 vs 2026)", () => { + // Both /orders/v0/orders and /orders/2026-01-01/orders exist; each path resolves to its own model. + expect(R.identifyApiModel("/orders/v0/orders/1/shipmentConfirmation")).toBe("ordersV0.json"); + expect(R.identifyApiModel("/orders/2026-01-01/orders/1")).toBe("orders_2026-01-01.json"); + }); +}); + +describe("generateOperationRegistry helpers", () => { + it("computePathPrefix stops before parameterized segments", () => { + expect(computePathPrefix(["/orders/v0/orders/{orderId}/shipmentConfirmation"])).toBe("/orders/v0/orders"); + expect(computePathPrefix(["/a/b/{x}", "/a/b/{y}/c"])).toBe("/a/b"); + expect(computePathPrefix(["/a/b", "/a/c"])).toBe("/a"); + expect(computePathPrefix([])).toBe(""); + }); + + it("the committed registry matches a fresh build (no drift)", () => { + const fresh = buildRegistry(); + expect(fresh.operations.length).toBeGreaterThan(0); + // Every freshly-built operation resolves to the same model via the loader. + for (const op of fresh.operations) { + expect(R.getOperationByKey(R.buildKey(op.apiName, op.apiVersion, op.operationId))).toBeTruthy(); + } + }); +}); diff --git a/local-ai-sandbox/test/scripts/apiRegistrationConfig.test.ts b/local-ai-sandbox/test/scripts/apiRegistrationConfig.test.ts new file mode 100644 index 000000000..4a6b15df3 --- /dev/null +++ b/local-ai-sandbox/test/scripts/apiRegistrationConfig.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { isExcluded, isOperationExcluded, deriveApiName, deriveDbNamespace, overrideFor } from "../../scripts/config/apiRegistrationConfig.js"; + +describe("exclude list", () => { + it("excludes superseded whole models/APIs", () => { + expect(isExcluded({ apiName: "Catalog Items", apiVersion: "v0" })).toBe(true); + expect(isExcluded({ modelFile: "listingsItems_2020-09-01.json" })).toBe(true); + }); + + it("does not exclude live APIs — including Orders v0 (hosts confirmShipment)", () => { + expect(isExcluded({ apiName: "Orders", apiVersion: "v0" })).toBe(false); + expect(isExcluded({ apiName: "Catalog Items", apiVersion: "2022-04-01" })).toBe(false); + }); + + it("a reason-only / empty identity matches nothing", () => { + expect(isExcluded({})).toBe(false); + expect(isOperationExcluded({}, "getOrder")).toBe(false); + }); + + it("a whole-model rule is not treated as an operation-level exclusion", () => { + // No operationId-scoped rules are configured today, so nothing is op-excluded. + expect(isOperationExcluded({ apiName: "Orders", apiVersion: "v0" }, "confirmShipment")).toBe(false); + expect(isOperationExcluded({ apiName: "Catalog Items", apiVersion: "v0" }, "anything")).toBe(false); + }); +}); + +describe("name / namespace derivation", () => { + it("uses overrides where the derived value would be wrong", () => { + expect(overrideFor("productPricing_2022-05-01.json")?.apiName).toBe("Product Pricing"); + expect(deriveApiName("Selling Partner API for Pricing", "productPricing_2022-05-01.json")).toBe("Product Pricing"); + expect(deriveDbNamespace("Product Pricing", "productPricing_2022-05-01.json")).toBe("pricing"); + expect(deriveDbNamespace("FBA Inventory", "fbaInventory.json")).toBe("inventory"); + }); + + it("falls back to title cleanup + slug when no override exists", () => { + expect(deriveApiName("The Selling Partner API for Amazon Foo Bar Processing", "foo.json")).toBe("Foo Bar"); + expect(deriveDbNamespace("External Fulfillment Widgets", "foo.json")).toBe("externalFulfillmentWidgets"); + }); +}); diff --git a/local-ai-sandbox/test/scripts/fetchNotificationSchemas.test.ts b/local-ai-sandbox/test/scripts/fetchNotificationSchemas.test.ts new file mode 100644 index 000000000..9f26dbee3 --- /dev/null +++ b/local-ai-sandbox/test/scripts/fetchNotificationSchemas.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest"; +import fc from "fast-check"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { deriveNotificationType, filterSchemas } from "../../scripts/fetchNotificationSchemas.js"; + +describe("Feature: notification-schemas-fetch, Property 3: Filename-to-type derivation is a right-inverse", () => { + /** + * **Validates: Requirements 3.3** + * + * For any valid identifier string s, deriveNotificationType(s + ".json") === s. + * This confirms that filename-to-type derivation is a right-inverse of type-to-filename. + */ + it("deriveNotificationType(s + '.json') === s for any valid identifier", () => { + fc.assert( + fc.property(fc.stringMatching(/^[A-Za-z_][A-Za-z0-9_]*$/), (s) => { + expect(deriveNotificationType(s + ".json")).toBe(s); + }), + { numRuns: 100 }, + ); + }); + + it('concrete: deriveNotificationType("OrderChangeNotification.json") === "OrderChangeNotification"', () => { + expect(deriveNotificationType("OrderChangeNotification.json")).toBe("OrderChangeNotification"); + }); +}); + +describe("Feature: notification-schemas-fetch, Property 1: Allowlist filtering produces exact intersection", () => { + /** + * **Validates: Requirements 1.4, 3.1, 3.2, 4.1** + * + * For any set of upstream schema filenames and any allowlist, + * the set of notification types in `toCopy` equals the intersection + * of the allowlist entries and the set of derivable types from the upstream filenames. + */ + it("toCopy keys equal the intersection of allowlist and available types", () => { + // Use case-insensitive deduplication for filenames to avoid collisions on + // case-insensitive filesystems (macOS HFS+/APFS). + const caseInsensitiveSelector = (s: string) => s.toLowerCase(); + fc.assert( + fc.property( + fc.uniqueArray(fc.stringMatching(/^[A-Za-z][A-Za-z0-9]{0,29}$/), { minLength: 1, maxLength: 20, selector: caseInsensitiveSelector }), + fc.uniqueArray(fc.stringMatching(/^[A-Za-z][A-Za-z0-9]{0,29}$/), { minLength: 0, maxLength: 15, selector: caseInsensitiveSelector }), + (availableNames, extraAllowlistNames) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "filter-schemas-test-")); + try { + // Write .json files for each available name + for (const name of availableNames) { + fs.writeFileSync(path.join(tempDir, `${name}.json`), "{}"); + } + + // Build allowlist: some entries from availableNames + some extras not in availableNames + const availableSet = new Set(availableNames); + const extrasNotInAvailable = extraAllowlistNames.filter((n) => !availableSet.has(n)); + const subsetOfAvailable = availableNames.slice(0, Math.ceil(availableNames.length / 2)); + const allowlist = [...subsetOfAvailable, ...extrasNotInAvailable]; + + const { toCopy } = filterSchemas(tempDir, allowlist); + + // Expected intersection: allowlist entries that are also in availableNames + const allowlistSet = new Set(allowlist); + const expectedIntersection = new Set([...availableNames].filter((n) => allowlistSet.has(n))); + + expect(new Set(toCopy.keys())).toEqual(expectedIntersection); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe("Feature: notification-schemas-fetch, Property 2: Missing allowlist entries are reported completely", () => { + /** + * **Validates: Requirements 1.5, 3.5, 6.4** + * + * For any allowlist and any set of upstream schema filenames, + * the `missing` array contains exactly those allowlist entries + * that have no corresponding file in the upstream directory. + */ + it("missing equals allowlist entries not found in available types", () => { + // Use case-insensitive deduplication for filenames to avoid collisions on + // case-insensitive filesystems (macOS HFS+/APFS). + const caseInsensitiveSelector = (s: string) => s.toLowerCase(); + fc.assert( + fc.property( + fc.uniqueArray(fc.stringMatching(/^[A-Za-z][A-Za-z0-9]{0,29}$/), { minLength: 1, maxLength: 20, selector: caseInsensitiveSelector }), + fc.uniqueArray(fc.stringMatching(/^[A-Za-z][A-Za-z0-9]{0,29}$/), { minLength: 0, maxLength: 15, selector: caseInsensitiveSelector }), + (availableNames, extraAllowlistNames) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "filter-schemas-test-")); + try { + // Write .json files for each available name + for (const name of availableNames) { + fs.writeFileSync(path.join(tempDir, `${name}.json`), "{}"); + } + + // Build allowlist: some entries from availableNames + some extras not in availableNames + const availableSet = new Set(availableNames); + const extrasNotInAvailable = extraAllowlistNames.filter((n) => !availableSet.has(n)); + const subsetOfAvailable = availableNames.slice(0, Math.ceil(availableNames.length / 2)); + const allowlist = [...subsetOfAvailable, ...extrasNotInAvailable]; + + const { missing } = filterSchemas(tempDir, allowlist); + + // Expected missing: allowlist entries NOT in available names + const expectedMissing = allowlist.filter((entry) => !availableSet.has(entry)).sort(); + + expect([...missing].sort()).toEqual(expectedMissing); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe("main() unit tests (mocked I/O)", () => { + /** + * **Validates: Requirements 1.2, 2.4, 4.4, 5.2, 5.3, 5.4, 6.1, 6.2, 6.3** + * + * Tests the main() orchestration function with mocked filesystem and child_process + * to verify logging, exit codes, and error handling behavior. + */ + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + let processExitSpy: MockInstance; + let originalArgv: string[]; + + beforeEach(() => { + vi.resetModules(); + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + processExitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + originalArgv = process.argv; + process.argv = ["node", "fetchNotificationSchemas.ts"]; + }); + + afterEach(() => { + process.argv = originalArgv; + vi.restoreAllMocks(); + }); + + it("logs warning and returns early when allowlist is empty", async () => { + vi.doMock("../../scripts/config/notificationSchemasConfig.js", () => ({ + NOTIFICATION_SCHEMA_ALLOWLIST: [] as string[], + })); + vi.doMock("node:child_process", () => ({ + execFileSync: vi.fn(), + })); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { ...actual }; + }); + + const { main } = await import("../../scripts/fetchNotificationSchemas.js"); + const { execFileSync: mockedExec } = await import("node:child_process"); + + main(); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("[notifications:fetch]")); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("No notification types configured")); + // Should NOT attempt to clone + expect(mockedExec).not.toHaveBeenCalled(); + }); + + it("logs error and exits with code 1 when clone fails", async () => { + // Use a sentinel error to simulate process.exit halting execution + const exitError = new Error("__PROCESS_EXIT__"); + processExitSpy.mockImplementation(() => { + throw exitError; + }); + + vi.doMock("../../scripts/config/notificationSchemasConfig.js", () => ({ + NOTIFICATION_SCHEMA_ALLOWLIST: ["OrderChangeNotification"], + })); + vi.doMock("node:child_process", () => ({ + execFileSync: vi.fn().mockImplementation(() => { + throw new Error("fatal: repository not found"); + }), + })); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + mkdtempSync: vi.fn().mockReturnValue("/tmp/sp-api-notification-schemas-test"), + }; + }); + + const { main } = await import("../../scripts/fetchNotificationSchemas.js"); + + expect(() => { + main(); + }).toThrow(exitError); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("clone failed")); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("fatal: repository not found")); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it("logs summary with correct counts on successful run", async () => { + vi.doMock("../../scripts/config/notificationSchemasConfig.js", () => ({ + NOTIFICATION_SCHEMA_ALLOWLIST: ["OrderChangeNotification", "ListingsItemUpdate"], + })); + vi.doMock("node:child_process", () => ({ + execFileSync: vi.fn().mockReturnValue(Buffer.from("")), + })); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + mkdtempSync: vi.fn().mockReturnValue("/tmp/sp-api-notification-schemas-test"), + readdirSync: vi.fn().mockReturnValue(["OrderChangeNotification.json", "ListingsItemUpdate.json", "OtherNotification.json"]), + mkdirSync: vi.fn(), + copyFileSync: vi.fn(), + rmSync: vi.fn(), + }; + }); + + const { main } = await import("../../scripts/fetchNotificationSchemas.js"); + const { rmSync: mockedRmSync } = await import("node:fs"); + main(); + + // Verify summary line logged with correct counts + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("done")); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("2 copied")); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("0 missing")); + expect(processExitSpy).not.toHaveBeenCalled(); + // Temp directory must be cleaned up after a successful run + expect(mockedRmSync).toHaveBeenCalledWith("/tmp/sp-api-notification-schemas-test", { recursive: true, force: true }); + }); + + it("logs error and exits with non-zero code when file write fails, and still cleans up the temp dir", async () => { + vi.doMock("../../scripts/config/notificationSchemasConfig.js", () => ({ + NOTIFICATION_SCHEMA_ALLOWLIST: ["OrderChangeNotification"], + })); + vi.doMock("node:child_process", () => ({ + execFileSync: vi.fn().mockReturnValue(Buffer.from("")), + })); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + mkdtempSync: vi.fn().mockReturnValue("/tmp/sp-api-notification-schemas-test"), + readdirSync: vi.fn().mockReturnValue(["OrderChangeNotification.json"]), + mkdirSync: vi.fn(), + copyFileSync: vi.fn().mockImplementation(() => { + throw new Error("EACCES: permission denied"); + }), + rmSync: vi.fn(), + }; + }); + + const { main } = await import("../../scripts/fetchNotificationSchemas.js"); + const { rmSync: mockedRmSync } = await import("node:fs"); + main(); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("failed to write")); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("EACCES: permission denied")); + expect(processExitSpy).toHaveBeenCalledWith(1); + // Temp directory must still be cleaned up even though the run reported an error + expect(mockedRmSync).toHaveBeenCalledWith("/tmp/sp-api-notification-schemas-test", { recursive: true, force: true }); + }); + + it("cleans up the temp dir even when the clone fails", async () => { + // main() creates the temp dir before cloning, so the finally block owns the + // handle and must remove it regardless of where the clone fails — otherwise + // the mkdtempSync directory would leak on every failed clone. + const exitError = new Error("__PROCESS_EXIT__"); + processExitSpy.mockImplementation(() => { + throw exitError; + }); + + vi.doMock("../../scripts/config/notificationSchemasConfig.js", () => ({ + NOTIFICATION_SCHEMA_ALLOWLIST: ["OrderChangeNotification"], + })); + vi.doMock("node:child_process", () => ({ + execFileSync: vi.fn().mockImplementation(() => { + throw new Error("fatal: repository not found"); + }), + })); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + mkdtempSync: vi.fn().mockReturnValue("/tmp/sp-api-notification-schemas-test"), + rmSync: vi.fn(), + }; + }); + + const { main } = await import("../../scripts/fetchNotificationSchemas.js"); + const { rmSync: mockedRmSync } = await import("node:fs"); + + expect(() => { + main(); + }).toThrow(exitError); + + // The temp dir must be cleaned up despite the clone failure. + expect(mockedRmSync).toHaveBeenCalledWith("/tmp/sp-api-notification-schemas-test", { recursive: true, force: true }); + }); +}); diff --git a/local-ai-sandbox/test/scripts/sanitizeModel.test.ts b/local-ai-sandbox/test/scripts/sanitizeModel.test.ts new file mode 100644 index 000000000..2697dd616 --- /dev/null +++ b/local-ai-sandbox/test/scripts/sanitizeModel.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import fc from "fast-check"; +import { sanitizeModel } from "../../scripts/fetchModels.js"; + +/** Collect every property key appearing anywhere in a value (objects and nested arrays). */ +function allKeys(value: unknown, acc: string[] = []): string[] { + if (Array.isArray(value)) { + for (const v of value) allKeys(v, acc); + } else if (value !== null && typeof value === "object") { + for (const [k, v] of Object.entries(value)) { + acc.push(k); + allKeys(v, acc); + } + } + return acc; +} + +describe("sanitizeModel", () => { + it("removes examples and x-amzn-api-sandbox at any depth", () => { + const input = { + swagger: "2.0", + "x-amzn-api-sandbox": { top: true }, + paths: { + "/x": { + get: { + operationId: "getX", + examples: { a: 1 }, + responses: { "200": { examples: { b: 2 }, "x-amzn-api-sandbox": { c: 3 }, example: { keep: true } } }, + }, + }, + }, + }; + const out = sanitizeModel(input) as Record; + const keys = allKeys(out); + expect(keys).not.toContain("examples"); + expect(keys).not.toContain("x-amzn-api-sandbox"); + }); + + it("preserves example (singular)", () => { + const out = sanitizeModel({ responses: { "200": { example: { keep: true } } } }); + expect(allKeys(out)).toContain("example"); + }); + + it("does not mutate its input", () => { + const input = { examples: { a: 1 }, keep: 1 }; + sanitizeModel(input); + expect(input).toEqual({ examples: { a: 1 }, keep: 1 }); + }); + + it("removes example only when it is nested inside a stripped subtree", () => { + // `example` inside `examples` is removed with its parent; a sibling `example` is kept. + expect(allKeys(sanitizeModel({ examples: { example: 0 } }))).not.toContain("example"); + expect(allKeys(sanitizeModel({ example: 0, examples: { a: 1 } }))).toContain("example"); + }); + + describe("properties", () => { + it("output never contains the stripped keys and is idempotent", () => { + fc.assert( + fc.property(fc.object({ key: fc.oneof(fc.string(), fc.constantFrom("examples", "x-amzn-api-sandbox", "example", "operationId")) }), (obj) => { + const once = sanitizeModel(obj); + const keys = allKeys(once); + expect(keys).not.toContain("examples"); + expect(keys).not.toContain("x-amzn-api-sandbox"); + // idempotent + expect(sanitizeModel(once)).toEqual(once); + }), + ); + }); + }); +}); diff --git a/local-ai-sandbox/test/service/apiSchemaIdentificationService.test.ts b/local-ai-sandbox/test/service/apiSchemaIdentificationService.test.ts new file mode 100644 index 000000000..8d2c05096 --- /dev/null +++ b/local-ai-sandbox/test/service/apiSchemaIdentificationService.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { identifyApiName, identifyApiVersion } from "../../src/service/apiSchemaIdentificationService.js"; + +describe("identifyApiName", () => { + it("maps /orders/ paths to Orders", () => { + expect(identifyApiName("/orders/v0/orders/123")).toBe("Orders"); + expect(identifyApiName("/orders/2026-01-01/orders/456")).toBe("Orders"); + }); + + it("maps /listings/ paths to Listings", () => { + expect(identifyApiName("/listings/2021-08-01/items/SKU123")).toBe("Listings"); + }); + + it("maps /catalog/ paths to Catalog Items", () => { + expect(identifyApiName("/catalog/2022-04-01/items")).toBe("Catalog Items"); + }); + + it("maps /externalFulfillment/.../shipments paths to External Fulfillment Shipments", () => { + expect(identifyApiName("/externalFulfillment/2024-09-11/shipments/SHIP1")).toBe("External Fulfillment Shipments"); + }); + + it("maps /externalFulfillment/.../returns paths to External Fulfillment Returns", () => { + expect(identifyApiName("/externalFulfillment/2024-09-11/returns/RET1")).toBe("External Fulfillment Returns"); + }); + + it("maps /externalFulfillment/inventory/ paths to External Fulfillment Inventory", () => { + expect(identifyApiName("/externalFulfillment/inventory/2024-09-11/inventories")).toBe("External Fulfillment Inventory"); + }); + + it("maps /fba/inventory/ paths to FBA Inventory", () => { + expect(identifyApiName("/fba/inventory/v1/items/SKU1")).toBe("FBA Inventory"); + }); + + it("maps /batches/products/pricing/ paths to Product Pricing", () => { + expect(identifyApiName("/batches/products/pricing/2022-05-01/items")).toBe("Product Pricing"); + }); + + it("maps /reports/ paths to Reports", () => { + expect(identifyApiName("/reports/2021-06-30/reports")).toBe("Reports"); + }); + + it("returns undefined for unknown paths", () => { + expect(identifyApiName("/unknown/path")).toBeUndefined(); + }); +}); + +describe("identifyApiVersion", () => { + it("extracts v0 from ordersV0.json model", () => { + expect(identifyApiVersion("/orders/v0/orders/123")).toBe("v0"); + }); + + it("extracts 2026-01-01 from orders_2026-01-01.json model", () => { + expect(identifyApiVersion("/orders/2026-01-01/orders/456")).toBe("2026-01-01"); + }); + + it("extracts 2022-04-01 from catalogItems_2022-04-01.json model", () => { + expect(identifyApiVersion("/catalog/2022-04-01/items")).toBe("2022-04-01"); + }); + + it("extracts 2021-08-01 from listingsItems_2021-08-01.json model", () => { + expect(identifyApiVersion("/listings/2021-08-01/items/SKU1")).toBe("2021-08-01"); + }); + + it("extracts v1 from fbaInventory_v1.json model", () => { + expect(identifyApiVersion("/fba/inventory/v1/items/SKU1")).toBe("v1"); + }); + + it("extracts 2024-09-11 from externalFulfillmentShipments_2024-09-11.json model", () => { + expect(identifyApiVersion("/externalFulfillment/2024-09-11/shipments/SHIP1")).toBe("2024-09-11"); + }); + + it("extracts 2024-09-11 from externalFulfillmentReturns_2024-09-11.json model", () => { + expect(identifyApiVersion("/externalFulfillment/2024-09-11/returns/RET1")).toBe("2024-09-11"); + }); + + it("extracts 2024-09-11 from externalFulfillmentInventory_2024-09-11.json model", () => { + expect(identifyApiVersion("/externalFulfillment/inventory/2024-09-11/inventories")).toBe("2024-09-11"); + }); + + it("extracts 2022-05-01 from productPricing_2022-05-01.json model", () => { + expect(identifyApiVersion("/batches/products/pricing/2022-05-01/items")).toBe("2022-05-01"); + }); + + it("extracts 2021-06-30 from reports_2021-06-30.json model", () => { + expect(identifyApiVersion("/reports/2021-06-30/reports")).toBe("2021-06-30"); + }); + + it("returns undefined for unknown paths", () => { + expect(identifyApiVersion("/unknown/path")).toBeUndefined(); + }); +}); diff --git a/local-ai-sandbox/test/service/dateComparison.property.test.ts b/local-ai-sandbox/test/service/dateComparison.property.test.ts new file mode 100644 index 000000000..9b21915f3 --- /dev/null +++ b/local-ai-sandbox/test/service/dateComparison.property.test.ts @@ -0,0 +1,422 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; +import { executeValidation } from "../../src/service/validationEngine.js"; +import { DateComparisonRule, RequestContext, ValidationFail, ValidationPipeline } from "../../src/validation/validationTypes.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; +import { buildKey } from "../../src/registry/operationRegistry.js"; + +// Mock the validation registry so we can inject test pipelines +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + VALIDATION_REGISTRY: new Map(), + }; +}); + +// Mock the Context singleton (not used directly by dateComparison but required by module) +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { + get instance() { + return { + db: { + data: {}, + }, + }; + }, + }, + }; +}); + +/** + * Feature: deterministic-validation-system + * Property 18: Date comparison semantics + * + * For any date comparison rule with a given operator (before, after, beforeOrEqual, afterOrEqual) + * and any two valid ISO 8601 date-time values extracted from the request context (or "now" as the + * second operand), the rule passes if and only if the relational comparison holds between the first + * and second date values. If the first date operand is absent from the request context, the rule + * skips (passes). If the second date operand is a parameter reference that is absent from the + * request context, the rule skips (passes). If either extracted date string cannot be parsed as a + * valid ISO 8601 date-time, the rule returns HTTP 400 identifying the parameter with the + * unparseable value. + * + * **Validates: Requirements 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7** + */ +describe("Feature: deterministic-validation-system, Property 18: Date comparison semantics", () => { + const TEST_OPERATION_ID = "__test_dateComparison__"; + + beforeEach(() => { + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + // Helper: generate a valid ISO 8601 date-time string within a reasonable range + // Use integer timestamps to avoid fast-check generating invalid Date objects + const isoDateArb = fc + .integer({ min: new Date("2000-01-01T00:00:00Z").getTime(), max: new Date("2030-12-31T23:59:59Z").getTime() }) + .map((ts) => new Date(ts).toISOString()); + + // Helper: operator arbitrary + const operatorArb = fc.constantFrom("before" as const, "after" as const, "beforeOrEqual" as const, "afterOrEqual" as const); + + // Helper: source arbitrary + const sourceArb = fc.constantFrom("path" as const, "query" as const, "body" as const); + + // Helper: param name arbitrary (valid identifier style) + const paramNameArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/); + + // Helper: build a DateComparisonRule + function buildRule( + firstOperandName: string, + firstOperandSource: "path" | "query" | "body", + secondOperand: DateComparisonRule["secondOperand"], + operator: DateComparisonRule["operator"], + ): DateComparisonRule { + return { + checkType: "dateComparison", + firstOperand: { name: firstOperandName, source: firstOperandSource }, + secondOperand, + operator, + failAction: { + statusCode: 400, + code: "InvalidDateRange", + message: "Date comparison failed", + }, + }; + } + + // Helper: evaluate operator semantics + function evaluateOperator(operator: DateComparisonRule["operator"], first: Date, second: Date): boolean { + switch (operator) { + case "before": + return first < second; + case "after": + return first > second; + case "beforeOrEqual": + return first <= second; + case "afterOrEqual": + return first >= second; + } + } + + // Helper: set param value in context based on source + function setParam( + context: { pathParams: Record; queryParams: Record; body: Record | undefined }, + name: string, + source: "path" | "query" | "body", + value: string, + ): void { + switch (source) { + case "path": + context.pathParams[name] = value; + break; + case "query": + context.queryParams[name] = value; + break; + case "body": + if (!context.body) context.body = {}; + context.body[name] = value; + break; + } + } + + it("Property 18: Valid date pairs with each operator → verify pass iff comparison holds", async () => { + await fc.assert( + fc.asyncProperty( + paramNameArb, // firstOperand name + sourceArb, // firstOperand source + paramNameArb, // secondOperand name + sourceArb, // secondOperand source + isoDateArb, // first date value + isoDateArb, // second date value + operatorArb, // operator + async (firstName, firstSource, secondName, secondSource, firstDateStr, secondDateStr, operator) => { + // Ensure param names are different to avoid collision + fc.pre(firstName !== secondName || firstSource !== secondSource); + + const rule = buildRule(firstName, firstSource, { kind: "param", name: secondName, source: secondSource }, operator); + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + const pathParams: Record = {}; + const queryParams: Record = {}; + let body: Record | undefined = undefined; + const contextBuilder = { pathParams, queryParams, body }; + + setParam(contextBuilder, firstName, firstSource, firstDateStr); + setParam(contextBuilder, secondName, secondSource, secondDateStr); + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: contextBuilder.pathParams, + queryParams: contextBuilder.queryParams, + body: contextBuilder.body, + }; + + const result = await executeValidation(context); + + // Determine expected outcome + const firstDate = new Date(firstDateStr); + const secondDate = new Date(secondDateStr); + const shouldPass = evaluateOperator(operator, firstDate, secondDate); + + if (shouldPass) { + expect(result.pass).toBe(true); + } else { + expect(result.pass).toBe(false); + const failResult = result as ValidationFail; + expect(failResult.statusCode).toBe(400); + } + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 18: First operand absent → always passes (rule skips)", async () => { + await fc.assert( + fc.asyncProperty( + paramNameArb, // firstOperand name + sourceArb, // firstOperand source + paramNameArb, // secondOperand name + sourceArb, // secondOperand source + isoDateArb, // second date value (present but doesn't matter) + operatorArb, // operator + async (firstName, firstSource, secondName, secondSource, secondDateStr, operator) => { + fc.pre(firstName !== secondName || firstSource !== secondSource); + + const rule = buildRule(firstName, firstSource, { kind: "param", name: secondName, source: secondSource }, operator); + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // First operand is NOT present in context; second operand IS present + const pathParams: Record = {}; + const queryParams: Record = {}; + let body: Record | undefined = undefined; + const contextBuilder = { pathParams, queryParams, body }; + + // Only set the second operand + setParam(contextBuilder, secondName, secondSource, secondDateStr); + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: contextBuilder.pathParams, + queryParams: contextBuilder.queryParams, + body: contextBuilder.body, + }; + + const result = await executeValidation(context); + // Should always pass because first operand is absent + expect(result.pass).toBe(true); + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 18: Second operand (param kind) absent → always passes (rule skips)", async () => { + await fc.assert( + fc.asyncProperty( + paramNameArb, // firstOperand name + sourceArb, // firstOperand source + paramNameArb, // secondOperand name + sourceArb, // secondOperand source + isoDateArb, // first date value (present) + operatorArb, // operator + async (firstName, firstSource, secondName, secondSource, firstDateStr, operator) => { + fc.pre(firstName !== secondName || firstSource !== secondSource); + + const rule = buildRule(firstName, firstSource, { kind: "param", name: secondName, source: secondSource }, operator); + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // First operand IS present; second operand is NOT present + const pathParams: Record = {}; + const queryParams: Record = {}; + let body: Record | undefined = undefined; + const contextBuilder = { pathParams, queryParams, body }; + + // Only set the first operand + setParam(contextBuilder, firstName, firstSource, firstDateStr); + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: contextBuilder.pathParams, + queryParams: contextBuilder.queryParams, + body: contextBuilder.body, + }; + + const result = await executeValidation(context); + // Should always pass because second operand (param kind) is absent + expect(result.pass).toBe(true); + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 18: Unparseable date strings → HTTP 400 identifying the offending parameter", async () => { + // Strings that are definitely NOT valid dates + const unparseableDateArb = fc.constantFrom("not-a-date", "abc123", "2024-13-45", "yesterday", "foo/bar/baz", "99:99:99"); + + await fc.assert( + fc.asyncProperty( + paramNameArb, // firstOperand name + sourceArb, // firstOperand source + paramNameArb, // secondOperand name + sourceArb, // secondOperand source + operatorArb, // operator + fc.constantFrom("first" as const, "second" as const), // which operand is unparseable + unparseableDateArb, // the bad date string + isoDateArb, // a valid date for the other operand + async (firstName, firstSource, secondName, secondSource, operator, badOperand, badDateStr, validDateStr) => { + fc.pre(firstName !== secondName || firstSource !== secondSource); + + const rule = buildRule(firstName, firstSource, { kind: "param", name: secondName, source: secondSource }, operator); + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + const pathParams: Record = {}; + const queryParams: Record = {}; + let body: Record | undefined = undefined; + const contextBuilder = { pathParams, queryParams, body }; + + if (badOperand === "first") { + setParam(contextBuilder, firstName, firstSource, badDateStr); + setParam(contextBuilder, secondName, secondSource, validDateStr); + } else { + setParam(contextBuilder, firstName, firstSource, validDateStr); + setParam(contextBuilder, secondName, secondSource, badDateStr); + } + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: contextBuilder.pathParams, + queryParams: contextBuilder.queryParams, + body: contextBuilder.body, + }; + + const result = await executeValidation(context); + + // Should fail with HTTP 400 + expect(result.pass).toBe(false); + const failResult = result as ValidationFail; + expect(failResult.statusCode).toBe(400); + expect(failResult.body.errors).toHaveLength(1); + + // Error message should identify the offending parameter name + const offendingParamName = badOperand === "first" ? firstName : secondName; + expect(failResult.body.errors[0].message).toContain(offendingParamName); + // Should mention unparseable/ISO 8601 + expect(failResult.body.errors[0].message).toContain("unparseable"); + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); + + it('Property 18: "now" as second operand → comparison uses current system time', async () => { + await fc.assert( + fc.asyncProperty( + paramNameArb, // firstOperand name + sourceArb, // firstOperand source + operatorArb, // operator + // Generate a date that is definitely in the past (before current time) + fc.constantFrom("past" as const, "future" as const), + async (firstName, firstSource, operator, timeRelation) => { + const rule = buildRule(firstName, firstSource, { kind: "now" }, operator); + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // Generate a date that is clearly in the past or future + const now = new Date(); + let firstDateStr: string; + if (timeRelation === "past") { + // 1 day ago + const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); + firstDateStr = pastDate.toISOString(); + } else { + // 1 day from now + const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); + firstDateStr = futureDate.toISOString(); + } + + const pathParams: Record = {}; + const queryParams: Record = {}; + let body: Record | undefined = undefined; + const contextBuilder = { pathParams, queryParams, body }; + + setParam(contextBuilder, firstName, firstSource, firstDateStr); + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: contextBuilder.pathParams, + queryParams: contextBuilder.queryParams, + body: contextBuilder.body, + }; + + const result = await executeValidation(context); + + // Determine expected result based on time relation and operator + const firstDate = new Date(firstDateStr); + // "now" is approximately the current time; since we use 1-day offsets, this is reliable + const isBeforeNow = timeRelation === "past"; + const isAfterNow = timeRelation === "future"; + + let shouldPass: boolean; + switch (operator) { + case "before": + shouldPass = isBeforeNow; + break; + case "after": + shouldPass = isAfterNow; + break; + case "beforeOrEqual": + shouldPass = isBeforeNow; + break; + case "afterOrEqual": + shouldPass = isAfterNow; + break; + } + + if (shouldPass) { + expect(result.pass).toBe(true); + } else { + expect(result.pass).toBe(false); + const failResult = result as ValidationFail; + expect(failResult.statusCode).toBe(400); + } + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/service/validationEngine.bodyExclusion.prop.test.ts b/local-ai-sandbox/test/service/validationEngine.bodyExclusion.prop.test.ts new file mode 100644 index 000000000..319e51a7c --- /dev/null +++ b/local-ai-sandbox/test/service/validationEngine.bodyExclusion.prop.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; + +// Use vi.hoisted so the mock fn is available when vi.mock factories execute (hoisted to top) +const { mockEnforcerRequest } = vi.hoisted(() => ({ + mockEnforcerRequest: vi.fn(), +})); + +// Mock openapi-enforcer to capture how it's called +vi.mock("openapi-enforcer", () => ({ + default: vi.fn().mockResolvedValue({ + request: mockEnforcerRequest, + }), +})); + +// Mock apiSchemaIdentificationService to always recognize the path +vi.mock("../../src/service/apiSchemaIdentificationService.js", () => ({ + identifyApiModel: vi.fn().mockReturnValue("ordersV0.json"), + identifyApiName: vi.fn().mockReturnValue("Orders"), + identifyApiVersion: vi.fn().mockReturnValue("v0"), +})); + +// Mock the validation registry to return an empty pipeline (so we only test schema validation stage) +vi.mock("../../src/validation/validationRegistry.js", () => { + const emptyPipelineMap = new Map(); + return { + buildValidationKey: vi.fn().mockReturnValue("Orders:v0:testOp"), + VALIDATION_REGISTRY: new Proxy(emptyPipelineMap, { + get(target, prop) { + if (prop === "get") return () => []; + return Reflect.get(target, prop); + }, + }), + }; +}); + +// Mock Context to avoid database dependency +vi.mock("../../src/database/Context.js", () => ({ + Context: { + get instance() { + return { db: { data: {} } }; + }, + }, +})); + +import { validateRequest } from "../../src/service/validationEngine.js"; +import { Request } from "express"; + +/** + * Property 23: GET/DELETE requests exclude body from schema validation + * + * Generate GET and DELETE requests with arbitrary body content; verify the + * Schema_Validation_Stage does not include the body in the enforcer call + * (body is ignored and validation succeeds based on path/query alone). + * + * Generate POST/PUT/PATCH requests; verify body IS included in the enforcer + * validation call. + * + * **Validates: Requirements 12.11** + */ +describe("Feature: deterministic-validation-system, Property 23: GET/DELETE requests exclude body from schema validation", () => { + beforeEach(() => { + mockEnforcerRequest.mockReset(); + // Default: enforcer.request returns a successful result + mockEnforcerRequest.mockReturnValue([ + { + operation: { operationId: "testOperation" }, + path: {}, + query: {}, + }, + undefined, + ]); + }); + + it("Property 23a: GET/DELETE requests do NOT include body in enforcer call", async () => { + const methodArb = fc.constantFrom("GET" as const, "DELETE" as const); + const bodyArb = fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fc.jsonValue()); + + await fc.assert( + fc.asyncProperty(methodArb, bodyArb, async (method, body) => { + mockEnforcerRequest.mockReturnValue([ + { + operation: { operationId: "testOperation" }, + path: {}, + query: {}, + }, + undefined, + ]); + + const mockRequest = { + method, + path: "/orders/v0/orders", + query: {}, + headers: {}, + body, + } as unknown as Request; + + await validateRequest(mockRequest); + + // Verify the enforcer request was called + expect(mockEnforcerRequest).toHaveBeenCalled(); + + // Get the arguments passed to enforcer.request() + const callArgs = mockEnforcerRequest.mock.calls[mockEnforcerRequest.mock.calls.length - 1][0]; + + // For GET/DELETE, the body field should NOT be present in the call + expect(callArgs).not.toHaveProperty("body"); + expect(callArgs.method).toBe(method); + + mockEnforcerRequest.mockClear(); + }), + { numRuns: 100 }, + ); + }); + + it("Property 23b: POST/PUT/PATCH requests DO include body in enforcer call", async () => { + const methodArb = fc.constantFrom("POST" as const, "PUT" as const, "PATCH" as const); + const bodyArb = fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fc.jsonValue()); + + await fc.assert( + fc.asyncProperty(methodArb, bodyArb, async (method, body) => { + mockEnforcerRequest.mockReturnValue([ + { + operation: { operationId: "testOperation" }, + path: {}, + query: {}, + }, + undefined, + ]); + + const mockRequest = { + method, + path: "/orders/v0/orders", + query: {}, + headers: {}, + body, + } as unknown as Request; + + await validateRequest(mockRequest); + + // Verify the enforcer request was called + expect(mockEnforcerRequest).toHaveBeenCalled(); + + // Get the arguments passed to enforcer.request() + const callArgs = mockEnforcerRequest.mock.calls[mockEnforcerRequest.mock.calls.length - 1][0]; + + // For POST/PUT/PATCH, the body field SHOULD be present in the call + expect(callArgs).toHaveProperty("body"); + expect(callArgs.method).toBe(method); + + mockEnforcerRequest.mockClear(); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/service/validationEngine.contextConstruction.prop.test.ts b/local-ai-sandbox/test/service/validationEngine.contextConstruction.prop.test.ts new file mode 100644 index 000000000..8b81b4d74 --- /dev/null +++ b/local-ai-sandbox/test/service/validationEngine.contextConstruction.prop.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; +import { Request } from "express"; + +/** + * Property 22: Successful schema validation constructs correct RequestContext + * + * Generate valid requests against known SP-API models; verify the unified pass result + * contains the correct operationId, apiName, apiVersion, pathParams, and queryParams + * as extracted by the openapi-enforcer. + * + * **Validates: Requirements 12.5, 12.6** + */ + +// Mock openapi-enforcer — use vi.hoisted to make variables available in hoisted vi.mock factories +const { mockEnforcerRequest, mockIdentifyApiModel, mockIdentifyApiName, mockIdentifyApiVersion } = vi.hoisted(() => ({ + mockEnforcerRequest: vi.fn(), + mockIdentifyApiModel: vi.fn(), + mockIdentifyApiName: vi.fn(), + mockIdentifyApiVersion: vi.fn(), +})); + +vi.mock("openapi-enforcer", () => ({ + default: vi.fn().mockResolvedValue({ + request: mockEnforcerRequest, + }), +})); + +// Mock apiSchemaIdentificationService to return model file, apiName, and apiVersion +vi.mock("../../src/service/apiSchemaIdentificationService.js", () => ({ + identifyApiModel: (...args: unknown[]) => mockIdentifyApiModel(...args), + identifyApiName: (...args: unknown[]) => mockIdentifyApiName(...args), + identifyApiVersion: (...args: unknown[]) => mockIdentifyApiVersion(...args), +})); + +// Mock the validation registry to have an empty pipeline (so it passes through) +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + const emptyPipelineMap = new Map(); + return { + ...original, + VALIDATION_REGISTRY: new Proxy(emptyPipelineMap, { + get(target, prop) { + if (prop === "get") return () => []; + return Reflect.get(target, prop); + }, + }), + }; +}); + +// Mock the Context singleton (needed for pipeline execution even though no pipeline exists) +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { + get instance() { + return { + db: { + data: {}, + }, + }; + }, + }, + }; +}); + +import { validateRequest } from "../../src/service/validationEngine.js"; +import { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; + +describe("Feature: deterministic-validation-system, Property 22: Successful schema validation constructs correct RequestContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("Property 22: Successful schema validation returns correct operationId, apiName, apiVersion, pathParams, and queryParams", async () => { + // Arbitraries for the fields returned by the enforcer and identification service + const operationIdArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{1,29}$/); + const apiNameArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9 ]{0,29}$/); + const apiVersionArb = fc.stringMatching(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,14}$/); + const pathParamsArb = fc.dictionary( + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), + fc.string({ minLength: 1, maxLength: 30 }), + { minKeys: 0, maxKeys: 4 }, + ); + const queryParamsArb = fc.dictionary( + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), + fc.string({ minLength: 1, maxLength: 30 }), + { minKeys: 0, maxKeys: 4 }, + ); + + await fc.assert( + fc.asyncProperty(operationIdArb, apiNameArb, apiVersionArb, pathParamsArb, queryParamsArb, async (operationId, apiName, apiVersion, pathParams, queryParams) => { + // Configure mocks for this iteration + mockIdentifyApiModel.mockReturnValue("testModel.json"); + mockIdentifyApiName.mockReturnValue(apiName); + mockIdentifyApiVersion.mockReturnValue(apiVersion); + + // Mock openapi-enforcer to return a successful validation result + mockEnforcerRequest.mockReturnValue([ + { + operation: { operationId }, + path: pathParams, + query: queryParams, + }, + undefined, // no error + ]); + + // Build a minimal Express-like request object + const mockRequest = { + path: "/test/api/path", + method: "GET", + query: {}, + headers: {}, + body: undefined, + } as unknown as Request; + + const result = await validateRequest(mockRequest); + + // Verify the result is a pass + expect(result.pass).toBe(true); + + const passResult = result as UnifiedValidationPass; + + // Verify the fields match what the enforcer/identification service returned + expect(passResult.operationId).toBe(operationId); + expect(passResult.apiName).toBe(apiName); + expect(passResult.apiVersion).toBe(apiVersion); + expect(passResult.pathParams).toEqual(pathParams); + expect(passResult.queryParams).toEqual(queryParams); + // No pipeline registered, so resolvedEntities should be empty + expect(passResult.resolvedEntities).toEqual({}); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/service/validationEngine.operationPassThrough.test.ts b/local-ai-sandbox/test/service/validationEngine.operationPassThrough.test.ts new file mode 100644 index 000000000..9be3dbcdf --- /dev/null +++ b/local-ai-sandbox/test/service/validationEngine.operationPassThrough.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Request } from "express"; + +// Mock openapi-enforcer +const mockEnforcerRequest = vi.fn(); +const mockEnforcer = vi.fn(); +vi.mock("openapi-enforcer", () => ({ + default: (...args: unknown[]) => mockEnforcer(...args), +})); + +// Mock apiSchemaIdentificationService +const mockIdentifyApiModel = vi.fn(); +const mockIdentifyApiName = vi.fn(); +const mockIdentifyApiVersion = vi.fn(); +vi.mock("../../src/service/apiSchemaIdentificationService.js", () => ({ + identifyApiModel: (...args: unknown[]) => mockIdentifyApiModel(...args), + identifyApiName: (...args: unknown[]) => mockIdentifyApiName(...args), + identifyApiVersion: (...args: unknown[]) => mockIdentifyApiVersion(...args), +})); + +// Mock the validation registry to return empty pipeline (isolate schema validation behavior) +vi.mock("../../src/validation/validationRegistry.js", () => { + const emptyPipelineMap = new Map(); + return { + buildValidationKey: (apiName: string, apiVersion: string, operationId: string) => `${apiName}:${apiVersion}:${operationId}`, + VALIDATION_REGISTRY: new Proxy(emptyPipelineMap, { + get(target, prop) { + if (prop === "get") return () => []; + return Reflect.get(target, prop); + }, + }), + }; +}); + +// Mock the Context singleton +vi.mock("../../src/database/Context.js", () => ({ + Context: { + get instance() { + return { db: { data: {} } }; + }, + }, + Api: {}, +})); + +import { validateRequest } from "../../src/service/validationEngine.js"; + +function createMockRequest(overrides: Partial = {}): Request { + return { + method: "GET", + path: "/orders/v0/orders/123-456", + query: {}, + headers: {}, + body: undefined, + ...overrides, + } as unknown as Request; +} + +describe("Operation Object Pass-Through (Requirements 13.1, 13.2, 13.5)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("performSchemaValidation returns the full value.operation object on success", () => { + it("includes the operation object from openapi-enforcer in the unified pass result", async () => { + const fullOperationObject = { + operationId: "getOrder", + responses: { + "200": { + description: "Success", + content: { "application/json": { schema: { type: "object" } } }, + }, + "404": { + description: "Not Found", + }, + }, + parameters: [ + { name: "orderId", in: "path", required: true, schema: { type: "string" } }, + { name: "marketplaceIds", in: "query", required: true, schema: { type: "array", items: { type: "string" } } }, + ], + summary: "Returns the order for the specified order ID", + tags: ["ordersV0"], + }; + + mockIdentifyApiModel.mockReturnValue("ordersV0.json"); + mockIdentifyApiName.mockReturnValue("Orders"); + mockIdentifyApiVersion.mockReturnValue("v0"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + mockEnforcerRequest.mockReturnValue([ + { + operation: fullOperationObject, + path: { orderId: "123-456" }, + query: { marketplaceIds: ["ATVPDKIKX0DER"] }, + }, + undefined, + ]); + + const request = createMockRequest({ path: "/orders/v0/orders/123-456" }); + const result = await validateRequest(request); + + expect(result.pass).toBe(true); + if (result.pass) { + expect(result.operation).toBe(fullOperationObject); + } + }); + }); + + describe("operation object is the same reference returned by the enforcer (not copied or filtered)", () => { + it("returns the exact same object reference without modification", async () => { + const operationFromEnforcer = { + operationId: "searchCatalogItems", + responses: { + "200": { description: "Success", content: { "application/json": { schema: { "$ref": "#/components/schemas/ItemSearchResults" } } } }, + "400": { description: "Bad Request" }, + }, + parameters: [ + { name: "keywords", in: "query", required: true, schema: { type: "array", items: { type: "string" } } }, + { name: "marketplaceIds", in: "query", required: true, schema: { type: "array", items: { type: "string" } } }, + ], + description: "Search for catalog items", + "x-custom-metadata": { rateLimit: 5 }, + }; + + mockIdentifyApiModel.mockReturnValue("catalogItems_2022-04-01.json"); + mockIdentifyApiName.mockReturnValue("Catalog Items"); + mockIdentifyApiVersion.mockReturnValue("2022-04-01"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + mockEnforcerRequest.mockReturnValue([ + { + operation: operationFromEnforcer, + path: {}, + query: { keywords: ["laptop"], marketplaceIds: ["ATVPDKIKX0DER"] }, + }, + undefined, + ]); + + const request = createMockRequest({ + path: "/catalog/2022-04-01/items", + query: { keywords: "laptop", marketplaceIds: "ATVPDKIKX0DER" }, + }); + const result = await validateRequest(request); + + expect(result.pass).toBe(true); + if (result.pass) { + // Verify it is the SAME reference (identity check, not deep equality) + expect(result.operation).toBe(operationFromEnforcer); + // Verify no properties were removed or transformed + expect(result.operation).toStrictEqual(operationFromEnforcer); + } + }); + }); + + describe("operation object contains expected OpenAPI properties without filtering", () => { + it("preserves responses, parameters, and operationId properties from the enforcer result", async () => { + const operationWithFullMetadata = { + operationId: "getListingsItem", + responses: { + "200": { + description: "Successfully retrieved listings item", + content: { + "application/json": { + schema: { + type: "object", + properties: { sku: { type: "string" }, summaries: { type: "array" } }, + }, + }, + }, + }, + "400": { description: "Bad request" }, + "403": { description: "Forbidden" }, + "404": { description: "Not found" }, + }, + parameters: [ + { name: "sellerId", in: "path", required: true, schema: { type: "string" } }, + { name: "sku", in: "path", required: true, schema: { type: "string" } }, + { name: "marketplaceIds", in: "query", required: true, schema: { type: "array" } }, + { name: "includedData", in: "query", required: false, schema: { type: "array" } }, + ], + security: [{ bearerAuth: [] }], + deprecated: false, + summary: "Returns details about a listings item", + }; + + mockIdentifyApiModel.mockReturnValue("listingsItems_2021-08-01.json"); + mockIdentifyApiName.mockReturnValue("Listings"); + mockIdentifyApiVersion.mockReturnValue("2021-08-01"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + mockEnforcerRequest.mockReturnValue([ + { + operation: operationWithFullMetadata, + path: { sellerId: "SELLER1", sku: "ABC-123" }, + query: { marketplaceIds: ["ATVPDKIKX0DER"] }, + }, + undefined, + ]); + + const request = createMockRequest({ + path: "/listings/2021-08-01/items/SELLER1/ABC-123", + query: { marketplaceIds: "ATVPDKIKX0DER" }, + }); + const result = await validateRequest(request); + + expect(result.pass).toBe(true); + if (result.pass) { + // Verify the operation contains all expected properties + expect(result.operation).toHaveProperty("operationId", "getListingsItem"); + expect(result.operation).toHaveProperty("responses"); + expect(result.operation).toHaveProperty("parameters"); + expect(result.operation).toHaveProperty("security"); + expect(result.operation).toHaveProperty("deprecated", false); + expect(result.operation).toHaveProperty("summary"); + + // Verify responses contain multiple status codes + expect(Object.keys(result.operation.responses)).toEqual(["200", "400", "403", "404"]); + + // Verify parameters array is fully preserved + expect(result.operation.parameters).toHaveLength(4); + expect(result.operation.parameters[0]).toEqual({ name: "sellerId", in: "path", required: true, schema: { type: "string" } }); + } + }); + + it("preserves custom/extension properties (x- prefixed) on the operation object", async () => { + const operationWithExtensions = { + operationId: "confirmShipment", + responses: { "204": { description: "No Content" } }, + parameters: [{ name: "orderId", in: "path", required: true, schema: { type: "string" } }], + "x-amzn-rate-limit": { burst: 10, sustained: 5 }, + "x-amzn-api-sandbox": { static: [{ request: {}, response: {} }] }, + }; + + mockIdentifyApiModel.mockReturnValue("ordersV0.json"); + mockIdentifyApiName.mockReturnValue("Orders"); + mockIdentifyApiVersion.mockReturnValue("v0"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + mockEnforcerRequest.mockReturnValue([ + { + operation: operationWithExtensions, + path: { orderId: "111-222-333" }, + query: {}, + }, + undefined, + ]); + + const request = createMockRequest({ + method: "POST", + path: "/orders/v0/orders/111-222-333/shipment/confirm", + body: { packageDetail: {} }, + }); + const result = await validateRequest(request); + + expect(result.pass).toBe(true); + if (result.pass) { + expect(result.operation).toHaveProperty("x-amzn-rate-limit"); + expect(result.operation["x-amzn-rate-limit"]).toEqual({ burst: 10, sustained: 5 }); + expect(result.operation).toHaveProperty("x-amzn-api-sandbox"); + } + }); + }); + + describe("operation object is not present in failure results", () => { + it("does not include operation when schema validation fails (404)", async () => { + mockIdentifyApiModel.mockReturnValue(undefined); + + const request = createMockRequest({ path: "/unknown/endpoint" }); + const result = await validateRequest(request); + + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result).not.toHaveProperty("operation"); + } + }); + + it("does not include operation when schema validation fails (400)", async () => { + mockIdentifyApiModel.mockReturnValue("ordersV0.json"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + mockEnforcerRequest.mockReturnValue([undefined, { toString: () => "Schema validation error" }]); + + const request = createMockRequest({ path: "/orders/v0/orders/123" }); + const result = await validateRequest(request); + + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result).not.toHaveProperty("operation"); + } + }); + }); +}); diff --git a/local-ai-sandbox/test/service/validationEngine.operationPassthrough.prop.test.ts b/local-ai-sandbox/test/service/validationEngine.operationPassthrough.prop.test.ts new file mode 100644 index 000000000..701a8d66a --- /dev/null +++ b/local-ai-sandbox/test/service/validationEngine.operationPassthrough.prop.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; +import { Request } from "express"; + +/** + * Property 24: Operation object pass-through identity + * + * For any request that passes OpenAPI schema validation, the unified pass result + * SHALL contain an `operation` field whose value is the exact same operation object + * returned by the openapi-enforcer result, without any filtering, transformation, + * or modification of its properties. The operation object SHALL be passed through + * as-is from the enforcer's `value.operation` to the unified pass result's `operation` field. + * + * **Validates: Requirements 13.1, 13.5** + */ + +// Mock openapi-enforcer — use vi.hoisted to make variables available in hoisted vi.mock factories +const { mockEnforcerRequest, mockIdentifyApiModel, mockIdentifyApiName, mockIdentifyApiVersion } = vi.hoisted(() => ({ + mockEnforcerRequest: vi.fn(), + mockIdentifyApiModel: vi.fn(), + mockIdentifyApiName: vi.fn(), + mockIdentifyApiVersion: vi.fn(), +})); + +vi.mock("openapi-enforcer", () => ({ + default: vi.fn().mockResolvedValue({ + request: mockEnforcerRequest, + }), +})); + +// Mock apiSchemaIdentificationService +vi.mock("../../src/service/apiSchemaIdentificationService.js", () => ({ + identifyApiModel: (...args: unknown[]) => mockIdentifyApiModel(...args), + identifyApiName: (...args: unknown[]) => mockIdentifyApiName(...args), + identifyApiVersion: (...args: unknown[]) => mockIdentifyApiVersion(...args), +})); + +// Mock the validation registry to have an empty pipeline (so requests pass through) +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + const emptyPipelineMap = new Map(); + return { + ...original, + VALIDATION_REGISTRY: new Proxy(emptyPipelineMap, { + get(target, prop) { + if (prop === "get") return () => []; + return Reflect.get(target, prop); + }, + }), + }; +}); + +// Mock the Context singleton +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { + get instance() { + return { + db: { + data: {}, + }, + }; + }, + }, + }; +}); + +import { validateRequest } from "../../src/service/validationEngine.js"; +import { UnifiedValidationPass } from "../../src/validation/validationTypes.js"; + +describe("Feature: deterministic-validation-system, Property 24: Operation object pass-through identity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("Property 24: The unified pass result contains the exact same operation object returned by openapi-enforcer without filtering or transformation", async () => { + // Generate arbitrary operation objects with varying shapes and properties + const operationObjectArb = fc.record({ + operationId: fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{1,29}$/), + responses: fc.dictionary( + fc.constantFrom("200", "201", "400", "404", "500"), + fc.record({ + description: fc.string({ minLength: 1, maxLength: 50 }), + content: fc.constant({ "application/json": { schema: { type: "object" } } }), + }), + { minKeys: 1, maxKeys: 3 }, + ), + parameters: fc.array( + fc.record({ + name: fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), + in: fc.constantFrom("path", "query", "header"), + required: fc.boolean(), + schema: fc.record({ type: fc.constantFrom("string", "integer", "boolean") }), + }), + { minLength: 0, maxLength: 5 }, + ), + summary: fc.option(fc.string({ minLength: 1, maxLength: 60 }), { nil: undefined }), + description: fc.option(fc.string({ minLength: 1, maxLength: 100 }), { nil: undefined }), + tags: fc.option(fc.array(fc.string({ minLength: 1, maxLength: 20 }), { minLength: 1, maxLength: 3 }), { nil: undefined }), + deprecated: fc.option(fc.boolean(), { nil: undefined }), + }); + + await fc.assert( + fc.asyncProperty(operationObjectArb, async (operationObject) => { + // Configure mocks for a successful schema validation + mockIdentifyApiModel.mockReturnValue("testModel.json"); + mockIdentifyApiName.mockReturnValue("TestApi"); + mockIdentifyApiVersion.mockReturnValue("v1"); + + // The enforcer returns the generated operation object as value.operation + mockEnforcerRequest.mockReturnValue([ + { + operation: operationObject, + path: {}, + query: {}, + }, + undefined, // no error + ]); + + // Build a minimal Express-like request object + const mockRequest = { + path: "/test/api/path", + method: "GET", + query: {}, + headers: {}, + body: undefined, + } as unknown as Request; + + const result = await validateRequest(mockRequest); + + // Verify the result is a pass + expect(result.pass).toBe(true); + + const passResult = result as UnifiedValidationPass; + + // The operation field must be the exact same reference (identity check) + expect(passResult.operation).toBe(operationObject); + + // Also verify deep equality — no properties were removed or modified + expect(passResult.operation).toEqual(operationObject); + }), + { numRuns: 100 }, + ); + }); + + it("Property 24b: Operation objects with nested complex structures are passed through without transformation", async () => { + // Generate operation objects with deeper/more complex nested structures + const complexOperationArb = fc.record({ + operationId: fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{1,19}$/), + responses: fc.constant({ + "200": { + description: "Success", + content: { + "application/json": { + schema: { + type: "object", + properties: { + items: { type: "array", items: { type: "object" } }, + pagination: { type: "object", properties: { nextToken: { type: "string" } } }, + }, + }, + }, + }, + }, + }), + "x-custom-metadata": fc.dictionary( + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/), + fc.jsonValue(), + { minKeys: 0, maxKeys: 3 }, + ), + security: fc.option( + fc.array(fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fc.array(fc.string({ minLength: 1, maxLength: 10 }))), { minLength: 1, maxLength: 2 }), + { nil: undefined }, + ), + requestBody: fc.option( + fc.record({ + required: fc.boolean(), + content: fc.constant({ "application/json": { schema: { type: "object" } } }), + }), + { nil: undefined }, + ), + }); + + await fc.assert( + fc.asyncProperty(complexOperationArb, async (operationObject) => { + mockIdentifyApiModel.mockReturnValue("testModel.json"); + mockIdentifyApiName.mockReturnValue("SomeApi"); + mockIdentifyApiVersion.mockReturnValue("2024-01-01"); + + mockEnforcerRequest.mockReturnValue([ + { + operation: operationObject, + path: { resourceId: "res-123" }, + query: { limit: "10" }, + }, + undefined, + ]); + + const mockRequest = { + path: "/some/api/path", + method: "POST", + query: { limit: "10" }, + headers: {}, + body: { name: "test" }, + } as unknown as Request; + + const result = await validateRequest(mockRequest); + + expect(result.pass).toBe(true); + + const passResult = result as UnifiedValidationPass; + + // Exact same reference — no transformation + expect(passResult.operation).toBe(operationObject); + expect(passResult.operation).toEqual(operationObject); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/service/validationEngine.property.test.ts b/local-ai-sandbox/test/service/validationEngine.property.test.ts new file mode 100644 index 000000000..b47733ada --- /dev/null +++ b/local-ai-sandbox/test/service/validationEngine.property.test.ts @@ -0,0 +1,2650 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; +import { resolveParam, executeValidation } from "../../src/service/validationEngine.js"; +import { AtLeastOneRequiredRule, EntityExistenceRule, MutualExclusivityRule, RequestContext, ValidationFail, ValidationPipeline } from "../../src/validation/validationTypes.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; +import { Api } from "../../src/database/Context.js"; + +// Mutable mock database state — tests can populate this before assertions +const mockDbData: Record> = {}; + +// Mock the validation registry so we can inject test pipelines +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + VALIDATION_REGISTRY: new Map(), + }; +}); + +// Mock the Context singleton so database access uses our mutable mockDbData +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { + get instance() { + return { + engine: { + get: (api: string, key: string) => { + const partition = mockDbData[api]; + if (!partition || !Object.hasOwn(partition, key)) return null; + return partition[key] ?? null; + }, + }, + }; + }, + }, + }; +}); + +describe("Feature: deterministic-validation-system", () => { + /** + * **Validates: Requirements 1.3, 1.5** + */ + it("Property 1: Parameter resolution correctness", () => { + const sourceArb = fc.constantFrom("path" as const, "query" as const, "body" as const); + + const requestContextArb = fc.record({ + apiName: fc.string(), + apiVersion: fc.string(), + operationId: fc.string(), + method: fc.constantFrom("GET", "POST", "PUT", "DELETE", "PATCH"), + pathParams: fc.dictionary(fc.string(), fc.string()), + queryParams: fc.dictionary(fc.string(), fc.string()), + body: fc.option(fc.dictionary(fc.string(), fc.jsonValue()), { nil: undefined }), + }) as fc.Arbitrary; + + const paramNameArb = fc.string(); + + fc.assert( + fc.property(requestContextArb, paramNameArb, sourceArb, (context, name, source) => { + // resolveParam should never throw regardless of input + let result: unknown; + expect(() => { + result = resolveParam(context, name, source); + }).not.toThrow(); + + // Verify correctness: returns the correct value when present, undefined when absent. + // Presence is checked via Object.hasOwn (own properties only) to match resolveParam's + // contract, which deliberately ignores inherited Object.prototype members (e.g. "toString", + // "constructor") so they are never mistaken for actual request data. + switch (source) { + case "path": + if (Object.hasOwn(context.pathParams, name)) { + expect(result).toBe(context.pathParams[name]); + } else { + expect(result).toBeUndefined(); + } + break; + case "query": + if (Object.hasOwn(context.queryParams, name)) { + expect(result).toBe(context.queryParams[name]); + } else { + expect(result).toBeUndefined(); + } + break; + case "body": + if (context.body !== undefined && Object.hasOwn(context.body, name)) { + expect(result).toBe(context.body[name]); + } else { + expect(result).toBeUndefined(); + } + break; + } + }), + { numRuns: 100 }, + ); + }); + + /** + * **Validates: Requirements 3.4, 3.5** + */ + it("Property 5: At-least-one-required semantics", async () => { + const TEST_OPERATION_ID = "__test_atLeastOneRequired__"; + + // Generate 1-5 unique param names (non-empty alphanumeric strings) + const paramNamesArb = fc + .uniqueArray(fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), { minLength: 1, maxLength: 5 }) + .filter((names) => names.length >= 1); + + // Decide whether some params have values (true) or none do (false) + const hasPresentArb = fc.boolean(); + + // Index used to determine how many params are present (replaces Math.random()) + const presentCountArb = fc.nat(); + + await fc.assert( + fc.asyncProperty(paramNamesArb, hasPresentArb, presentCountArb, fc.context(), async (paramNames, hasPresent, presentCountSeed, ctx) => { + // Build the atLeastOneRequired rule + const rule: AtLeastOneRequiredRule = { + checkType: "atLeastOneRequired", + params: paramNames.map((name) => ({ name, source: "query" as const })), + failAction: { + statusCode: 400, + code: "InvalidInput", + message: `At least one of '${paramNames.join("', '")}' must be provided`, + }, + }; + + // Register the test pipeline in the mocked registry + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + try { + if (hasPresent) { + // Pick a random subset (at least 1) of param names to have values + const presentCount = Math.max(1, (presentCountSeed % paramNames.length) + 1); + const presentNames = paramNames.slice(0, presentCount); + + const queryParams: Record = {}; + for (const name of presentNames) { + queryParams[name] = "some-value"; + } + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams, + body: undefined, + }; + + ctx.log(`Testing with ${presentNames.length} present params: ${presentNames.join(", ")}`); + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + } else { + // None of the params are present + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + ctx.log(`Testing with 0 present params from: ${paramNames.join(", ")}`); + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + + const failResult = result as ValidationFail; + expect(failResult.statusCode).toBe(400); + + // Verify the message lists all param names + const message = failResult.body.errors[0].message; + for (const name of paramNames) { + expect(message).toContain(name); + } + } + } finally { + // Cleanup + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + } + }), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Property 4: Mutual exclusivity semantics + * + * For any mutual exclusivity rule with N parameters and any request context, + * the rule passes if and only if exactly one of the N parameters is present + * and non-empty in the context. If zero are present, the failure message lists + * all N parameter names as required options. If more than one are present, + * the failure message identifies the conflicting parameter names. + * + * **Validates: Requirements 3.1, 3.2, 3.3** + */ +describe("Feature: deterministic-validation-system, Property 4: Mutual exclusivity semantics", () => { + const TEST_OPERATION_ID = "__test_mutualExclusivity__"; + + beforeEach(() => { + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + it("Property 4: Passes when exactly one parameter is present", async () => { + await fc.assert( + fc.asyncProperty( + // Generate N param names (N >= 2, unique, valid identifiers) + fc.uniqueArray(fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), { minLength: 2, maxLength: 6 }), + // Generate a non-empty value for the present param + fc.string({ minLength: 1, maxLength: 20 }), + // Index of which param to make present + fc.nat(), + async (paramNames, value, presentIdx) => { + const rule: MutualExclusivityRule = { + checkType: "mutualExclusivity", + params: paramNames.map((name) => ({ name, source: "query" as const })), + failAction: { + statusCode: 400, + code: "InvalidInput", + message: `Exactly one of '${paramNames.join(", ")}' must be provided`, + }, + }; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // Only one param is present + const chosenParam = paramNames[presentIdx % paramNames.length]; + const queryParams: Record = { [chosenParam]: value }; + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 4: Fails with 400 when zero parameters are present", async () => { + await fc.assert( + fc.asyncProperty( + // Generate N param names (N >= 2, unique, valid identifiers) + fc.uniqueArray(fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), { minLength: 2, maxLength: 6 }), + async (paramNames) => { + const rule: MutualExclusivityRule = { + checkType: "mutualExclusivity", + params: paramNames.map((name) => ({ name, source: "query" as const })), + failAction: { + statusCode: 400, + code: "InvalidInput", + message: `Exactly one of '${paramNames.join(", ")}' must be provided`, + }, + }; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // No params present — empty query + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + + const failResult = result as ValidationFail; + expect(failResult.statusCode).toBe(400); + expect(failResult.body.errors).toHaveLength(1); + + // Message should list all param names as required options + const message = failResult.body.errors[0].message; + for (const name of paramNames) { + expect(message).toContain(name); + } + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 4: Fails with 400 when more than one parameter is present", async () => { + await fc.assert( + fc.asyncProperty( + // Generate N param names (N >= 2, unique, valid identifiers) + fc.uniqueArray(fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), { minLength: 2, maxLength: 6 }), + // Generate non-empty values + fc.string({ minLength: 1, maxLength: 20 }), + fc.string({ minLength: 1, maxLength: 20 }), + // How many extra params to set (at least 2 total present) + fc.nat({ max: 4 }), + async (paramNames, value1, value2, extraCount) => { + const rule: MutualExclusivityRule = { + checkType: "mutualExclusivity", + params: paramNames.map((name) => ({ name, source: "query" as const })), + failAction: { + statusCode: 400, + code: "InvalidInput", + message: `Exactly one of '${paramNames.join(", ")}' must be provided`, + }, + }; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // Set at least 2 params present + const numPresent = Math.min(2 + (extraCount % (paramNames.length - 1)), paramNames.length); + const presentNames = paramNames.slice(0, numPresent); + const queryParams: Record = {}; + queryParams[presentNames[0]] = value1; + queryParams[presentNames[1]] = value2; + for (let i = 2; i < presentNames.length; i++) { + queryParams[presentNames[i]] = `val${i}`; + } + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + + const failResult = result as ValidationFail; + expect(failResult.statusCode).toBe(400); + expect(failResult.body.errors).toHaveLength(1); + + // Message should identify the conflicting param names + const message = failResult.body.errors[0].message; + for (const name of presentNames) { + expect(message).toContain(name); + } + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Property 6: Conditional exclusion semantics + * + * For any conditional exclusion rule and any request context, the rule passes if the + * trigger parameter is absent OR if the trigger is present and all forbidden parameters + * are absent. The rule fails with HTTP 400 if and only if the trigger parameter is + * present AND at least one forbidden parameter is also present and non-empty. + * + * **Validates: Requirements 3.6, 3.7** + */ +import { ConditionalExclusionRule } from "../../src/validation/validationTypes.js"; +import { executeValidation as execValidation } from "../../src/service/validationEngine.js"; +import { VALIDATION_REGISTRY as REGISTRY } from "../../src/validation/validationRegistry.js"; + +describe("Feature: deterministic-validation-system, Property 6: Conditional exclusion semantics", () => { + const TEST_OPERATION_ID = "__test_conditionalExclusion__"; + + beforeEach(() => { + REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + it("Property 6: Passes when trigger is absent (regardless of forbidden params)", async () => { + await fc.assert( + fc.asyncProperty( + // Generate a trigger param name (valid identifier-style) + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), + // Generate 1-4 forbidden param names (unique, valid identifiers) + fc.uniqueArray(fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), { minLength: 1, maxLength: 4 }), + // Generate values for forbidden params + fc.array(fc.string({ minLength: 1, maxLength: 10 }), { minLength: 1, maxLength: 4 }), + async (triggerName, forbiddenNames, forbiddenValues) => { + // Ensure trigger name is not in forbidden names + const filteredForbidden = forbiddenNames.filter((n) => n !== triggerName); + fc.pre(filteredForbidden.length >= 1); + + const rule: ConditionalExclusionRule = { + checkType: "conditionalExclusion", + trigger: { name: triggerName, source: "query" }, + forbidden: filteredForbidden.map((name) => ({ name, source: "query" as const })), + failAction: { + statusCode: 400, + code: "InvalidInput", + message: `Parameter cannot be provided when '${triggerName}' is present`, + }, + }; + + REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // Build query params WITHOUT the trigger — forbidden may or may not be present + const queryParams: Record = {}; + for (let i = 0; i < filteredForbidden.length && i < forbiddenValues.length; i++) { + queryParams[filteredForbidden[i]] = forbiddenValues[i]; + } + // Trigger is NOT present in queryParams + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams, + body: undefined, + }; + + const result = await execValidation(context); + // Should always pass when trigger is absent, regardless of forbidden params + expect(result.pass).toBe(true); + + REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 6: Passes when trigger is present and all forbidden are absent", async () => { + await fc.assert( + fc.asyncProperty( + // Generate a trigger param name + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), + // Generate a trigger value (non-empty) + fc.string({ minLength: 1, maxLength: 20 }), + // Generate 1-4 forbidden param names + fc.uniqueArray(fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), { minLength: 1, maxLength: 4 }), + async (triggerName, triggerValue, forbiddenNames) => { + // Ensure trigger name is not in forbidden names + const filteredForbidden = forbiddenNames.filter((n) => n !== triggerName); + fc.pre(filteredForbidden.length >= 1); + + const rule: ConditionalExclusionRule = { + checkType: "conditionalExclusion", + trigger: { name: triggerName, source: "query" }, + forbidden: filteredForbidden.map((name) => ({ name, source: "query" as const })), + failAction: { + statusCode: 400, + code: "InvalidInput", + message: `Parameter cannot be provided when '${triggerName}' is present`, + }, + }; + + REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // Trigger IS present, forbidden params are NOT present + const queryParams: Record = { [triggerName]: triggerValue }; + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams, + body: undefined, + }; + + const result = await execValidation(context); + // Should pass: trigger present but no forbidden params + expect(result.pass).toBe(true); + + REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 6: Fails with 400 when trigger is present and at least one forbidden is present", async () => { + await fc.assert( + fc.asyncProperty( + // Generate a trigger param name + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), + // Generate a trigger value (non-empty) + fc.string({ minLength: 1, maxLength: 20 }), + // Generate 1-4 forbidden param names + fc.uniqueArray(fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), { minLength: 1, maxLength: 4 }), + // Generate a forbidden value (non-empty) + fc.string({ minLength: 1, maxLength: 20 }), + // Index of which forbidden param to make present + fc.nat(), + async (triggerName, triggerValue, forbiddenNames, forbiddenValue, forbiddenIdx) => { + // Ensure trigger name is not in forbidden names + const filteredForbidden = forbiddenNames.filter((n) => n !== triggerName); + fc.pre(filteredForbidden.length >= 1); + + const rule: ConditionalExclusionRule = { + checkType: "conditionalExclusion", + trigger: { name: triggerName, source: "query" }, + forbidden: filteredForbidden.map((name) => ({ name, source: "query" as const })), + failAction: { + statusCode: 400, + code: "InvalidInput", + message: `Parameter cannot be provided when '${triggerName}' is present`, + }, + }; + + REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // Trigger IS present AND at least one forbidden param IS present + const presentForbiddenName = filteredForbidden[forbiddenIdx % filteredForbidden.length]; + const queryParams: Record = { + [triggerName]: triggerValue, + [presentForbiddenName]: forbiddenValue, + }; + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams, + body: undefined, + }; + + const result = await execValidation(context); + // Should fail with 400 + expect(result.pass).toBe(false); + + const failResult = result as ValidationFail; + expect(failResult.statusCode).toBe(400); + expect(failResult.body.errors).toHaveLength(1); + // Error message should reference the trigger name and the forbidden param + expect(failResult.body.errors[0].message).toContain(triggerName); + expect(failResult.body.errors[0].message).toContain(presentForbiddenName); + + REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Feature: deterministic-validation-system + * Property 3: Entity existence — nested lookup + * + * For any nested entity existence rule, if the parent entity does not exist in + * the database, the rule returns HTTP 404 referencing the parent entity label + * and identifier; if the parent exists but the child entity is not found within + * the parent's specified collection, the rule returns HTTP 404 referencing the + * child entity label and identifier; if both parent and child exist, the rule passes. + * + * **Validates: Requirements 2.3, 2.4, 2.5** + */ +describe("Feature: deterministic-validation-system, Property 3: Entity existence — nested lookup", () => { + const TEST_OPERATION_ID = "__test_nested_entity_prop3__"; + + beforeEach(() => { + // Reset all API partitions in mock database + for (const api of Object.values(Api)) { + mockDbData[api] = {}; + } + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + it("should correctly handle nested entity lookup: pass when both exist, 404 for missing parent, 404 for missing child", async () => { + // Arbitraries (excluding JS prototype-polluting keys that can't be used as plain object keys) + const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype", "toString", "valueOf", "hasOwnProperty"]); + const identifierArb = fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0 && !RESERVED_KEYS.has(s)); + const labelArb = fc.string({ minLength: 1, maxLength: 15 }).filter((s) => s.trim().length > 0); + const paramSourceArb = fc.constantFrom("path" as const, "query" as const); + const fieldNameArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/); + const scenarioArb = fc.constantFrom("both_exist" as const, "parent_missing" as const, "child_missing" as const); + + await fc.assert( + fc.asyncProperty( + fieldNameArb, // parentParamName + paramSourceArb, // parentParamSource + labelArb, // parentLabel + fieldNameArb, // childParamName + paramSourceArb, // childParamSource + fieldNameArb, // childCollection + fieldNameArb, // childIdField + labelArb, // childLabel + identifierArb, // parentId + identifierArb, // childId + scenarioArb, // scenario + async (parentParamName, parentParamSource, parentLabel, childParamName, childParamSource, childCollection, childIdField, childLabel, parentId, childId, scenario) => { + // Ensure parentParamName and childParamName are different to avoid collisions + fc.pre(parentParamName !== childParamName); + // Ensure childCollection is different from parentParamName to avoid field collisions + fc.pre(childCollection !== parentParamName); + + // Build the EntityExistenceRule with nested config + const rule: EntityExistenceRule = { + checkType: "entityExistence", + entity: { + api: Api.ORDERS, + paramName: parentParamName, + paramSource: parentParamSource, + entityLabel: parentLabel, + }, + nested: { + childParamName, + childParamSource, + childCollection, + childIdField, + childLabel, + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Entity not found", + }, + }; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // Build request context + const pathParams: Record = {}; + const queryParams: Record = {}; + + if (parentParamSource === "path") { + pathParams[parentParamName] = parentId; + } else { + queryParams[parentParamName] = parentId; + } + + if (childParamSource === "path") { + pathParams[childParamName] = childId; + } else { + queryParams[childParamName] = childId; + } + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams, + queryParams, + body: undefined, + }; + + // Set up database state based on scenario + const parentEntity: Record = { + [parentParamName]: parentId, + }; + + // Reset the orders partition + mockDbData[Api.ORDERS] = {}; + + switch (scenario) { + case "both_exist": { + // Parent exists with child in its collection + const childEntity = { [childIdField]: childId }; + parentEntity[childCollection] = [childEntity]; + mockDbData[Api.ORDERS] = { [parentId]: parentEntity }; + break; + } + case "parent_missing": { + // Parent does NOT exist — leave db empty + mockDbData[Api.ORDERS] = {}; + break; + } + case "child_missing": { + // Parent exists but child has a different ID so it won't match + parentEntity[childCollection] = [{ [childIdField]: `__nonmatch__${childId}__xyz` }]; + mockDbData[Api.ORDERS] = { [parentId]: parentEntity }; + break; + } + } + + const result = await executeValidation(context); + + // Verify based on scenario + switch (scenario) { + case "both_exist": + expect(result.pass).toBe(true); + break; + case "parent_missing": + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(404); + expect(result.body.errors[0].message).toContain(parentLabel); + } + break; + case "child_missing": + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(404); + expect(result.body.errors[0].message).toContain(childLabel); + } + break; + } + + // Cleanup + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + mockDbData[Api.ORDERS] = {}; + }, + ), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Feature: deterministic-validation-system + * Property 8: Business rule skip on missing entity + * + * For any business rule and any request context where the referenced entity does + * not exist in the database, the rule SHALL return a pass result without evaluating + * the condition or producing an error. + * + * **Validates: Requirements 4.4** + */ +import { BusinessRuleCheck } from "../../src/validation/validationTypes.js"; + +describe("Feature: deterministic-validation-system, Property 8: Business rule skip on missing entity", () => { + const TEST_OPERATION_ID = "__test_businessRule_missingEntity__"; + + beforeEach(() => { + // Reset all API partitions in mock database + for (const api of Object.values(Api)) { + mockDbData[api] = {}; + } + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + it("Property 8: Always passes when entity referenced by business rule is not found in database", async () => { + await fc.assert( + fc.asyncProperty( + // Generate an entity identifier (non-empty string used as the lookup key) + fc.string({ minLength: 1, maxLength: 30 }).filter((s) => s.trim().length > 0), + // Generate a field path for the condition + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}(\.[a-zA-Z][a-zA-Z0-9]{0,9}){0,2}$/), + // Generate an operator + fc.constantFrom("eq" as const, "neq" as const, "in" as const, "notIn" as const), + // Generate a condition value + fc.oneof(fc.string({ minLength: 1, maxLength: 10 }), fc.integer(), fc.boolean()), + // Generate a param name for the entity identifier + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/), + // Generate a param source + fc.constantFrom("path" as const, "query" as const, "body" as const), + async (entityId, fieldPath, operator, conditionValue, paramName, paramSource) => { + // Build a business rule that references an entity + const rule: BusinessRuleCheck = { + checkType: "businessRule", + entity: { + api: Api.ORDERS, + paramName, + paramSource, + }, + condition: { + field: fieldPath, + operator, + value: operator === "in" || operator === "notIn" ? [conditionValue] : conditionValue, + }, + failAction: { + statusCode: 400, + code: "BusinessRuleViolation", + message: "Business rule violated", + }, + }; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // Set up database with NO entity matching the identifier. + // Either empty or with different entities that don't match. + mockDbData[Api.ORDERS] = { + __other_entity_1__: { someField: "someValue" }, + __other_entity_2__: { anotherField: 42 }, + }; + + // Build request context with the entity identifier + const pathParams: Record = {}; + const queryParams: Record = {}; + let body: Record | undefined; + + switch (paramSource) { + case "path": + pathParams[paramName] = entityId; + break; + case "query": + queryParams[paramName] = entityId; + break; + case "body": + body = { [paramName]: entityId }; + break; + } + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "POST", + pathParams, + queryParams, + body, + }; + + const result = await executeValidation(context); + + // Key property: when entity is NOT found, the business rule always passes + expect(result.pass).toBe(true); + + // Cleanup + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + mockDbData[Api.ORDERS] = {}; + }, + ), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Feature: deterministic-validation-system + * Property 7: Business rule evaluation correctness + * + * For any business rule with a given operator (eq, neq, in, notIn) and any entity + * data in the database, the rule evaluates the condition by extracting the specified + * field from the entity and comparing it to the expected value using the specified + * operator. The rule fails (returning the Fail_Action) if and only if the condition + * is satisfied (i.e., the business constraint is violated). + * + * **Validates: Requirements 4.1, 4.2, 4.3** + */ +// BusinessRuleCheck already imported above + +describe("Feature: deterministic-validation-system, Property 7: Business rule evaluation correctness", () => { + const TEST_OPERATION_ID = "__test_businessRule_prop7__"; + + beforeEach(() => { + // Reset all API partitions in mock database + for (const api of Object.values(Api)) { + mockDbData[api] = {}; + } + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + it("Property 7 (eq): condition satisfied → fails; condition not satisfied → passes", async () => { + // Generate a field value and a comparison value; test both matching and non-matching + const fieldValueArb = fc.oneof(fc.string({ minLength: 1, maxLength: 20 }), fc.integer(), fc.boolean()); + const fieldNameArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/); + const identifierArb = fc.stringMatching(/^[a-zA-Z0-9]{1,15}$/); + + await fc.assert( + fc.asyncProperty( + fieldNameArb, + fieldValueArb, + identifierArb, + fc.boolean(), // shouldMatch: whether the condition matches (field === value) + async (fieldName, fieldValue, identifier, shouldMatch) => { + // Build entity in the database + const entity: Record = { [fieldName]: fieldValue }; + mockDbData[Api.ORDERS] = { [identifier]: entity }; + + // Comparison value: same as fieldValue if shouldMatch, otherwise different + const comparisonValue = shouldMatch ? fieldValue : `__different__${String(fieldValue)}`; + + const rule: BusinessRuleCheck = { + checkType: "businessRule", + entity: { + api: Api.ORDERS, + paramName: "orderId", + paramSource: "path", + }, + condition: { + field: fieldName, + operator: "eq", + value: comparisonValue, + }, + failAction: { + statusCode: 400, + code: "BusinessRuleViolation", + message: "Business rule violated", + }, + }; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: { orderId: identifier }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + + if (shouldMatch) { + // Condition satisfied → fails (business constraint violated) + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(400); + expect(result.body.errors[0].code).toBe("BusinessRuleViolation"); + } + } else { + // Condition NOT satisfied → passes + expect(result.pass).toBe(true); + } + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + mockDbData[Api.ORDERS] = {}; + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 7 (neq): condition satisfied → fails; condition not satisfied → passes", async () => { + const fieldValueArb = fc.oneof(fc.string({ minLength: 1, maxLength: 20 }), fc.integer(), fc.boolean()); + const fieldNameArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/); + const identifierArb = fc.stringMatching(/^[a-zA-Z0-9]{1,15}$/); + + await fc.assert( + fc.asyncProperty( + fieldNameArb, + fieldValueArb, + identifierArb, + fc.boolean(), // shouldSatisfyCondition: whether fieldValue !== comparisonValue (neq satisfied) + async (fieldName, fieldValue, identifier, shouldSatisfyCondition) => { + const entity: Record = { [fieldName]: fieldValue }; + mockDbData[Api.ORDERS] = { [identifier]: entity }; + + // For neq: condition is satisfied when fieldValue !== comparisonValue + // shouldSatisfyCondition=true means we want neq to be TRUE → values must differ + // shouldSatisfyCondition=false means we want neq to be FALSE → values must be equal + const comparisonValue = shouldSatisfyCondition ? `__different__${String(fieldValue)}` : fieldValue; + + const rule: BusinessRuleCheck = { + checkType: "businessRule", + entity: { + api: Api.ORDERS, + paramName: "orderId", + paramSource: "path", + }, + condition: { + field: fieldName, + operator: "neq", + value: comparisonValue, + }, + failAction: { + statusCode: 400, + code: "BusinessRuleViolation", + message: "Business rule violated", + }, + }; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: { orderId: identifier }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + + if (shouldSatisfyCondition) { + // neq condition satisfied (field !== value) → fails + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(400); + expect(result.body.errors[0].code).toBe("BusinessRuleViolation"); + } + } else { + // neq condition NOT satisfied (field === value) → passes + expect(result.pass).toBe(true); + } + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + mockDbData[Api.ORDERS] = {}; + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 7 (in): condition satisfied → fails; condition not satisfied → passes", async () => { + const fieldValueArb = fc.string({ minLength: 1, maxLength: 15 }); + const fieldNameArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/); + const identifierArb = fc.stringMatching(/^[a-zA-Z0-9]{1,15}$/); + // Generate an array of strings for the "in" comparison + const arrayValuesArb = fc.array(fc.string({ minLength: 1, maxLength: 15 }), { minLength: 1, maxLength: 5 }); + + await fc.assert( + fc.asyncProperty( + fieldNameArb, + fieldValueArb, + identifierArb, + arrayValuesArb, + fc.boolean(), // shouldSatisfyCondition: whether fieldValue is in the array + async (fieldName, fieldValue, identifier, arrayValues, shouldSatisfyCondition) => { + const entity: Record = { [fieldName]: fieldValue }; + mockDbData[Api.ORDERS] = { [identifier]: entity }; + + // For "in": condition is satisfied when array.includes(fieldValue) + let comparisonArray: string[]; + if (shouldSatisfyCondition) { + // Ensure fieldValue IS in the array + comparisonArray = [...arrayValues.filter((v) => v !== fieldValue), fieldValue]; + } else { + // Ensure fieldValue is NOT in the array + comparisonArray = arrayValues.filter((v) => v !== fieldValue); + if (comparisonArray.length === 0) { + comparisonArray = [`__noMatch__${fieldValue}`]; + } + } + + const rule: BusinessRuleCheck = { + checkType: "businessRule", + entity: { + api: Api.ORDERS, + paramName: "orderId", + paramSource: "path", + }, + condition: { + field: fieldName, + operator: "in", + value: comparisonArray, + }, + failAction: { + statusCode: 400, + code: "BusinessRuleViolation", + message: "Business rule violated", + }, + }; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: { orderId: identifier }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + + if (shouldSatisfyCondition) { + // "in" condition satisfied → fails + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(400); + expect(result.body.errors[0].code).toBe("BusinessRuleViolation"); + } + } else { + // "in" condition NOT satisfied → passes + expect(result.pass).toBe(true); + } + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + mockDbData[Api.ORDERS] = {}; + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 7 (notIn): condition satisfied → fails; condition not satisfied → passes", async () => { + const fieldValueArb = fc.string({ minLength: 1, maxLength: 15 }); + const fieldNameArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/); + const identifierArb = fc.stringMatching(/^[a-zA-Z0-9]{1,15}$/); + // Generate an array of strings for the "notIn" comparison + const arrayValuesArb = fc.array(fc.string({ minLength: 1, maxLength: 15 }), { minLength: 1, maxLength: 5 }); + + await fc.assert( + fc.asyncProperty( + fieldNameArb, + fieldValueArb, + identifierArb, + arrayValuesArb, + fc.boolean(), // shouldSatisfyCondition: whether fieldValue is NOT in the array (notIn satisfied) + async (fieldName, fieldValue, identifier, arrayValues, shouldSatisfyCondition) => { + const entity: Record = { [fieldName]: fieldValue }; + mockDbData[Api.ORDERS] = { [identifier]: entity }; + + // For "notIn": condition is satisfied when !array.includes(fieldValue) + let comparisonArray: string[]; + if (shouldSatisfyCondition) { + // Ensure fieldValue is NOT in the array (so notIn is satisfied → fails) + comparisonArray = arrayValues.filter((v) => v !== fieldValue); + if (comparisonArray.length === 0) { + comparisonArray = [`__noMatch__${fieldValue}`]; + } + } else { + // Ensure fieldValue IS in the array (so notIn is NOT satisfied → passes) + comparisonArray = [...arrayValues.filter((v) => v !== fieldValue), fieldValue]; + } + + const rule: BusinessRuleCheck = { + checkType: "businessRule", + entity: { + api: Api.ORDERS, + paramName: "orderId", + paramSource: "path", + }, + condition: { + field: fieldName, + operator: "notIn", + value: comparisonArray, + }, + failAction: { + statusCode: 400, + code: "BusinessRuleViolation", + message: "Business rule violated", + }, + }; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: { orderId: identifier }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + + if (shouldSatisfyCondition) { + // "notIn" condition satisfied (field not in array) → fails + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(400); + expect(result.body.errors[0].code).toBe("BusinessRuleViolation"); + } + } else { + // "notIn" condition NOT satisfied (field is in array) → passes + expect(result.pass).toBe(true); + } + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + mockDbData[Api.ORDERS] = {}; + }, + ), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Feature: deterministic-validation-system + * Property 9: Pipeline short-circuit execution + * + * For any validation pipeline of length N where rule at position K (0 ≤ K < N) + * is the first to fail, the pipeline returns the failure result of rule K, + * and rules at positions K+1 through N-1 are never evaluated. If all N rules + * pass, the pipeline returns a pass result. + * + * **Validates: Requirements 5.1, 5.2, 5.3, 5.4** + */ +describe("Feature: deterministic-validation-system, Property 9: Pipeline short-circuit execution", () => { + const TEST_OPERATION_ID = "__test_pipeline_shortcircuit__"; + + beforeEach(() => { + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + it("Property 9: Pipeline short-circuits at first failing rule K, returning rule K's failure", async () => { + await fc.assert( + fc.asyncProperty( + // Pipeline length N (2-5) + fc.integer({ min: 2, max: 5 }), + // Position K where the failing rule is (will be constrained to [0, N-1]) + fc.nat(), + async (pipelineLength, rawK) => { + const K = rawK % pipelineLength; + + // Create unique param names for each rule to detect which rules are evaluated + const ruleParamNames: string[] = []; + for (let i = 0; i < pipelineLength; i++) { + ruleParamNames.push(`param_rule_${i}`); + } + + // Build the pipeline: each rule is an atLeastOneRequired with a unique param + const pipeline: AtLeastOneRequiredRule[] = ruleParamNames.map((paramName, idx) => ({ + checkType: "atLeastOneRequired" as const, + params: [{ name: paramName, source: "query" as const }], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: `At least one of '${paramName}' must be provided`, + }, + })); + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), pipeline as ValidationPipeline); + + // Build query params: provide values for rules 0..K-1, omit for K and beyond + const queryParams: Record = {}; + for (let i = 0; i < K; i++) { + queryParams[ruleParamNames[i]] = "present-value"; + } + // Rule at K does NOT have its param → it will fail + // Rules K+1..N-1 also don't have their params, but should never be reached + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams, + body: undefined, + }; + + const result = await executeValidation(context); + + // Pipeline should fail at rule K + expect(result.pass).toBe(false); + + const failResult = result as ValidationFail; + expect(failResult.statusCode).toBe(400); + expect(failResult.body.errors).toHaveLength(1); + + // The error message should reference rule K's param, not any later rule's param + const errorMessage = failResult.body.errors[0].message; + expect(errorMessage).toContain(ruleParamNames[K]); + + // Verify error does NOT reference any param from rules K+1..N-1 + for (let i = K + 1; i < pipelineLength; i++) { + expect(errorMessage).not.toContain(ruleParamNames[i]); + } + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 9: Pipeline returns pass when all rules pass", async () => { + await fc.assert( + fc.asyncProperty( + // Pipeline length N (2-5) + fc.integer({ min: 2, max: 5 }), + async (pipelineLength) => { + // Create unique param names for each rule + const ruleParamNames: string[] = []; + for (let i = 0; i < pipelineLength; i++) { + ruleParamNames.push(`param_allpass_${i}`); + } + + // Build the pipeline: each rule is atLeastOneRequired with a unique param + const pipeline: AtLeastOneRequiredRule[] = ruleParamNames.map((paramName) => ({ + checkType: "atLeastOneRequired" as const, + params: [{ name: paramName, source: "query" as const }], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: `At least one of '${paramName}' must be provided`, + }, + })); + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), pipeline as ValidationPipeline); + + // Provide ALL params so every rule passes + const queryParams: Record = {}; + for (const paramName of ruleParamNames) { + queryParams[paramName] = "present-value"; + } + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams, + body: undefined, + }; + + const result = await executeValidation(context); + + // All rules pass → pipeline returns pass + expect(result.pass).toBe(true); + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }, + ), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Feature: deterministic-validation-system + * Property 10: Error response structure conformance + * + * For any validation rule that fails, the returned body SHALL be a JSON object with + * an `errors` array containing exactly one entry, and that entry SHALL contain a `code` + * string field and a `message` string field. The returned statusCode SHALL equal the + * rule's failAction.statusCode. + * + * **Validates: Requirements 8.1, 8.2, 8.3** + */ +describe("Feature: deterministic-validation-system, Property 10: Error response structure conformance", () => { + const TEST_OPERATION_ID = "__test_errorStructure_prop10__"; + + beforeEach(() => { + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + it("Property 10: Failing rules produce conformant SP-API error response structure", async () => { + // Generate a random statusCode (400-599) and a non-empty code string + const statusCodeArb = fc.integer({ min: 400, max: 599 }); + const codeArb = fc.string({ minLength: 1, maxLength: 30 }).filter((s) => s.trim().length > 0); + const messageArb = fc.string({ minLength: 1, maxLength: 50 }).filter((s) => s.trim().length > 0); + // Generate 1-5 unique param names that we will NOT provide — guaranteeing failure + const paramNamesArb = fc.uniqueArray(fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/), { minLength: 1, maxLength: 5 }); + + await fc.assert( + fc.asyncProperty(statusCodeArb, codeArb, messageArb, paramNamesArb, async (statusCode, code, message, paramNames) => { + fc.pre(paramNames.length >= 1); + + // Build an atLeastOneRequired rule that will definitely fail (no params provided) + const rule: AtLeastOneRequiredRule = { + checkType: "atLeastOneRequired", + params: paramNames.map((name) => ({ name, source: "query" as const })), + failAction: { + statusCode, + code, + message, + }, + }; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + try { + // Execute with empty query params — guaranteed to fail + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + + // Verify: result must indicate failure + expect(result.pass).toBe(false); + + if (!result.pass) { + // Verify: statusCode matches the rule's failAction.statusCode + expect(result.statusCode).toBe(statusCode); + + // Verify: body is an object + expect(result.body).toBeDefined(); + expect(typeof result.body).toBe("object"); + expect(result.body).not.toBeNull(); + + // Verify: body.errors is an array with exactly one entry + expect(Array.isArray(result.body.errors)).toBe(true); + expect(result.body.errors).toHaveLength(1); + + // Verify: the single error entry has code (string) and message (string) + const errorEntry = result.body.errors[0]; + expect(typeof errorEntry.code).toBe("string"); + expect(typeof errorEntry.message).toBe("string"); + } + } finally { + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + } + }), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Feature: deterministic-validation-system + * Property 12: Unregistered Validation_Key fails validation + * + * For any composite Validation_Key (formed from apiName, apiVersion, and operationId) + * that does not have a pipeline registered in the Validation_Registry, executing + * validation SHALL return a fail result with HTTP 400 and a NoValidationPipeline error code. + * + * **Validates: Requirements 6.2, 9.3, 9.5** + */ +describe("Feature: deterministic-validation-system, Property 12: Unregistered Validation_Key fails validation", () => { + beforeEach(() => { + // Ensure the registry is empty so no composite key collides with registered entries + VALIDATION_REGISTRY.clear(); + }); + + it("Property 12: Any unregistered three-part composite key (apiName:apiVersion:operationId) always returns a fail result", async () => { + await fc.assert( + fc.asyncProperty( + // Generate arbitrary apiName (non-empty, prefixed to avoid collisions) + fc.string({ minLength: 1, maxLength: 30 }).map((s) => `UnregApi_${s}`), + // Generate arbitrary apiVersion (non-empty, varied formats like "v0", "2024-01-01", etc.) + fc.oneof( + fc.string({ minLength: 1, maxLength: 20 }).map((s) => `v${s}`), + fc.tuple(fc.integer({ min: 2020, max: 2030 }), fc.integer({ min: 1, max: 12 }), fc.integer({ min: 1, max: 28 })).map(([y, m, d]) => `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`), + ), + // Generate arbitrary operationId (non-empty) + fc.string({ minLength: 1, maxLength: 40 }).map((s) => `unregisteredOp_${s}`), + // Generate arbitrary HTTP methods + fc.constantFrom("GET", "POST", "PUT", "DELETE", "PATCH"), + // Generate arbitrary path params + fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fc.string({ maxLength: 20 })), + // Generate arbitrary query params + fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fc.string({ maxLength: 20 })), + // Generate arbitrary body (or undefined) + fc.option(fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fc.jsonValue()), { nil: undefined }), + async (apiName, apiVersion, operationId, method, pathParams, queryParams, body) => { + const key = buildKey(apiName, apiVersion, operationId); + // Ensure the composite key is NOT in the registry + fc.pre(!VALIDATION_REGISTRY.has(key)); + + const context: RequestContext = { + apiName, + apiVersion, + operationId, + method, + pathParams, + queryParams, + body, + }; + + const result = await executeValidation(context); + + // Key property: unregistered composite keys always fail validation + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(501); + expect(result.body?.errors[0].code).toBe("NoValidationPipeline"); + expect(result.body?.errors[0].message).toContain(key); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Property 11: Default error code fallback + * + * For any validation rule whose failAction does not specify a `code` value, + * when the rule fails, the error entry's `code` field SHALL equal the rule's + * checkType string. + * + * **Validates: Requirements 8.4** + */ +describe("Feature: deterministic-validation-system, Property 11: Default error code fallback", () => { + const TEST_OPERATION_ID = "__test_defaultErrorCodeFallback__"; + + beforeEach(() => { + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + it("Property 11: When failAction.code is undefined, error code equals checkType", async () => { + // Strategy: randomly choose among rule types that can be made to fail, + // set failAction.code to undefined, create a failing context, and verify + // that the error response uses checkType as the code. + const ruleTypeArb = fc.constantFrom("atLeastOneRequired" as const, "mutualExclusivity" as const, "conditionalExclusion" as const); + + // Generate unique param names for building rules + const paramNamesArb = fc.uniqueArray(fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/), { minLength: 2, maxLength: 5 }); + + await fc.assert( + fc.asyncProperty(ruleTypeArb, paramNamesArb, fc.string({ minLength: 1, maxLength: 20 }), async (ruleType, paramNames, triggerValue) => { + fc.pre(paramNames.length >= 2); + + let rule: AtLeastOneRequiredRule | MutualExclusivityRule | ConditionalExclusionRule; + let context: RequestContext; + + switch (ruleType) { + case "atLeastOneRequired": { + // Rule with no code specified — all params absent causes failure + rule = { + checkType: "atLeastOneRequired", + params: paramNames.map((name) => ({ name, source: "query" as const })), + failAction: { + statusCode: 400, + code: undefined, + message: `At least one of '${paramNames.join("', '")}' must be provided`, + }, + }; + + // Context where none of the params are present → rule fails + context = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + break; + } + case "mutualExclusivity": { + // Rule with no code specified — zero params present causes failure + rule = { + checkType: "mutualExclusivity", + params: paramNames.map((name) => ({ name, source: "query" as const })), + failAction: { + statusCode: 400, + code: undefined, + message: `Exactly one of '${paramNames.join(", ")}' must be provided`, + }, + }; + + // Context where none of the params are present → rule fails + context = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + break; + } + case "conditionalExclusion": { + // Rule with no code specified — trigger present + forbidden present causes failure + const triggerName = paramNames[0]; + const forbiddenNames = paramNames.slice(1); + + rule = { + checkType: "conditionalExclusion", + trigger: { name: triggerName, source: "query" }, + forbidden: forbiddenNames.map((name) => ({ name, source: "query" as const })), + failAction: { + statusCode: 400, + code: undefined, + message: `Parameter cannot be provided when '${triggerName}' is present`, + }, + }; + + // Context where trigger AND at least one forbidden param are present → rule fails + const queryParams: Record = { + [triggerName]: triggerValue, + [forbiddenNames[0]]: "some-forbidden-value", + }; + + context = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams, + body: undefined, + }; + break; + } + } + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + const result = await executeValidation(context); + + // The rule should fail + expect(result.pass).toBe(false); + + if (!result.pass) { + // The error code should fall back to the checkType since code is undefined + expect(result.body.errors).toHaveLength(1); + expect(result.body.errors[0].code).toBe(ruleType); + } + + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Feature: deterministic-validation-system + * Property 13: Composite key guarantees cross-domain uniqueness + * + * For any two distinct combinations of API name and API version, and any shared + * operationId, registering different validation pipelines under each composite key + * (`apiName1:apiVersion1:operationId` and `apiName2:apiVersion2:operationId`) SHALL + * result in lookups resolving to distinct pipelines — the pipeline returned for one + * key is not the same as the pipeline returned for the other. + * + * **Validates: Requirements 9.1, 9.6** + */ +describe("Feature: deterministic-validation-system, Property 13: Composite key guarantees cross-domain uniqueness", () => { + beforeEach(() => { + VALIDATION_REGISTRY.clear(); + }); + + it("Property 13: Cross-domain — different apiName with same operationId resolves to distinct pipelines", async () => { + await fc.assert( + fc.asyncProperty( + // Generate two distinct apiNames + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9 ]{0,19}$/), + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9 ]{0,19}$/), + // Shared apiVersion + fc.stringMatching(/^[a-zA-Z0-9][a-zA-Z0-9.\-]{0,14}$/), + // Shared operationId + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,19}$/), + async (apiName1, apiName2, apiVersion, operationId) => { + // Ensure the two apiNames are distinct + fc.pre(apiName1 !== apiName2); + + // Create two distinct pipelines with different failAction messages + const pipeline1: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [{ name: "pipeline1Param", source: "query" }], + failAction: { + statusCode: 400, + code: "Pipeline1", + message: "Pipeline 1 failure", + }, + }, + ]; + + const pipeline2: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [{ name: "pipeline2Param", source: "query" }], + failAction: { + statusCode: 400, + code: "Pipeline2", + message: "Pipeline 2 failure", + }, + }, + ]; + + // Register both pipelines under the same operationId but different apiNames + const key1 = buildKey(apiName1, apiVersion, operationId); + const key2 = buildKey(apiName2, apiVersion, operationId); + VALIDATION_REGISTRY.set(key1, pipeline1); + VALIDATION_REGISTRY.set(key2, pipeline2); + + // Verify keys are distinct + expect(key1).not.toBe(key2); + + // Lookup pipeline 1 via executeValidation — provide pipeline1Param absent to trigger failure + const context1: RequestContext = { + apiName: apiName1, + apiVersion, + operationId, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result1 = await executeValidation(context1); + expect(result1.pass).toBe(false); + if (!result1.pass) { + expect(result1.body.errors[0].code).toBe("Pipeline1"); + } + + // Lookup pipeline 2 via executeValidation — provide pipeline2Param absent to trigger failure + const context2: RequestContext = { + apiName: apiName2, + apiVersion, + operationId, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result2 = await executeValidation(context2); + expect(result2.pass).toBe(false); + if (!result2.pass) { + expect(result2.body.errors[0].code).toBe("Pipeline2"); + } + + // Cleanup + VALIDATION_REGISTRY.delete(key1); + VALIDATION_REGISTRY.delete(key2); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 13: Cross-version — same apiName with different apiVersion resolves to distinct pipelines", async () => { + await fc.assert( + fc.asyncProperty( + // Shared apiName + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9 ]{0,19}$/), + // Generate two distinct apiVersions (e.g., "v0" vs "2026-01-01") + fc.stringMatching(/^[a-zA-Z0-9][a-zA-Z0-9.\-]{0,14}$/), + fc.stringMatching(/^[a-zA-Z0-9][a-zA-Z0-9.\-]{0,14}$/), + // Shared operationId + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,19}$/), + async (apiName, apiVersion1, apiVersion2, operationId) => { + // Ensure the two apiVersions are distinct + fc.pre(apiVersion1 !== apiVersion2); + + // Create two distinct pipelines with different failAction messages + const pipeline1: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [{ name: "versionPipeline1Param", source: "query" }], + failAction: { + statusCode: 400, + code: "VersionPipeline1", + message: "Version Pipeline 1 failure", + }, + }, + ]; + + const pipeline2: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [{ name: "versionPipeline2Param", source: "query" }], + failAction: { + statusCode: 400, + code: "VersionPipeline2", + message: "Version Pipeline 2 failure", + }, + }, + ]; + + // Register both pipelines under same apiName + operationId but different apiVersions + const key1 = buildKey(apiName, apiVersion1, operationId); + const key2 = buildKey(apiName, apiVersion2, operationId); + VALIDATION_REGISTRY.set(key1, pipeline1); + VALIDATION_REGISTRY.set(key2, pipeline2); + + // Verify keys are distinct + expect(key1).not.toBe(key2); + + // Lookup pipeline 1 via executeValidation + const context1: RequestContext = { + apiName, + apiVersion: apiVersion1, + operationId, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result1 = await executeValidation(context1); + expect(result1.pass).toBe(false); + if (!result1.pass) { + expect(result1.body.errors[0].code).toBe("VersionPipeline1"); + } + + // Lookup pipeline 2 via executeValidation + const context2: RequestContext = { + apiName, + apiVersion: apiVersion2, + operationId, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result2 = await executeValidation(context2); + expect(result2.pass).toBe(false); + if (!result2.pass) { + expect(result2.body.errors[0].code).toBe("VersionPipeline2"); + } + + // Cleanup + VALIDATION_REGISTRY.delete(key1); + VALIDATION_REGISTRY.delete(key2); + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 13: Real-world scenario — Orders:v0:getOrder vs Orders:2026-01-01:getOrder resolve independently", async () => { + await fc.assert( + fc.asyncProperty( + // Generate arbitrary non-empty param names to differentiate pipelines + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/), + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/), + async (param1, param2) => { + fc.pre(param1 !== param2); + + const pipeline1: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [{ name: param1, source: "query" }], + failAction: { + statusCode: 400, + code: "OrdersV0", + message: `Orders v0 requires '${param1}'`, + }, + }, + ]; + + const pipeline2: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [{ name: param2, source: "query" }], + failAction: { + statusCode: 400, + code: "Orders2026", + message: `Orders 2026-01-01 requires '${param2}'`, + }, + }, + ]; + + const key1 = buildKey("Orders", "v0", "getOrder"); + const key2 = buildKey("Orders", "2026-01-01", "getOrder"); + VALIDATION_REGISTRY.set(key1, pipeline1); + VALIDATION_REGISTRY.set(key2, pipeline2); + + // Verify the keys are "Orders:v0:getOrder" and "Orders:2026-01-01:getOrder" + expect(key1).toBe("Orders:v0:getOrder"); + expect(key2).toBe("Orders:2026-01-01:getOrder"); + expect(key1).not.toBe(key2); + + // Execute with context targeting Orders v0 — should use pipeline1 + const context1: RequestContext = { + apiName: "Orders", + apiVersion: "v0", + operationId: "getOrder", + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result1 = await executeValidation(context1); + expect(result1.pass).toBe(false); + if (!result1.pass) { + expect(result1.body.errors[0].code).toBe("OrdersV0"); + expect(result1.body.errors[0].message).toContain(param1); + } + + // Execute with context targeting Orders 2026-01-01 — should use pipeline2 + const context2: RequestContext = { + apiName: "Orders", + apiVersion: "2026-01-01", + operationId: "getOrder", + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result2 = await executeValidation(context2); + expect(result2.pass).toBe(false); + if (!result2.pass) { + expect(result2.body.errors[0].code).toBe("Orders2026"); + expect(result2.body.errors[0].message).toContain(param2); + } + + // Additionally verify: providing param1 makes pipeline1 pass but pipeline2 still fails + const context1WithParam: RequestContext = { + apiName: "Orders", + apiVersion: "v0", + operationId: "getOrder", + method: "GET", + pathParams: {}, + queryParams: { [param1]: "value" }, + body: undefined, + }; + + const result1Pass = await executeValidation(context1WithParam); + expect(result1Pass.pass).toBe(true); + + // Same param1 should NOT satisfy pipeline2 (which needs param2) + const context2WithParam1: RequestContext = { + apiName: "Orders", + apiVersion: "2026-01-01", + operationId: "getOrder", + method: "GET", + pathParams: {}, + queryParams: { [param1]: "value" }, + body: undefined, + }; + + const result2StillFails = await executeValidation(context2WithParam1); + expect(result2StillFails.pass).toBe(false); + if (!result2StillFails.pass) { + expect(result2StillFails.body.errors[0].code).toBe("Orders2026"); + } + + // Cleanup + VALIDATION_REGISTRY.delete(key1); + VALIDATION_REGISTRY.delete(key2); + }, + ), + { numRuns: 100 }, + ); + }); +}); + + + +/** + * Feature: deterministic-validation-system + * Property 15: Nested entity resolution populates both parent and child + * + * For any nested entity existence rule and any request context where both the parent + * entity and child entity exist in the database, the handler SHALL return a pass result + * that includes the parent entity record keyed by `entityLabel` and the child entity + * record keyed by `childLabel` in `resolvedEntities`. + * + * **Validates: Requirements 10.2** + */ +describe("Feature: deterministic-validation-system, Property 15: Nested entity resolution populates both parent and child", () => { + const TEST_OPERATION_ID = "__test_nested_resolvedEntities_prop15__"; + + beforeEach(() => { + for (const api of Object.values(Api)) { + mockDbData[api] = {}; + } + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + it("Property 15: When both parent and child exist, resolvedEntities contains parent keyed by entityLabel and child keyed by childLabel", async () => { + // Arbitraries for generating nested entity existence rules + const identifierArb = fc.stringMatching(/^[a-zA-Z0-9]{1,15}$/); + const labelArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/); + const fieldNameArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/); + const paramSourceArb = fc.constantFrom("path" as const, "query" as const); + // Generate extra field values for richer entity data + const extraFieldValueArb = fc.oneof(fc.string({ minLength: 1, maxLength: 15 }), fc.integer(), fc.boolean()); + + await fc.assert( + fc.asyncProperty( + fieldNameArb, // parentParamName + paramSourceArb, // parentParamSource + labelArb, // parentLabel (entityLabel) + fieldNameArb, // childParamName + paramSourceArb, // childParamSource + fieldNameArb, // childCollection + fieldNameArb, // childIdField + labelArb, // childLabel + identifierArb, // parentId + identifierArb, // childId + extraFieldValueArb, // extra parent field value + extraFieldValueArb, // extra child field value + async (parentParamName, parentParamSource, parentLabel, childParamName, childParamSource, childCollection, childIdField, childLabel, parentId, childId, extraParentValue, extraChildValue) => { + // Preconditions to avoid collisions + fc.pre(parentParamName !== childParamName); + fc.pre(childCollection !== parentParamName); + fc.pre(childCollection !== childIdField); + fc.pre(parentLabel !== childLabel); + + // Build the nested EntityExistenceRule + const rule: EntityExistenceRule = { + checkType: "entityExistence", + entity: { + api: Api.ORDERS, + paramName: parentParamName, + paramSource: parentParamSource, + entityLabel: parentLabel, + }, + nested: { + childParamName, + childParamSource, + childCollection, + childIdField, + childLabel, + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Entity not found", + }, + }; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // Build the parent entity with the child in its collection + const childEntity: Record = { + [childIdField]: childId, + extraChildField: extraChildValue, + }; + + const parentEntity: Record = { + [parentParamName]: parentId, + [childCollection]: [childEntity], + extraParentField: extraParentValue, + }; + + // Populate mock database — use the parentId as key for direct lookup + mockDbData[Api.ORDERS] = { [parentId]: parentEntity }; + + // Build request context with both parent and child IDs + const pathParams: Record = {}; + const queryParams: Record = {}; + + if (parentParamSource === "path") { + pathParams[parentParamName] = parentId; + } else { + queryParams[parentParamName] = parentId; + } + + if (childParamSource === "path") { + pathParams[childParamName] = childId; + } else { + queryParams[childParamName] = childId; + } + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams, + queryParams, + body: undefined, + }; + + const result = await executeValidation(context); + + // The rule should pass since both parent and child exist + expect(result.pass).toBe(true); + + if (result.pass) { + // resolvedEntities should contain the parent keyed by entityLabel + expect(result.resolvedEntities).toHaveProperty(parentLabel); + expect(result.resolvedEntities[parentLabel]).toEqual(parentEntity); + + // resolvedEntities should contain the child keyed by childLabel + expect(result.resolvedEntities).toHaveProperty(childLabel); + expect(result.resolvedEntities[childLabel]).toEqual(childEntity); + } + + // Cleanup + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + mockDbData[Api.ORDERS] = {}; + }, + ), + { numRuns: 100 }, + ); + }); +}); + + + +/** + * Feature: deterministic-validation-system + * Property 14: Flat entity resolution populates resolvedEntities + * + * For any entity existence rule (flat, non-nested) and any request context where + * the entity identifier resolves and the entity exists in the database, the handler + * SHALL return a pass result that includes the full database record of the resolved + * entity in `resolvedEntities`, keyed by the rule's `entityLabel`. + * + * **Validates: Requirements 10.1** + */ +describe("Feature: deterministic-validation-system, Property 14: Flat entity resolution populates resolvedEntities", () => { + const TEST_OPERATION_ID = "__test_flat_entity_resolution_prop14__"; + + beforeEach(() => { + // Reset all API partitions in mock database + for (const api of Object.values(Api)) { + mockDbData[api] = {}; + } + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + it("Property 14: Flat entity existence pass includes full entity record in resolvedEntities keyed by entityLabel", async () => { + // Generate arbitrary entity data fields (non-trivial objects) + const entityFieldsArb = fc.dictionary( + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/), + fc.oneof(fc.string({ minLength: 1, maxLength: 20 }), fc.integer(), fc.boolean()), + { minKeys: 1, maxKeys: 5 }, + ); + + // Generate an entity identifier (non-empty, valid string) + const identifierArb = fc.stringMatching(/^[a-zA-Z0-9]{1,20}$/); + + // Generate a paramName for the entity identifier + const paramNameArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/); + + // Generate a paramSource + const paramSourceArb = fc.constantFrom("path" as const, "query" as const, "body" as const); + + // Generate an entityLabel + const entityLabelArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,14}$/).filter((s) => s.trim().length > 0); + + // Pick an API partition + const apiArb = fc.constantFrom(Api.ORDERS, Api.LISTINGS, Api.CATALOG, Api.INVENTORY, Api.EXT_FULFILLMENT_SHIPMENTS); + + await fc.assert( + fc.asyncProperty( + entityFieldsArb, + identifierArb, + paramNameArb, + paramSourceArb, + entityLabelArb, + apiArb, + async (entityFields, identifier, paramName, paramSource, entityLabel, api) => { + // Build the full entity record: must include the paramName field so the handler can find it + const fullEntity: Record = { + ...entityFields, + [paramName]: identifier, + }; + + // Store entity in the mock database under the chosen API partition + mockDbData[api] = { [identifier]: fullEntity }; + + // Build the EntityExistenceRule (flat, no nested) + const rule: EntityExistenceRule = { + checkType: "entityExistence", + entity: { + api, + paramName, + paramSource, + entityLabel, + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: `${entityLabel} not found`, + }, + }; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule] as ValidationPipeline); + + // Build request context with the entity identifier in the correct source + const pathParams: Record = {}; + const queryParams: Record = {}; + let body: Record | undefined; + + switch (paramSource) { + case "path": + pathParams[paramName] = identifier; + break; + case "query": + queryParams[paramName] = identifier; + break; + case "body": + body = { [paramName]: identifier }; + break; + } + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams, + queryParams, + body, + }; + + const result = await executeValidation(context); + + // Key property: result passes and resolvedEntities contains the full entity record + expect(result.pass).toBe(true); + + if (result.pass) { + // resolvedEntities must be keyed by the entityLabel + expect(result.resolvedEntities).toHaveProperty(entityLabel); + // The resolved entity must be the full database record + expect(result.resolvedEntities[entityLabel]).toEqual(fullEntity); + } + + // Cleanup + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + mockDbData[api] = {}; + }, + ), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Feature: deterministic-validation-system + * Property 16: Pipeline accumulates resolvedEntities and passes them to handlers + * + * For any validation pipeline containing one or more entity existence rules that all pass, + * the final pass result SHALL contain a `resolvedEntities` map that aggregates entries from + * every passing entity existence rule. Additionally, each rule handler in the pipeline SHALL + * receive the accumulated `resolvedEntities` map (containing entries from all prior passing + * entity existence rules) as a parameter. If the pipeline contains no entity existence rules + * or none resolve an entity, the `resolvedEntities` map SHALL be empty (not omitted). + * + * **Validates: Requirements 10.3, 10.4, 10.5, 10.6** + */ +import { registerRuleHandler } from "../../src/service/validationEngine.js"; +import { ValidationPass } from "../../src/validation/validationTypes.js"; +import { buildKey } from "../../src/registry/operationRegistry.js"; + +describe("Feature: deterministic-validation-system, Property 16: Pipeline accumulates resolvedEntities and passes them to handlers", () => { + const TEST_OPERATION_ID = "__test_pipeline_resolvedEntities_prop16__"; + + beforeEach(() => { + // Reset all API partitions in mock database + for (const api of Object.values(Api)) { + mockDbData[api] = {}; + } + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + it("Property 16: Final result aggregates resolvedEntities from all passing entity existence rules", async () => { + // Generate 2-4 distinct entity labels, each with unique entity data + const entityCountArb = fc.integer({ min: 2, max: 4 }); + const identifierArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{2,10}$/); + + await fc.assert( + fc.asyncProperty( + entityCountArb, + fc.array(identifierArb, { minLength: 4, maxLength: 4 }), + async (entityCount, identifiers) => { + // Use unique labels and param names for each entity existence rule + const labels: string[] = []; + const paramNames: string[] = []; + const entityIds: string[] = []; + + for (let i = 0; i < entityCount; i++) { + labels.push(`entity_label_${i}`); + paramNames.push(`entityId_${i}`); + entityIds.push(`${identifiers[i]}_${i}`); + } + + // Ensure all labels are unique + fc.pre(new Set(labels).size === labels.length); + fc.pre(new Set(paramNames).size === paramNames.length); + + // Build entity existence rules for each entity + const pipeline: EntityExistenceRule[] = labels.map((label, idx) => ({ + checkType: "entityExistence" as const, + entity: { + api: Api.ORDERS, + paramName: paramNames[idx], + paramSource: "path" as const, + entityLabel: label, + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: `${label} not found`, + }, + })); + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), pipeline as ValidationPipeline); + + // Populate the database with entities that match the identifiers + const expectedEntities: Record> = {}; + for (let i = 0; i < entityCount; i++) { + const entityData: Record = { + [paramNames[i]]: entityIds[i], + someField: `value_${i}`, + index: i, + }; + mockDbData[Api.ORDERS] = mockDbData[Api.ORDERS] ?? {}; + (mockDbData[Api.ORDERS] as Record)[entityIds[i]] = entityData; + expectedEntities[labels[i]] = entityData; + } + + // Build path params with all entity identifiers + const pathParams: Record = {}; + for (let i = 0; i < entityCount; i++) { + pathParams[paramNames[i]] = entityIds[i]; + } + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + + // All rules pass → result should be pass with aggregated resolvedEntities + expect(result.pass).toBe(true); + + const passResult = result as ValidationPass; + expect(passResult.resolvedEntities).toBeDefined(); + + // Verify all entity labels are present in the result's resolvedEntities + for (let i = 0; i < entityCount; i++) { + expect(passResult.resolvedEntities[labels[i]]).toBeDefined(); + expect(passResult.resolvedEntities[labels[i]][paramNames[i]]).toBe(entityIds[i]); + } + + // Cleanup + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + mockDbData[Api.ORDERS] = {}; + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 16: Each subsequent handler receives accumulated resolvedEntities from prior rules", async () => { + // Strategy: Use a custom handler that captures the resolvedEntities it receives. + // Place entity existence rules before it in the pipeline. Verify the custom handler + // sees all entities resolved by prior entityExistence rules. + + const CUSTOM_CHECK_TYPE = "__test_custom_spy_prop16__"; + const capturedEntitiesList: Record>[] = []; + + // Register a custom spy handler that captures the resolvedEntities parameter + registerRuleHandler(CUSTOM_CHECK_TYPE, async (_rule, _context, resolvedEntities) => { + capturedEntitiesList.push({ ...resolvedEntities }); + return { pass: true, resolvedEntities: {} }; + }); + + const entityCountArb = fc.integer({ min: 1, max: 3 }); + const identifierArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{2,10}$/); + + await fc.assert( + fc.asyncProperty( + entityCountArb, + fc.array(identifierArb, { minLength: 3, maxLength: 3 }), + async (entityCount, identifiers) => { + capturedEntitiesList.length = 0; + + const labels: string[] = []; + const paramNames: string[] = []; + const entityIds: string[] = []; + + for (let i = 0; i < entityCount; i++) { + labels.push(`spy_entity_${i}`); + paramNames.push(`spyId_${i}`); + entityIds.push(`${identifiers[i]}_${i}`); + } + + fc.pre(new Set(labels).size === labels.length); + fc.pre(new Set(paramNames).size === paramNames.length); + + // Build pipeline: entityExistence rules followed by a spy handler + const entityRules: EntityExistenceRule[] = labels.map((label, idx) => ({ + checkType: "entityExistence" as const, + entity: { + api: Api.ORDERS, + paramName: paramNames[idx], + paramSource: "path" as const, + entityLabel: label, + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: `${label} not found`, + }, + })); + + // Add the spy rule at the end of the pipeline + const spyRule = { + checkType: CUSTOM_CHECK_TYPE, + failAction: { + statusCode: 400, + code: "Spy", + message: "Spy rule", + }, + }; + + const pipeline = [...entityRules, spyRule] as ValidationPipeline; + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), pipeline); + + // Populate database with entities + mockDbData[Api.ORDERS] = {}; + for (let i = 0; i < entityCount; i++) { + const entityData: Record = { + [paramNames[i]]: entityIds[i], + data: `data_${i}`, + }; + (mockDbData[Api.ORDERS] as Record)[entityIds[i]] = entityData; + } + + // Build path params + const pathParams: Record = {}; + for (let i = 0; i < entityCount; i++) { + pathParams[paramNames[i]] = entityIds[i]; + } + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + + // The spy handler should have been called once + expect(capturedEntitiesList.length).toBe(1); + + // The captured resolvedEntities should contain ALL entity labels from prior rules + const captured = capturedEntitiesList[0]; + for (let i = 0; i < entityCount; i++) { + expect(captured[labels[i]]).toBeDefined(); + expect(captured[labels[i]][paramNames[i]]).toBe(entityIds[i]); + } + + // Cleanup + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + mockDbData[Api.ORDERS] = {}; + capturedEntitiesList.length = 0; + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 16: Pipelines with no entity existence rules return empty resolvedEntities map (not absent)", async () => { + // Generate pipelines with only parameter constraint rules (no entity existence) + const paramCountArb = fc.integer({ min: 1, max: 4 }); + + await fc.assert( + fc.asyncProperty(paramCountArb, async (paramCount) => { + // Build a pipeline with only atLeastOneRequired rules (all will pass) + const paramNames: string[] = []; + for (let i = 0; i < paramCount; i++) { + paramNames.push(`nonEntityParam_${i}`); + } + + const pipeline: AtLeastOneRequiredRule[] = paramNames.map((name) => ({ + checkType: "atLeastOneRequired" as const, + params: [{ name, source: "query" as const }], + failAction: { + statusCode: 400, + code: "InvalidInput", + message: `At least one of '${name}' must be provided`, + }, + })); + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), pipeline as ValidationPipeline); + + // Provide all params so every rule passes + const queryParams: Record = {}; + for (const name of paramNames) { + queryParams[name] = "present-value"; + } + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: {}, + queryParams, + body: undefined, + }; + + const result = await executeValidation(context); + + // Should pass with empty resolvedEntities (not absent/undefined) + expect(result.pass).toBe(true); + + const passResult = result as ValidationPass; + expect(passResult.resolvedEntities).toBeDefined(); + expect(passResult.resolvedEntities).toEqual({}); + + // Cleanup + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Feature: deterministic-validation-system + * Property 17: Last-write-wins for duplicate entity labels + * + * For any validation pipeline containing two entity existence rules that both pass + * and produce a resolved entity with the same label key, the final `resolvedEntities` + * map SHALL contain only the entity data from the later (higher array-index) rule, + * overwriting the earlier entry. + * + * **Validates: Requirements 10.7** + */ +describe("Feature: deterministic-validation-system, Property 17: Last-write-wins for duplicate entity labels", () => { + const TEST_OPERATION_ID = "__test_last_write_wins_prop17__"; + + beforeEach(() => { + for (const api of Object.values(Api)) { + mockDbData[api] = {}; + } + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + }); + + it("Property 17: When two entity existence rules use the same entityLabel, the final resolvedEntities contains only the later rule's entity data", async () => { + // Generate two distinct identifiers and entity data for the same label + const identifierArb = fc.stringMatching(/^[a-zA-Z0-9]{3,15}$/); + const entityLabelArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/); + const extraFieldValueArb = fc.oneof(fc.string({ minLength: 1, maxLength: 15 }), fc.integer(), fc.boolean()); + + await fc.assert( + fc.asyncProperty( + entityLabelArb, // shared entityLabel for both rules + identifierArb, // first entity identifier + identifierArb, // second entity identifier + extraFieldValueArb, // extra field value for first entity + extraFieldValueArb, // extra field value for second entity + async (sharedLabel, firstId, secondId, firstExtra, secondExtra) => { + // Ensure the two identifiers are different so we get two distinct entities + fc.pre(firstId !== secondId); + + // Use different paramNames so the rules look up different identifiers + const firstParamName = "firstEntityId"; + const secondParamName = "secondEntityId"; + + // Build two entity existence rules with the SAME entityLabel + const rule1: EntityExistenceRule = { + checkType: "entityExistence", + entity: { + api: Api.ORDERS, + paramName: firstParamName, + paramSource: "path", + entityLabel: sharedLabel, + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: `${sharedLabel} not found`, + }, + }; + + const rule2: EntityExistenceRule = { + checkType: "entityExistence", + entity: { + api: Api.ORDERS, + paramName: secondParamName, + paramSource: "path", + entityLabel: sharedLabel, + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: `${sharedLabel} not found`, + }, + }; + + // Register pipeline with rule1 first, then rule2 (later in array) + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", TEST_OPERATION_ID), [rule1, rule2] as ValidationPipeline); + + // Create two distinct entities in the database + const firstEntity: Record = { + [firstParamName]: firstId, + extraField: firstExtra, + source: "first_rule", + }; + + const secondEntity: Record = { + [secondParamName]: secondId, + extraField: secondExtra, + source: "second_rule", + }; + + mockDbData[Api.ORDERS] = { + [firstId]: firstEntity, + [secondId]: secondEntity, + }; + + // Build request context with both identifiers + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId: TEST_OPERATION_ID, + method: "GET", + pathParams: { + [firstParamName]: firstId, + [secondParamName]: secondId, + }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + + // Both rules should pass since both entities exist + expect(result.pass).toBe(true); + + if (result.pass) { + // Key property: the resolvedEntities map should contain ONLY the second entity's data + // under the shared label (last-write-wins) + expect(result.resolvedEntities).toHaveProperty(sharedLabel); + expect(result.resolvedEntities[sharedLabel]).toEqual(secondEntity); + + // Verify it does NOT contain the first entity's data + expect(result.resolvedEntities[sharedLabel]).not.toEqual(firstEntity); + + // Only one entry should be in the map for this label + const labelEntries = Object.keys(result.resolvedEntities).filter((k) => k === sharedLabel); + expect(labelEntries).toHaveLength(1); + } + + // Cleanup + VALIDATION_REGISTRY.delete(buildKey("TestApi", "v1", TEST_OPERATION_ID)); + mockDbData[Api.ORDERS] = {}; + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/service/validationEngine.schemaStage.prop.test.ts b/local-ai-sandbox/test/service/validationEngine.schemaStage.prop.test.ts new file mode 100644 index 000000000..e6833b7b4 --- /dev/null +++ b/local-ai-sandbox/test/service/validationEngine.schemaStage.prop.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; +import { Request } from "express"; + +/** + * Property 19: Schema_Validation_Stage executes before pipeline rules + * + * For any incoming request (valid or invalid against the OpenAPI schema), + * the Schema_Validation_Stage SHALL execute to completion before any + * Validation_Pipeline rule is evaluated. If schema validation fails, + * no pipeline rules are executed. + * + * **Validates: Requirements 12.1, 12.12** + */ + +// Mock openapi-enforcer to control schema validation results +const mockEnforcerRequest = vi.fn(); +vi.mock("openapi-enforcer", () => { + return { + default: vi.fn().mockImplementation(async () => ({ + request: mockEnforcerRequest, + })), + }; +}); + +// Mock api schema identification service +const mockIdentifyApiModel = vi.fn(); +const mockIdentifyApiName = vi.fn(); +const mockIdentifyApiVersion = vi.fn(); +vi.mock("../../src/service/apiSchemaIdentificationService.js", () => ({ + identifyApiModel: (...args: unknown[]) => mockIdentifyApiModel(...args), + identifyApiName: (...args: unknown[]) => mockIdentifyApiName(...args), + identifyApiVersion: (...args: unknown[]) => mockIdentifyApiVersion(...args), +})); + +// Mock validation registry to track pipeline rule execution +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + VALIDATION_REGISTRY: new Map(), + }; +}); + +// Mock the Context singleton with all API partitions initialized as empty +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { + get instance() { + return { + engine: { + get: () => null, + }, + }; + }, + }, + }; +}); + +import { validateRequest } from "../../src/service/validationEngine.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; +import { ValidationPipeline } from "../../src/validation/validationTypes.js"; + +describe("Feature: deterministic-validation-system, Property 19: Schema_Validation_Stage executes before pipeline rules", () => { + beforeEach(() => { + vi.clearAllMocks(); + VALIDATION_REGISTRY.clear(); + }); + + it("Property 19a: When schema validation fails (invalid schema), no pipeline rules are evaluated and result is a schema-level error", async () => { + // Arbitrary: generate invalid request scenarios + const invalidSchemaArb = fc.record({ + path: fc.constantFrom( + "/orders/v0/orders/123", + "/listings/2021-08-01/items/SELLER123/ABC-SKU", + "/catalog/2022-04-01/items", + ), + method: fc.constantFrom("GET", "POST", "PUT", "DELETE"), + errorMessage: fc.string({ minLength: 5, maxLength: 100 }), + }); + + await fc.assert( + fc.asyncProperty(invalidSchemaArb, async ({ path, method, errorMessage }) => { + // Configure mocks: model is found (path is recognized) but schema validation fails + mockIdentifyApiModel.mockReturnValue("someModel.json"); + mockIdentifyApiName.mockReturnValue("Orders"); + mockIdentifyApiVersion.mockReturnValue("v0"); + + // Enforcer returns an error (schema validation failure) + mockEnforcerRequest.mockReturnValue([undefined, { toString: () => errorMessage }]); + + // Register a pipeline rule that tracks if it's ever evaluated + const spyRule = { + checkType: "entityExistence", + entity: { api: "orders", paramName: "orderId", paramSource: "path", entityLabel: "order" }, + failAction: { statusCode: 404, code: "NotFound", message: "Order not found" }, + }; + VALIDATION_REGISTRY.set("Orders:v0:getOrder", [spyRule] as unknown as ValidationPipeline); + + // Build a mock Express request + const mockRequest = { + path, + method, + query: {}, + headers: {}, + body: undefined, + } as unknown as Request; + + const result = await validateRequest(mockRequest); + + // Schema validation should have failed + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(400); + // The body should contain the schema error + expect(result.body).toBeDefined(); + expect(result.body).toHaveProperty("errors"); + } + }), + { numRuns: 50 }, + ); + }); + + it("Property 19b: When schema validation passes but pipeline rules fail, schema passes first then pipeline rules execute", async () => { + // Arbitrary: generate valid schema scenarios with pipeline rules that fail + const validSchemaArb = fc.record({ + path: fc.constantFrom( + "/orders/v0/orders/123", + "/orders/v0/orders/456", + "/orders/v0/orders/789", + ), + method: fc.constantFrom("GET", "DELETE"), + orderId: fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0), + pipelineErrorMessage: fc.string({ minLength: 3, maxLength: 50 }), + }); + + await fc.assert( + fc.asyncProperty(validSchemaArb, async ({ path, method, orderId, pipelineErrorMessage }) => { + // Configure mocks: schema validation passes + mockIdentifyApiModel.mockReturnValue("ordersV0.json"); + mockIdentifyApiName.mockReturnValue("Orders"); + mockIdentifyApiVersion.mockReturnValue("v0"); + + // Enforcer returns success with operationId, path params, query params + mockEnforcerRequest.mockReturnValue([ + { + operation: { operationId: "getOrder" }, + path: { orderId }, + query: {}, + }, + undefined, + ]); + + // Register a pipeline rule that will FAIL (to confirm pipeline runs after schema passes) + const failingRule = { + checkType: "entityExistence", + entity: { api: "orders", paramName: "orderId", paramSource: "path", entityLabel: "order" }, + failAction: { statusCode: 404, code: "NotFound", message: pipelineErrorMessage }, + }; + VALIDATION_REGISTRY.set("Orders:v0:getOrder", [failingRule] as unknown as ValidationPipeline); + + // Build a mock Express request + const mockRequest = { + path, + method, + query: {}, + headers: {}, + body: undefined, + } as unknown as Request; + + const result = await validateRequest(mockRequest); + + // The result should be a pipeline failure (NOT a schema failure) + // This proves schema passed first, then pipeline rules ran and failed + expect(result.pass).toBe(false); + if (!result.pass) { + // Pipeline rules produce 404 with structured error body (array of errors) + expect(result.statusCode).toBe(404); + expect(result.body).toBeDefined(); + // Pipeline errors have a body with errors array containing objects with code and message + if (result.body && "errors" in result.body && Array.isArray(result.body.errors)) { + expect(result.body.errors[0]).toHaveProperty("code", "NotFound"); + expect(result.body.errors[0]).toHaveProperty("message"); + } + } + }), + { numRuns: 50 }, + ); + }); + + it("Property 19c: When path is not recognized (404), pipeline rules are never evaluated", async () => { + // Arbitrary: generate arbitrary unrecognized paths + const unrecognizedPathArb = fc.record({ + path: fc.constantFrom( + "/unknown/api/endpoint", + "/v1/nonexistent/path", + "/some/random/route", + "/api/v2/resources/123", + ), + method: fc.constantFrom("GET", "POST", "PUT", "DELETE", "PATCH"), + }); + + await fc.assert( + fc.asyncProperty(unrecognizedPathArb, async ({ path, method }) => { + // Configure mocks: path is not recognized + mockIdentifyApiModel.mockReturnValue(undefined); + + // Register a pipeline rule that should NEVER be reached + const spyRule = { + checkType: "entityExistence", + entity: { api: "orders", paramName: "id", paramSource: "path", entityLabel: "entity" }, + failAction: { statusCode: 404, code: "NotFound", message: "Should never see this" }, + }; + VALIDATION_REGISTRY.set("SomeApi:v1:someOp", [spyRule] as unknown as ValidationPipeline); + + // Build a mock Express request + const mockRequest = { + path, + method, + query: {}, + headers: {}, + body: undefined, + } as unknown as Request; + + const result = await validateRequest(mockRequest); + + // Should return 404 (unrecognized path) WITHOUT body + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(404); + // Unrecognized path returns no body (undefined) + expect(result.body).toBeUndefined(); + } + + // The enforcer request mock should NOT have been called since the model was not identified + expect(mockEnforcerRequest).not.toHaveBeenCalled(); + }), + { numRuns: 50 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/service/validationEngine.schemaValidation.test.ts b/local-ai-sandbox/test/service/validationEngine.schemaValidation.test.ts new file mode 100644 index 000000000..7042d3470 --- /dev/null +++ b/local-ai-sandbox/test/service/validationEngine.schemaValidation.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Request } from "express"; + +// Mock openapi-enforcer +const mockEnforcerRequest = vi.fn(); +const mockEnforcer = vi.fn(); +vi.mock("openapi-enforcer", () => ({ + default: (...args: unknown[]) => mockEnforcer(...args), +})); + +// Mock apiSchemaIdentificationService +const mockIdentifyApiModel = vi.fn(); +const mockIdentifyApiName = vi.fn(); +const mockIdentifyApiVersion = vi.fn(); +vi.mock("../../src/service/apiSchemaIdentificationService.js", () => ({ + identifyApiModel: (...args: unknown[]) => mockIdentifyApiModel(...args), + identifyApiName: (...args: unknown[]) => mockIdentifyApiName(...args), + identifyApiVersion: (...args: unknown[]) => mockIdentifyApiVersion(...args), +})); + +// Mock the validation registry to return empty pipeline (isolate schema validation behavior) +vi.mock("../../src/validation/validationRegistry.js", () => { + const emptyPipelineMap = new Map(); + return { + buildValidationKey: (apiName: string, apiVersion: string, operationId: string) => `${apiName}:${apiVersion}:${operationId}`, + VALIDATION_REGISTRY: new Proxy(emptyPipelineMap, { + get(target, prop) { + if (prop === "get") return () => []; + return Reflect.get(target, prop); + }, + }), + }; +}); + +// Mock the Context singleton +vi.mock("../../src/database/Context.js", () => ({ + Context: { + get instance() { + return { db: { data: {} } }; + }, + }, + Api: {}, +})); + +import { validateRequest } from "../../src/service/validationEngine.js"; + +function createMockRequest(overrides: Partial = {}): Request { + return { + method: "GET", + path: "/orders/v0/orders/123-456", + query: {}, + headers: {}, + body: undefined, + ...overrides, + } as unknown as Request; +} + +describe("Schema Validation (performSchemaValidation via validateRequest)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("valid path recognition and pass result", () => { + it("returns pass with operationId, apiName, apiVersion, pathParams for /orders/v0/orders/123-456", async () => { + mockIdentifyApiModel.mockReturnValue("ordersV0.json"); + mockIdentifyApiName.mockReturnValue("Orders"); + mockIdentifyApiVersion.mockReturnValue("v0"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + mockEnforcerRequest.mockReturnValue([ + { + operation: { operationId: "getOrder" }, + path: { orderId: "123-456" }, + query: {}, + }, + undefined, + ]); + + const request = createMockRequest({ path: "/orders/v0/orders/123-456" }); + const result = await validateRequest(request); + + expect(result.pass).toBe(true); + if (result.pass) { + expect(result.operationId).toBe("getOrder"); + expect(result.apiName).toBe("Orders"); + expect(result.apiVersion).toBe("v0"); + expect(result.pathParams).toEqual({ orderId: "123-456" }); + } + }); + }); + + describe("unrecognized path returns 404 with no errors", () => { + it("returns { pass: false, statusCode: 404 } with no body for /unknown/endpoint", async () => { + mockIdentifyApiModel.mockReturnValue(undefined); + + const request = createMockRequest({ path: "/unknown/endpoint" }); + const result = await validateRequest(request); + + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(404); + expect(result.body).toBeUndefined(); + } + }); + }); + + describe("valid path but invalid query params returns 400 with errors", () => { + it("returns { pass: false, statusCode: 400, errors: { errors: } }", async () => { + mockIdentifyApiModel.mockReturnValue("ordersV0.json"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + + const mockError = { + toString: () => "Request has one or more errors:\n Invalid query parameter 'marketplaceIds'", + }; + mockEnforcerRequest.mockReturnValue([undefined, mockError]); + + const request = createMockRequest({ + path: "/orders/v0/orders/123-456", + query: { invalidParam: "bad" }, + }); + const result = await validateRequest(request); + + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(400); + expect(result.body).toEqual({ + errors: [{ code: "SchemaValidationError", message: expect.stringContaining("Request has one or more errors") }], + }); + } + }); + }); + + describe("GET request does not include body in enforcer call", () => { + it("calls enforcer.request without body field for GET", async () => { + mockIdentifyApiModel.mockReturnValue("ordersV0.json"); + mockIdentifyApiName.mockReturnValue("Orders"); + mockIdentifyApiVersion.mockReturnValue("v0"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + mockEnforcerRequest.mockReturnValue([ + { operation: { operationId: "getOrder" }, path: {}, query: {} }, + undefined, + ]); + + const request = createMockRequest({ + method: "GET", + path: "/orders/v0/orders/123-456", + body: { someField: "should not be passed" }, + }); + await validateRequest(request); + + expect(mockEnforcerRequest).toHaveBeenCalledTimes(1); + const callArgs = mockEnforcerRequest.mock.calls[0][0]; + expect(callArgs).not.toHaveProperty("body"); + expect(callArgs.method).toBe("GET"); + }); + }); + + describe("DELETE request does not include body in enforcer call", () => { + it("calls enforcer.request without body field for DELETE", async () => { + mockIdentifyApiModel.mockReturnValue("ordersV0.json"); + mockIdentifyApiName.mockReturnValue("Orders"); + mockIdentifyApiVersion.mockReturnValue("v0"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + mockEnforcerRequest.mockReturnValue([ + { operation: { operationId: "deleteOrder" }, path: {}, query: {} }, + undefined, + ]); + + const request = createMockRequest({ + method: "DELETE", + path: "/orders/v0/orders/123-456", + body: { someField: "should not be passed" }, + }); + await validateRequest(request); + + expect(mockEnforcerRequest).toHaveBeenCalledTimes(1); + const callArgs = mockEnforcerRequest.mock.calls[0][0]; + expect(callArgs).not.toHaveProperty("body"); + expect(callArgs.method).toBe("DELETE"); + }); + }); + + describe("POST request includes body in enforcer call", () => { + it("calls enforcer.request with body field for POST", async () => { + mockIdentifyApiModel.mockReturnValue("ordersV0.json"); + mockIdentifyApiName.mockReturnValue("Orders"); + mockIdentifyApiVersion.mockReturnValue("v0"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + mockEnforcerRequest.mockReturnValue([ + { operation: { operationId: "confirmShipment" }, path: {}, query: {} }, + undefined, + ]); + + const requestBody = { packageDetail: { trackingNumber: "1Z999" } }; + const request = createMockRequest({ + method: "POST", + path: "/orders/v0/orders/123-456/shipment/confirm", + body: requestBody, + }); + await validateRequest(request); + + expect(mockEnforcerRequest).toHaveBeenCalledTimes(1); + const callArgs = mockEnforcerRequest.mock.calls[0][0]; + expect(callArgs).toHaveProperty("body"); + expect(callArgs.body).toEqual(requestBody); + expect(callArgs.method).toBe("POST"); + }); + }); + + describe("WSCH006 exception code is suppressed during enforcer loading", () => { + it("passes exceptionSkipCodes containing WSCH006 to Enforcer", async () => { + mockIdentifyApiModel.mockReturnValue("ordersV0.json"); + mockIdentifyApiName.mockReturnValue("Orders"); + mockIdentifyApiVersion.mockReturnValue("v0"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + mockEnforcerRequest.mockReturnValue([ + { operation: { operationId: "getOrder" }, path: {}, query: {} }, + undefined, + ]); + + const request = createMockRequest({ path: "/orders/v0/orders/123-456" }); + await validateRequest(request); + + expect(mockEnforcer).toHaveBeenCalledWith("./res/models/ordersV0.json", { + componentOptions: { + exceptionSkipCodes: ["WSCH006"], + }, + }); + }); + }); + + describe("apiName/apiVersion derivation for multiple paths", () => { + it('/listings/2021-08-01/items/... → apiName "Listings", apiVersion "2021-08-01"', async () => { + mockIdentifyApiModel.mockReturnValue("listingsItems_2021-08-01.json"); + mockIdentifyApiName.mockReturnValue("Listings"); + mockIdentifyApiVersion.mockReturnValue("2021-08-01"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + mockEnforcerRequest.mockReturnValue([ + { operation: { operationId: "getListingsItem" }, path: { sellerId: "SELLER1", sku: "SKU123" }, query: { marketplaceIds: ["ATVPDKIKX0DER"] } }, + undefined, + ]); + + const request = createMockRequest({ + path: "/listings/2021-08-01/items/SELLER1/SKU123", + query: { marketplaceIds: "ATVPDKIKX0DER" }, + }); + const result = await validateRequest(request); + + expect(result.pass).toBe(true); + if (result.pass) { + expect(result.apiName).toBe("Listings"); + expect(result.apiVersion).toBe("2021-08-01"); + } + }); + + it('/catalog/2022-04-01/items → apiName "Catalog Items", apiVersion "2022-04-01"', async () => { + mockIdentifyApiModel.mockReturnValue("catalogItems_2022-04-01.json"); + mockIdentifyApiName.mockReturnValue("Catalog Items"); + mockIdentifyApiVersion.mockReturnValue("2022-04-01"); + + const enforcerInstance = { request: mockEnforcerRequest }; + mockEnforcer.mockResolvedValue(enforcerInstance); + mockEnforcerRequest.mockReturnValue([ + { operation: { operationId: "searchCatalogItems" }, path: {}, query: { keywords: ["laptop"], marketplaceIds: ["ATVPDKIKX0DER"] } }, + undefined, + ]); + + const request = createMockRequest({ + path: "/catalog/2022-04-01/items", + query: { keywords: "laptop", marketplaceIds: "ATVPDKIKX0DER" }, + }); + const result = await validateRequest(request); + + expect(result.pass).toBe(true); + if (result.pass) { + expect(result.apiName).toBe("Catalog Items"); + expect(result.apiVersion).toBe("2022-04-01"); + } + }); + }); +}); diff --git a/local-ai-sandbox/test/service/validationEngine.schemaViolation.prop.test.ts b/local-ai-sandbox/test/service/validationEngine.schemaViolation.prop.test.ts new file mode 100644 index 000000000..a7c6fc767 --- /dev/null +++ b/local-ai-sandbox/test/service/validationEngine.schemaViolation.prop.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fc from "fast-check"; +import type { Request } from "express"; + +/** + * Property 21: Schema violation returns 400 with error details + * + * Generate requests that match a known SP-API model path but violate the OpenAPI schema + * (invalid query params, missing required params, invalid body); verify the result is + * `{ pass: false, statusCode: 400, body: { errors: } }` + * + * **Validates: Requirements 12.4** + */ + +// Mock openapi-enforcer to simulate schema validation failures +const mockEnforcerRequest = vi.fn(); +vi.mock("openapi-enforcer", () => ({ + default: vi.fn(() => + Promise.resolve({ + request: mockEnforcerRequest, + }), + ), +})); + +// Mock apiSchemaIdentificationService to return a model file name (simulating a recognized path) +vi.mock("../../src/service/apiSchemaIdentificationService.js", () => ({ + identifyApiModel: vi.fn(() => "ordersV0.json"), + identifyApiName: vi.fn(() => "Orders"), + identifyApiVersion: vi.fn(() => "v0"), +})); + +// Mock the validation registry so pipeline lookup returns no rules (we only test schema stage) +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + VALIDATION_REGISTRY: new Map(), + }; +}); + +describe("Feature: deterministic-validation-system, Property 21: Schema violation returns 400 with error details", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("Property 21: Schema violation always returns pass:false, statusCode:400, body with non-empty errors string", async () => { + // Dynamically import validateRequest after mocks are set up + const { validateRequest } = await import("../../src/service/validationEngine.js"); + + await fc.assert( + fc.asyncProperty( + // Generate arbitrary non-empty error strings to verify they're passed through + fc.string({ minLength: 1, maxLength: 200 }), + // Generate an HTTP method + fc.constantFrom("GET", "POST", "PUT", "DELETE", "PATCH"), + // Generate a path that would match a known SP-API model + fc.constantFrom("/orders/v0/orders/123-456", "/orders/v0/orders", "/orders/v0/orders/abc/shipment"), + async (errorMessage, method, path) => { + // Set up the enforcer mock to return a schema validation error + const errorObject = { + toString: () => errorMessage, + }; + mockEnforcerRequest.mockReturnValue([undefined, errorObject]); + + // Build a minimal Express-like request object + const request = { + method, + path, + query: {}, + headers: {}, + body: {}, + } as unknown as Request; + + const result = await validateRequest(request); + + // Verify the result structure + expect(result.pass).toBe(false); + + if (!result.pass) { + expect(result.statusCode).toBe(400); + expect(result.body).toBeDefined(); + expect(result.body).toHaveProperty("errors"); + + const body = result.body as { errors: Array<{ code: string; message: string }> }; + expect(Array.isArray(body.errors)).toBe(true); + expect(body.errors.length).toBeGreaterThan(0); + expect(body.errors[0].code).toBe("SchemaValidationError"); + if (errorMessage.trim().length > 0) expect(body.errors[0].message.length).toBeGreaterThan(0); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it("Property 21: The error string from enforcer is passed through (normalized) in the response body", async () => { + const { validateRequest } = await import("../../src/service/validationEngine.js"); + + await fc.assert( + fc.asyncProperty( + // Generate error strings with various whitespace patterns to test normalization + fc.string({ minLength: 1, maxLength: 100 }).filter((s) => s.trim().length > 0), + async (errorMessage) => { + const errorObject = { + toString: () => errorMessage, + }; + mockEnforcerRequest.mockReturnValue([undefined, errorObject]); + + const request = { + method: "GET", + path: "/orders/v0/orders/test-id", + query: {}, + headers: {}, + body: {}, + } as unknown as Request; + + const result = await validateRequest(request); + + expect(result.pass).toBe(false); + + if (!result.pass) { + expect(result.statusCode).toBe(400); + + const body = result.body as { errors: Array<{ code: string; message: string }> }; + // The engine normalizes whitespace: replaces \s+ with single space and trims + const expectedNormalized = errorMessage.replace(/\s+/g, " ").trim(); + expect(body.errors[0].message).toBe(expectedNormalized); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/service/validationEngine.test.ts b/local-ai-sandbox/test/service/validationEngine.test.ts new file mode 100644 index 000000000..36ecc4970 --- /dev/null +++ b/local-ai-sandbox/test/service/validationEngine.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { executeValidation, registerRuleHandler } from "../../src/service/validationEngine.js"; +import { RequestContext, ValidationFail, ValidationPipeline, ValidationResult, ValidationRule } from "../../src/validation/validationTypes.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; +import { buildKey } from "../../src/registry/operationRegistry.js"; + +// Mock the validation registry so we can inject test pipelines +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + VALIDATION_REGISTRY: new Map(), + }; +}); + +// Mock the Context singleton so database access uses a controlled mock +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { + get instance() { + return { + db: { + data: {}, + }, + }; + }, + }, + }; +}); + +describe("Validation Engine Pipeline Execution", () => { + beforeEach(() => { + VALIDATION_REGISTRY.clear(); + }); + + it("empty pipeline returns pass", async () => { + const operationId = "emptyPipelineOp"; + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", operationId), [] as ValidationPipeline); + + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result).toEqual({ pass: true, resolvedEntities: {} }); + }); + + it("unregistered operationId returns failure", async () => { + const context: RequestContext = { + apiName: "UnknownApi", + apiVersion: "v99", + operationId: "nonExistentOperation_xyz_12345", + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(501); + expect(result.body?.errors[0].code).toBe("NoValidationPipeline"); + expect(result.body?.errors[0].message).toContain("UnknownApi:v99:nonExistentOperation_xyz_12345"); + } + }); + + it("executes rules in order and short-circuits on first failure", async () => { + const operationId = "shortCircuitOp"; + + // Create 3 atLeastOneRequired rules with unique param names per rule + const pipeline: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [{ name: "paramA", source: "query" }], + failAction: { statusCode: 400, code: "MissingA", message: "paramA is required" }, + }, + { + checkType: "atLeastOneRequired", + params: [{ name: "paramB", source: "query" }], + failAction: { statusCode: 400, code: "MissingB", message: "paramB is required" }, + }, + { + checkType: "atLeastOneRequired", + params: [{ name: "paramC", source: "query" }], + failAction: { statusCode: 400, code: "MissingC", message: "paramC is required" }, + }, + ]; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", operationId), pipeline); + + // Provide paramA and paramB but NOT paramC — rule at index 2 should fail + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId, + method: "GET", + pathParams: {}, + queryParams: { paramA: "value1", paramB: "value2" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + + const failResult = result as ValidationFail; + expect(failResult.statusCode).toBe(400); + expect(failResult.body.errors[0].code).toBe("MissingC"); + expect(failResult.body.errors[0].message).toContain("paramC"); + }); + + it("custom handler registration via registerRuleHandler", async () => { + const operationId = "customHandlerOp"; + const customCheckType = "customCheck"; + + // Register a custom handler that passes if body has a "token" field + const customHandler = vi.fn(async (rule: ValidationRule, context: RequestContext, _resolvedEntities: Record>): Promise => { + if (context.body && "token" in context.body) { + return { pass: true, resolvedEntities: {} }; + } + return { + pass: false, + statusCode: 401, + body: { errors: [{ code: "Unauthorized", message: "Token is required" }] }, + }; + }); + + registerRuleHandler(customCheckType, customHandler); + + // Create a pipeline that uses the custom check type + const pipeline: ValidationPipeline = [ + { + checkType: customCheckType, + failAction: { statusCode: 401, code: "Unauthorized", message: "Token is required" }, + } as unknown as ValidationRule, + ]; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", operationId), pipeline); + + // Test with token present — should pass + const contextWithToken: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId, + method: "POST", + pathParams: {}, + queryParams: {}, + body: { token: "abc123" }, + }; + + const passResult = await executeValidation(contextWithToken); + expect(passResult.pass).toBe(true); + expect(customHandler).toHaveBeenCalledTimes(1); + + // Test without token — should fail + const contextWithoutToken: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId, + method: "POST", + pathParams: {}, + queryParams: {}, + body: {}, + }; + + const failResult = await executeValidation(contextWithoutToken); + expect(failResult.pass).toBe(false); + expect(customHandler).toHaveBeenCalledTimes(2); + + if (!failResult.pass) { + expect(failResult.statusCode).toBe(401); + expect(failResult.body.errors[0].code).toBe("Unauthorized"); + } + }); + + it("all rules pass returns overall pass", async () => { + const operationId = "allPassOp"; + + // Create pipeline with 2 atLeastOneRequired rules + const pipeline: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [{ name: "alpha", source: "query" }], + failAction: { statusCode: 400, code: "MissingAlpha", message: "alpha is required" }, + }, + { + checkType: "atLeastOneRequired", + params: [{ name: "beta", source: "query" }], + failAction: { statusCode: 400, code: "MissingBeta", message: "beta is required" }, + }, + ]; + + VALIDATION_REGISTRY.set(buildKey("TestApi", "v1", operationId), pipeline); + + // Provide all required params + const context: RequestContext = { + apiName: "TestApi", + apiVersion: "v1", + operationId, + method: "GET", + pathParams: {}, + queryParams: { alpha: "val1", beta: "val2" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result).toEqual({ pass: true, resolvedEntities: {} }); + }); +}); diff --git a/local-ai-sandbox/test/service/validationEngine.unrecognizedPath.prop.test.ts b/local-ai-sandbox/test/service/validationEngine.unrecognizedPath.prop.test.ts new file mode 100644 index 000000000..14d35c597 --- /dev/null +++ b/local-ai-sandbox/test/service/validationEngine.unrecognizedPath.prop.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from "vitest"; +import fc from "fast-check"; +import { validateRequest } from "../../src/service/validationEngine.js"; +import { Request } from "express"; + +/** + * Mock openapi-enforcer — should never be reached for unrecognized paths + */ +vi.mock("openapi-enforcer", () => ({ + default: vi.fn().mockRejectedValue(new Error("openapi-enforcer should not be called for unrecognized paths")), +})); + +/** + * Mock apiSchemaIdentificationService to return undefined for identifyApiModel, + * simulating an unrecognized path that doesn't match any known SP-API model. + */ +vi.mock("../../src/service/apiSchemaIdentificationService.js", () => ({ + identifyApiModel: vi.fn().mockReturnValue(undefined), + identifyApiName: vi.fn().mockReturnValue(undefined), + identifyApiVersion: vi.fn().mockReturnValue(undefined), +})); + +/** + * Feature: deterministic-validation-system + * Property 20: Unrecognized path returns 404 without body + * + * For any request whose path does not match any known SP-API model file in the + * API schema identification service, the unified validation entry point SHALL + * return a fail result with HTTP 404 and no error body. + * + * **Validates: Requirements 12.3** + */ +describe("Feature: deterministic-validation-system, Property 20: Unrecognized path returns 404 without body", () => { + it("Property 20: Any unrecognized path returns { pass: false, statusCode: 404, body: undefined }", async () => { + // Generate arbitrary request paths that won't match any known SP-API model path prefix + const unrecognizedPathArb = fc.oneof( + // Random alphanumeric path segments + fc.array(fc.stringMatching(/^[a-zA-Z0-9_-]{1,20}$/), { minLength: 1, maxLength: 5 }).map((segments) => "/" + segments.join("/")), + // Paths that look API-like but aren't real SP-API paths + fc.tuple(fc.constantFrom("/random", "/unknown", "/fake", "/test", "/api", "/v1", "/v2", "/foo"), fc.stringMatching(/^\/[a-zA-Z0-9_-]{1,15}$/)).map( + ([prefix, suffix]) => prefix + suffix, + ), + // Single segment paths + fc.stringMatching(/^\/[a-zA-Z][a-zA-Z0-9_-]{0,30}$/).filter( + (path) => + !path.includes("/orders/") && + !path.includes("/listings/") && + !path.includes("/catalog/") && + !path.includes("/fba/") && + !path.includes("/externalFulfillment/") && + !path.includes("/batches/") && + !path.includes("/reports/"), + ), + ); + + const methodArb = fc.constantFrom("GET", "POST", "PUT", "DELETE", "PATCH"); + + await fc.assert( + fc.asyncProperty(unrecognizedPathArb, methodArb, async (path, method) => { + // Build a minimal Express-like request object + const mockRequest = { + path, + method, + query: {}, + headers: {}, + body: undefined, + } as unknown as Request; + + const result = await validateRequest(mockRequest); + + // The unified entry point should return a 404 fail with no body + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(404); + expect(result.body).toBeUndefined(); + } + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/tool/databaseLookupTool.test.ts b/local-ai-sandbox/test/tool/databaseLookupTool.test.ts deleted file mode 100644 index e71848efe..000000000 --- a/local-ai-sandbox/test/tool/databaseLookupTool.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { Context, Api } from "../../src/database/Context.js"; -import { databaseLookupCallback } from "../../src/tool/databaseLookupTool.js"; - -describe("databaseLookupTool", () => { - beforeEach(() => { - Context.instance.db.data.listings = {}; - }); - - describe("single id lookup", () => { - it("returns the item when it exists", async () => { - Context.instance.db.data.listings["SKU-1"] = { name: "Widget", price: 9.99 }; - const result = await databaseLookupCallback({ api: Api.LISTINGS, id: "SKU-1" }); - expect(JSON.parse(result)).toEqual({ name: "Widget", price: 9.99 }); - }); - - it('returns "No data found" for a missing id', async () => { - const result = await databaseLookupCallback({ api: Api.LISTINGS, id: "MISSING" }); - expect(result).toBe("No data found"); - }); - - it("returns seeded catalog data", async () => { - const result = await databaseLookupCallback({ api: Api.CATALOG, asin: "B0F4X2K9LM" }); - const parsed = JSON.parse(result); - expect(parsed.asin).toBe("B0F4X2K9LM"); - }); - }); - - describe("get all (no id)", () => { - it("returns all items for the api partition", async () => { - Context.instance.db.data.listings.A = { x: 1 }; - Context.instance.db.data.listings.B = { x: 2 }; - const result = await databaseLookupCallback({ api: Api.LISTINGS }); - const parsed = JSON.parse(result); - expect(Object.keys(parsed)).toEqual(["A", "B"]); - }); - - it("returns empty object when no data exists", async () => { - const result = await databaseLookupCallback({ api: Api.LISTINGS }); - expect(JSON.parse(result)).toEqual({}); - }); - }); - - describe("batch ids lookup", () => { - it("returns results for all requested ids", async () => { - Context.instance.db.data.listings["SKU-1"] = { name: "Widget" }; - Context.instance.db.data.listings["SKU-2"] = { name: "Gadget" }; - const result = await databaseLookupCallback({ api: Api.LISTINGS, ids: ["SKU-1", "SKU-2"] }); - const parsed = JSON.parse(result); - expect(parsed["SKU-1"]).toEqual({ name: "Widget" }); - expect(parsed["SKU-2"]).toEqual({ name: "Gadget" }); - }); - - it("returns null for missing ids in a batch", async () => { - Context.instance.db.data.listings["SKU-1"] = { name: "Widget" }; - const result = await databaseLookupCallback({ api: Api.LISTINGS, ids: ["SKU-1", "MISSING"] }); - const parsed = JSON.parse(result); - expect(parsed["SKU-1"]).toEqual({ name: "Widget" }); - expect(parsed.MISSING).toBeNull(); - }); - - it("returns all nulls when none exist", async () => { - const result = await databaseLookupCallback({ api: Api.LISTINGS, ids: ["A", "B", "C"] }); - const parsed = JSON.parse(result); - expect(parsed).toEqual({ A: null, B: null, C: null }); - }); - }); - - describe("fields filtering", () => { - it("returns only requested fields for single id", async () => { - Context.instance.db.data.listings["SKU-1"] = { name: "Widget", price: 9.99, color: "red", weight: 1.5 }; - const result = await databaseLookupCallback({ api: Api.LISTINGS, id: "SKU-1", fields: ["name", "price"] }); - const parsed = JSON.parse(result); - expect(parsed).toEqual({ name: "Widget", price: 9.99 }); - expect(parsed.color).toBeUndefined(); - }); - - it("returns null for fields that do not exist on the item", async () => { - Context.instance.db.data.listings["SKU-1"] = { name: "Widget" }; - const result = await databaseLookupCallback({ api: Api.LISTINGS, id: "SKU-1", fields: ["name", "nonexistent"] }); - const parsed = JSON.parse(result); - expect(parsed).toEqual({ name: "Widget", nonexistent: null }); - }); - - it("applies fields filter to batch lookups", async () => { - Context.instance.db.data.listings["SKU-1"] = { name: "Widget", price: 9.99, color: "red" }; - Context.instance.db.data.listings["SKU-2"] = { name: "Gadget", price: 19.99, color: "blue" }; - const result = await databaseLookupCallback({ api: Api.LISTINGS, ids: ["SKU-1", "SKU-2"], fields: ["price"] }); - const parsed = JSON.parse(result); - expect(parsed["SKU-1"]).toEqual({ price: 9.99 }); - expect(parsed["SKU-2"]).toEqual({ price: 19.99 }); - }); - - it("returns null for missing ids even with fields filter", async () => { - const result = await databaseLookupCallback({ api: Api.LISTINGS, ids: ["MISSING"], fields: ["name"] }); - const parsed = JSON.parse(result); - expect(parsed.MISSING).toBeNull(); - }); - - it("does not filter when fields is not provided", async () => { - Context.instance.db.data.listings["SKU-1"] = { name: "Widget", price: 9.99, color: "red" }; - const result = await databaseLookupCallback({ api: Api.LISTINGS, id: "SKU-1" }); - const parsed = JSON.parse(result); - expect(parsed).toEqual({ name: "Widget", price: 9.99, color: "red" }); - }); - - it("applies fields filter to get-all queries", async () => { - Context.instance.db.data.listings["SKU-1"] = { name: "Widget", price: 9.99, color: "red" }; - Context.instance.db.data.listings["SKU-2"] = { name: "Gadget", price: 19.99, color: "blue" }; - const result = await databaseLookupCallback({ api: Api.LISTINGS, fields: ["price"] }); - const parsed = JSON.parse(result); - expect(parsed["SKU-1"]).toEqual({ price: 9.99 }); - expect(parsed["SKU-2"]).toEqual({ price: 19.99 }); - }); - }); -}); diff --git a/local-ai-sandbox/test/trigger/processListingSubmission.test.ts b/local-ai-sandbox/test/trigger/processListingSubmission.test.ts new file mode 100644 index 000000000..914b0ee9e --- /dev/null +++ b/local-ai-sandbox/test/trigger/processListingSubmission.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Context, Api } from "../../src/database/Context.js"; +import { processListingSubmission } from "../../src/trigger/handlers/processListingSubmission.js"; +import { DataEvent } from "../../src/trigger/DataEvent.js"; +import { listingKey } from "../../src/operation/listingsItemModel.js"; + +const MP = "ATVPDKIKX0DER"; +const SELLER = "AMY6FKRUBY7XV"; + +/** + * Catalog matching, the sandbox's stand-in for Amazon's asynchronous + * downstream processing of a submission. Driven directly here; the + * put-to-trigger path is covered in listingsOperations.test.ts. + */ +describe("processListingSubmission", () => { + beforeEach(() => { + Context.reset(); + }); + + const event = (sku: string): DataEvent => ({ + type: "UPDATE", + api: Api.LISTINGS, + id: listingKey(SELLER, sku), + entity: undefined, + previousEntity: undefined, + }); + + /** Writes fixture data without firing triggers. */ + function seed(domain: Api, key: string, doc: Record): void { + Context.instance.engine.put(domain, key, doc, { silent: true }); + } + + function seedListing(sku: string, overrides: Record = {}) { + seed(Api.LISTINGS, listingKey(SELLER, sku), { + sellerId: SELLER, + sku, + productType: "PRODUCT", + marketplaceId: MP, + attributes: {}, + issues: [], + mfnAvailability: [], + createdDate: "2026-01-01T00:00:00.000Z", + lastUpdatedDate: "2026-01-01T00:00:00.000Z", + ...overrides, + }); + } + + function issueCodes(sku: string): string[] { + return ((Context.instance.engine.get(Api.LISTINGS, listingKey(SELLER, sku))?.issues as { code: string }[] | undefined) ?? []).map((i) => i.code); + } + + it("does nothing for a listing that no longer exists", () => { + expect(() => { + processListingSubmission(event("SKU-GONE")); + }).not.toThrow(); + }); + + describe("offer-only submissions", () => { + it("tags 4005015 when the suggested ASIN matches no catalog item", () => { + seedListing("SKU-A", { + requirements: "LISTING_OFFER_ONLY", + asin: "B0MISSING1", + attributes: { merchant_suggested_asin: [{ value: "B0MISSING1" }] }, + }); + + processListingSubmission(event("SKU-A")); + + expect(issueCodes("SKU-A")).toEqual(["4005015"]); + }); + + it("tags 8560 when there is no suggested ASIN to match on", () => { + seedListing("SKU-B", { requirements: "LISTING_OFFER_ONLY", asin: "B0GENERATED" }); + + processListingSubmission(event("SKU-B")); + + expect(issueCodes("SKU-B")).toEqual(["8560"]); + }); + + it("tags nothing when the ASIN is in the catalog", () => { + seed(Api.CATALOG, "B0EXISTS001", { asin: "B0EXISTS001" }); + seedListing("SKU-C", { requirements: "LISTING_OFFER_ONLY", asin: "B0EXISTS001" }); + + processListingSubmission(event("SKU-C")); + + expect(issueCodes("SKU-C")).toEqual([]); + }); + + it("matches on an external product identifier", () => { + seed(Api.CATALOG, "B0BYUPC001", { + asin: "B0BYUPC001", + identifiers: [{ marketplaceId: MP, identifiers: [{ identifierType: "UPC", identifier: "714532191586" }] }], + }); + seedListing("SKU-D", { + requirements: "LISTING_OFFER_ONLY", + attributes: { externally_assigned_product_identifier: [{ type: "upc", value: "714532191586" }] }, + }); + + processListingSubmission(event("SKU-D")); + + expect(issueCodes("SKU-D")).toEqual([]); + }); + + it("never creates a catalog item", () => { + seedListing("SKU-E", { requirements: "LISTING_OFFER_ONLY", asin: "B0MISSING1" }); + + processListingSubmission(event("SKU-E")); + + expect(Context.instance.engine.get(Api.CATALOG, "B0MISSING1")).toBeNull(); + }); + }); + + describe("full submissions", () => { + it("creates a catalog item for an ASIN the catalog does not hold", () => { + seedListing("SKU-F", { + asin: "B0NETNEW001", + attributes: { + item_name: [{ value: "New Widget" }], + brand: [{ value: "TestBrand" }], + externally_assigned_product_identifier: [{ type: "upc", value: "714532191586" }], + // A sales term, which belongs to the offer and not to the catalog. + condition_type: [{ value: "new_new" }], + }, + }); + + processListingSubmission(event("SKU-F")); + + const item = Context.instance.engine.get(Api.CATALOG, "B0NETNEW001"); + expect(item).not.toBeNull(); + expect(item?.productTypes).toEqual([{ marketplaceId: MP, productType: "PRODUCT" }]); + expect(item?.summaries).toEqual([{ marketplaceId: MP, itemName: "New Widget", brand: "TestBrand" }]); + expect(item?.identifiers).toEqual([{ marketplaceId: MP, identifiers: [{ identifierType: "UPC", identifier: "714532191586" }] }]); + + const attributes = item?.attributes as Record; + expect(attributes.item_name).toEqual([{ value: "New Widget" }]); + expect(attributes.condition_type).toBeUndefined(); + }); + + it("leaves an existing catalog item untouched", () => { + seed(Api.CATALOG, "B0EXISTS001", { asin: "B0EXISTS001", attributes: { brand: [{ value: "Untouched" }] } }); + seedListing("SKU-G", { asin: "B0EXISTS001", attributes: { brand: [{ value: "Submitted" }] } }); + + processListingSubmission(event("SKU-G")); + + expect((Context.instance.engine.get(Api.CATALOG, "B0EXISTS001")?.attributes as Record).brand).toEqual([ + { value: "Untouched" }, + ]); + }); + + it("tags no matching issue, whatever the catalog holds", () => { + seedListing("SKU-H", { asin: "B0NETNEW002" }); + + processListingSubmission(event("SKU-H")); + + expect(issueCodes("SKU-H")).toEqual([]); + }); + }); + + describe("reconciliation", () => { + it("clears its own issue once the catalog item appears", () => { + seedListing("SKU-I", { requirements: "LISTING_OFFER_ONLY", asin: "B0LATER0001" }); + processListingSubmission(event("SKU-I")); + expect(issueCodes("SKU-I")).toEqual(["8560"]); + + seed(Api.CATALOG, "B0LATER0001", { asin: "B0LATER0001" }); + processListingSubmission(event("SKU-I")); + + expect(issueCodes("SKU-I")).toEqual([]); + }); + + it("is idempotent: repeated runs do not duplicate the issue", () => { + seedListing("SKU-J", { + requirements: "LISTING_OFFER_ONLY", + asin: "B0MISSING1", + attributes: { merchant_suggested_asin: [{ value: "B0MISSING1" }] }, + }); + + processListingSubmission(event("SKU-J")); + processListingSubmission(event("SKU-J")); + processListingSubmission(event("SKU-J")); + + expect(issueCodes("SKU-J")).toEqual(["4005015"]); + }); + + it("preserves issues it does not own", () => { + const validationIssue = { code: "90220", message: "brand is required but not supplied.", severity: "ERROR", categories: ["MISSING_ATTRIBUTE"] }; + seedListing("SKU-K", { + requirements: "LISTING_OFFER_ONLY", + asin: "B0MISSING1", + attributes: { merchant_suggested_asin: [{ value: "B0MISSING1" }] }, + issues: [validationIssue], + }); + + processListingSubmission(event("SKU-K")); + + expect(issueCodes("SKU-K")).toEqual(["90220", "4005015"]); + }); + + it("does not modify the listing's attributes", () => { + const attributes = { item_name: [{ value: "Only This" }] }; + seedListing("SKU-L", { requirements: "LISTING_OFFER_ONLY", asin: "B0MISSING1", attributes }); + + processListingSubmission(event("SKU-L")); + + expect(Context.instance.engine.get(Api.LISTINGS, listingKey(SELLER, "SKU-L"))?.attributes).toEqual(attributes); + }); + }); +}); diff --git a/local-ai-sandbox/test/trigger/reduceInventoryOnOrderPlaced.test.ts b/local-ai-sandbox/test/trigger/reduceInventoryOnOrderPlaced.test.ts new file mode 100644 index 000000000..290a88006 --- /dev/null +++ b/local-ai-sandbox/test/trigger/reduceInventoryOnOrderPlaced.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Context, Api } from "../../src/database/Context.js"; +import { reduceInventoryOnOrderPlaced } from "../../src/trigger/handlers/reduceInventoryOnOrderPlaced.js"; +import { DataEvent } from "../../src/trigger/DataEvent.js"; +import { listingKey } from "../../src/operation/listingsItemModel.js"; + +const SELLER = "AMY6FKRUBY7XV"; + +describe("reduceInventoryOnOrderPlaced", () => { + beforeEach(() => { + Context.reset(); + }); + + it("reduces MFN listing inventory on DEFAULT channel", () => { + const engine = Context.instance.engine; + engine.put( + Api.LISTINGS, + listingKey(SELLER, "SKU-A"), + { sku: "SKU-A", sellerId: SELLER, mfnAvailability: [{ fulfillmentChannelCode: "DEFAULT", quantity: 20 }] }, + { silent: true }, + ); + + const event: DataEvent = { + type: "INSERT", + api: Api.ORDERS, + id: "order-001", + entity: { + fulfillment: { fulfillmentStatus: "PENDING", fulfilledBy: "MERCHANT" }, + orderItems: [{ product: { sellerSku: "SKU-A" }, quantityOrdered: 3 }], + }, + }; + + reduceInventoryOnOrderPlaced(event); + + const listing = engine.get(Api.LISTINGS, listingKey(SELLER, "SKU-A")); + expect((listing?.mfnAvailability as any[])[0].quantity).toBe(17); + }); + + it("reduces FBA inventory in the inventory partition", () => { + const engine = Context.instance.engine; + engine.put(Api.INVENTORY, "SKU-A", { sellerSku: "SKU-A", totalQuantity: 100, fulfillableQuantity: 80 }, { silent: true }); + + const event: DataEvent = { + type: "INSERT", + api: Api.ORDERS, + id: "order-002", + entity: { + fulfillment: { fulfillmentStatus: "PENDING", fulfilledBy: "AMAZON" }, + orderItems: [{ product: { sellerSku: "SKU-A" }, quantityOrdered: 5 }], + }, + }; + + reduceInventoryOnOrderPlaced(event); + + const inventory = engine.get(Api.INVENTORY, "SKU-A"); + expect(inventory?.totalQuantity).toBe(95); + expect(inventory?.fulfillableQuantity).toBe(75); + }); + + it("does nothing when listing/inventory does not exist", () => { + const engine = Context.instance.engine; + + const event: DataEvent = { + type: "INSERT", + api: Api.ORDERS, + id: "order-003", + entity: { + fulfillment: { fulfillmentStatus: "PENDING", fulfilledBy: "MERCHANT" }, + orderItems: [{ product: { sellerSku: "NONEXISTENT" }, quantityOrdered: 5 }], + }, + }; + + reduceInventoryOnOrderPlaced(event); + + expect(engine.get(Api.LISTINGS, "NONEXISTENT")).toBeNull(); + }); +}); diff --git a/local-ai-sandbox/test/trigger/triggerProcessor.test.ts b/local-ai-sandbox/test/trigger/triggerProcessor.test.ts new file mode 100644 index 000000000..d0a810017 --- /dev/null +++ b/local-ai-sandbox/test/trigger/triggerProcessor.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from "vitest"; +import { TriggerProcessor } from "../../src/trigger/TriggerProcessor.js"; +import { Api } from "../../src/database/Context.js"; + +// Mock the registry to inject a test trigger with a condition +vi.mock("../../src/trigger/triggerRegistry.js", () => ({ + triggerRegistry: [ + { + name: "Test DELETE condition trigger", + description: "Fires on DELETE when previousEntity has status SHIPPED", + on: { + api: "orders", + event: ["DELETE"], + condition: (event: any) => { + const target = event.type === "DELETE" ? event.previousEntity : event.entity; + return target?.fulfillment?.fulfillmentStatus === "SHIPPED"; + }, + }, + handler: vi.fn(), + }, + ], +})); + +import { triggerRegistry } from "../../src/trigger/triggerRegistry.js"; + +describe("TriggerProcessor", () => { + it("evaluates condition against previousEntity for DELETE events", async () => { + const handler = (triggerRegistry[0] as any).handler; + handler.mockReset(); + + await TriggerProcessor.emit("DELETE", Api.ORDERS, "order-001", undefined, { + orderId: "order-001", + fulfillment: { fulfillmentStatus: "SHIPPED" }, + }); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: "DELETE", + previousEntity: expect.objectContaining({ fulfillment: { fulfillmentStatus: "SHIPPED" } }), + }), + ); + }); + + it("does not fire when DELETE previousEntity does not match condition", async () => { + const handler = (triggerRegistry[0] as any).handler; + handler.mockReset(); + + await TriggerProcessor.emit("DELETE", Api.ORDERS, "order-002", undefined, { + orderId: "order-002", + fulfillment: { fulfillmentStatus: "PENDING" }, + }); + + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/local-ai-sandbox/test/validation/dateComparisonHandler.test.ts b/local-ai-sandbox/test/validation/dateComparisonHandler.test.ts new file mode 100644 index 000000000..ead81ee32 --- /dev/null +++ b/local-ai-sandbox/test/validation/dateComparisonHandler.test.ts @@ -0,0 +1,522 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { executeValidation } from "../../src/service/validationEngine.js"; +import { RequestContext, ValidationFail, ValidationPass, ValidationPipeline } from "../../src/validation/validationTypes.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; +import { buildKey } from "../../src/registry/operationRegistry.js"; + +// Mock the validation registry so we can inject test pipelines +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + VALIDATION_REGISTRY: new Map(), + }; +}); + +// Mock the Context singleton (required by module but not used by dateComparison handler) +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { + get instance() { + return { + db: { + data: {}, + }, + }; + }, + }, + }; +}); + +beforeEach(() => { + VALIDATION_REGISTRY.clear(); +}); + +// Test helper constants +const TEST_API_NAME = "TestApi"; +const TEST_API_VERSION = "v1"; +const TEST_OP = "__test_dateComparison__"; + +// Concrete ISO 8601 date strings +const EARLIER_DATE = "2024-01-01T00:00:00Z"; +const LATER_DATE = "2024-06-15T12:00:00Z"; + +// Helper to create a dateComparison pipeline with param-based second operand +function makeDateParamPipeline(operator: "before" | "after" | "beforeOrEqual" | "afterOrEqual"): ValidationPipeline { + return [ + { + checkType: "dateComparison", + firstOperand: { name: "startDate", source: "query" }, + secondOperand: { kind: "param", name: "endDate", source: "query" }, + operator, + failAction: { statusCode: 400, code: "InvalidInput", message: `startDate must be ${operator} endDate` }, + }, + ]; +} + +// Helper to create a dateComparison pipeline with "now" second operand +function makeDateNowPipeline(operator: "before" | "after" | "beforeOrEqual" | "afterOrEqual"): ValidationPipeline { + return [ + { + checkType: "dateComparison", + firstOperand: { name: "targetDate", source: "query" }, + secondOperand: { kind: "now" }, + operator, + failAction: { statusCode: 400, code: "InvalidInput", message: `targetDate must be ${operator} now` }, + }, + ]; +} + +describe("dateComparison handler", () => { + describe("before operator", () => { + it("passes when firstDate is before secondDate", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("before")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE, endDate: LATER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when firstDate equals secondDate", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("before")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE, endDate: EARLIER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + }); + + it("fails when firstDate is after secondDate", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("before")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: LATER_DATE, endDate: EARLIER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + }); + }); + + describe("after operator", () => { + it("passes when firstDate is after secondDate", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("after")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: LATER_DATE, endDate: EARLIER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when firstDate equals secondDate", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("after")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE, endDate: EARLIER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + }); + + it("fails when firstDate is before secondDate", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("after")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE, endDate: LATER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + }); + }); + + describe("beforeOrEqual operator", () => { + it("passes when firstDate is before secondDate", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("beforeOrEqual")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE, endDate: LATER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("passes when firstDate equals secondDate", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("beforeOrEqual")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE, endDate: EARLIER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when firstDate is after secondDate", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("beforeOrEqual")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: LATER_DATE, endDate: EARLIER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + }); + }); + + describe("afterOrEqual operator", () => { + it("passes when firstDate is after secondDate", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("afterOrEqual")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: LATER_DATE, endDate: EARLIER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("passes when firstDate equals secondDate", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("afterOrEqual")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE, endDate: EARLIER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when firstDate is before secondDate", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("afterOrEqual")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE, endDate: LATER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + }); + }); + + describe('"now" second operand', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-06-15T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("passes when firstDate is before now (using 'before' operator)", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateNowPipeline("before")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { targetDate: "2024-01-01T00:00:00Z" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when firstDate is after now (using 'before' operator)", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateNowPipeline("before")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { targetDate: "2025-01-01T00:00:00Z" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + }); + }); + + describe("first operand absent (rule skips)", () => { + it("passes when first operand is not in query params", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("before")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { endDate: LATER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + expect((result as ValidationPass).resolvedEntities).toEqual({}); + }); + + it("passes when first operand is empty string", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("before")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: "", endDate: LATER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + }); + + describe("second operand param absent (rule skips)", () => { + it("passes when second operand param is not in query params", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("before")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + expect((result as ValidationPass).resolvedEntities).toEqual({}); + }); + + it("passes when second operand param is empty string", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("before")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE, endDate: "" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + }); + + describe("unparseable first operand", () => { + it("returns HTTP 400 with parameter name for invalid first date", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("before")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: "not-a-date", endDate: LATER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("startDate"); + expect(fail.body.errors[0].message).toContain("unparseable"); + }); + + it("returns HTTP 400 for gibberish date format", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("after")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: "2024-13-45T99:99:99Z", endDate: LATER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("startDate"); + }); + }); + + describe("unparseable second operand", () => { + it("returns HTTP 400 with parameter name for invalid second date", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("before")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE, endDate: "not-a-date" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("endDate"); + expect(fail.body.errors[0].message).toContain("unparseable"); + }); + + it("returns HTTP 400 for gibberish second date format", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("after")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE, endDate: "2024-13-45T99:99:99Z" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("endDate"); + }); + }); + + describe("pass result structure", () => { + it("returns { pass: true, resolvedEntities: {} } on successful comparison", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), makeDateParamPipeline("before")); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { startDate: EARLIER_DATE, endDate: LATER_DATE }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result).toEqual({ pass: true, resolvedEntities: {} }); + }); + }); +}); diff --git a/local-ai-sandbox/test/validation/extFulfillmentInventoryValidation.test.ts b/local-ai-sandbox/test/validation/extFulfillmentInventoryValidation.test.ts new file mode 100644 index 000000000..56c5c519f --- /dev/null +++ b/local-ai-sandbox/test/validation/extFulfillmentInventoryValidation.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { OPERATIONS_REGISTRY } from "../../src/registry/operationRegistry.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; + +describe("External Fulfillment Inventory registration", () => { + const key = "External Fulfillment Inventory:2024-09-11:batchInventory"; + + it("registers batchInventory handler in the operation registry", () => { + expect(OPERATIONS_REGISTRY.get(key)).toBeDefined(); + }); + + it("batchInventory is allowed in Seller mode", () => { + // Default test env has MODE: "Seller" + expect(OPERATIONS_REGISTRY.isAllowedInCurrentMode(key)).toBe(true); + }); + + it("validation pipeline contains batchSizeLimit rule with maxItems: 10", () => { + const pipeline = VALIDATION_REGISTRY.get(key); + expect(pipeline).toBeDefined(); + expect(pipeline).toEqual([ + { + checkType: "batchSizeLimit", + arrayParam: { name: "requests", source: "body" }, + maxItems: 10, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Batch size exceeds maximum of 10 items", + }, + }, + ]); + }); +}); diff --git a/local-ai-sandbox/test/validation/listingsRequestValidation.test.ts b/local-ai-sandbox/test/validation/listingsRequestValidation.test.ts new file mode 100644 index 000000000..02f812155 --- /dev/null +++ b/local-ai-sandbox/test/validation/listingsRequestValidation.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { Request } from "express"; +import { validateRequest } from "../../src/service/validationEngine.js"; +import { Context, Api } from "../../src/database/Context.js"; +import type { UnifiedValidationFail } from "../../src/validation/validationTypes.js"; +import { listingKey } from "../../src/operation/listingsItemModel.js"; + +/** + * Request-level (HTTP 400) validation for the Listings Items API, exercised + * end to end through the real OpenAPI model and the real rule pipeline. + * These are distinct from submission issues, which are reported as HTTP 200 + * with status INVALID. + */ +const MP = "ATVPDKIKX0DER"; +const SELLER = "AMY6FKRUBY7XV"; + +function request(overrides: Partial & { path: string }): Request { + return { + method: "GET", + query: {}, + headers: {}, + body: undefined, + ...overrides, + } as unknown as Request; +} + +function itemsPath(sku = "SKU-1") { + return `/listings/2021-08-01/items/${SELLER}/${sku}`; +} + +async function expectFail(req: Request): Promise { + const result = await validateRequest(req); + expect(result.pass).toBe(false); + return result as UnifiedValidationFail; +} + +describe("Listings request validation (HTTP 400 class)", () => { + beforeEach(() => { + Context.reset(); + Context.instance.engine.put( + Api.LISTINGS, + listingKey(SELLER, "SKU-1"), + { sku: "SKU-1", sellerId: SELLER, attributes: {}, issues: [] }, + { silent: true }, + ); + }); + + describe("marketplaceIds", () => { + it("rejects a missing marketplaceIds", async () => { + const fail = await expectFail(request({ path: itemsPath() })); + expect(fail.statusCode).toBe(400); + }); + + it("rejects a marketplace ID from another region", async () => { + const fail = await expectFail(request({ path: itemsPath(), query: { marketplaceIds: "A1F83G8C2ARO7P" } })); + expect(fail.statusCode).toBe(400); + }); + + it("accepts a valid marketplace ID", async () => { + const result = await validateRequest(request({ path: itemsPath(), query: { marketplaceIds: MP } })); + expect(result.pass).toBe(true); + }); + }); + + describe("enum-constrained query parameters", () => { + it("rejects an unknown includedData value", async () => { + const fail = await expectFail(request({ path: itemsPath(), query: { marketplaceIds: MP, includedData: "summaries,bogusSection" } })); + expect(fail.statusCode).toBe(400); + }); + + it("rejects an unknown sortBy value on search", async () => { + const fail = await expectFail(request({ path: `/listings/2021-08-01/items/${SELLER}`, query: { marketplaceIds: MP, sortBy: "price" } })); + expect(fail.statusCode).toBe(400); + }); + + it("rejects an unknown identifiersType value on search", async () => { + const fail = await expectFail( + request({ path: `/listings/2021-08-01/items/${SELLER}`, query: { marketplaceIds: MP, identifiers: "SKU-1", identifiersType: "MPN" } }), + ); + expect(fail.statusCode).toBe(400); + }); + + it("rejects an unknown withStatus value on search", async () => { + const fail = await expectFail(request({ path: `/listings/2021-08-01/items/${SELLER}`, query: { marketplaceIds: MP, withStatus: "SELLABLE" } })); + expect(fail.statusCode).toBe(400); + }); + + it("rejects an unknown mode value on put", async () => { + const fail = await expectFail( + request({ + method: "PUT", + path: itemsPath(), + query: { marketplaceIds: MP, mode: "WRONG_ENUM" }, + body: { productType: "PRODUCT", attributes: {} }, + }), + ); + expect(fail.statusCode).toBe(400); + }); + }); + + /** + * Both selling partner types call these operations, but some datasets belong + * to one of them only. These run in the default Seller mode, so they cover + * the seller side of the gate; the vendor side lives in + * test/operation/listingsVendorMode.test.ts, which has to reload the module + * graph to change MODE. + */ + describe("selling-partner-specific datasets", () => { + it("rejects the vendor-only procurement section for a seller", async () => { + const fail = await expectFail(request({ path: itemsPath(), query: { marketplaceIds: MP, includedData: "procurement" } })); + expect(fail.statusCode).toBe(400); + expect(fail.body?.errors[0].message).toContain("only available to vendors"); + }); + + it("accepts the seller-only offers and fulfillmentAvailability sections for a seller", async () => { + const result = await validateRequest( + request({ path: itemsPath(), query: { marketplaceIds: MP, includedData: "offers,fulfillmentAvailability" } }), + ); + expect(result.pass).toBe(true); + }); + + it("rejects the vendor-only procurement section on search for a seller", async () => { + const fail = await expectFail( + request({ path: `/listings/2021-08-01/items/${SELLER}`, query: { marketplaceIds: MP, includedData: "summaries,procurement" } }), + ); + expect(fail.statusCode).toBe(400); + }); + + it("accepts a seller's LISTING_OFFER_ONLY submission", async () => { + const result = await validateRequest( + request({ + method: "PUT", + path: itemsPath(), + query: { marketplaceIds: MP }, + body: { productType: "PRODUCT", requirements: "LISTING_OFFER_ONLY", attributes: {} }, + }), + ); + expect(result.pass).toBe(true); + }); + }); + + describe("pageSize bounds on search", () => { + it("rejects a pageSize above the documented maximum of 20", async () => { + const fail = await expectFail(request({ path: `/listings/2021-08-01/items/${SELLER}`, query: { marketplaceIds: MP, pageSize: "50" } })); + expect(fail.statusCode).toBe(400); + }); + }); + + describe("request body", () => { + it("rejects a put with no body", async () => { + const fail = await expectFail(request({ method: "PUT", path: itemsPath(), query: { marketplaceIds: MP } })); + expect(fail.statusCode).toBe(400); + }); + + it("rejects a put missing the required productType", async () => { + const fail = await expectFail(request({ method: "PUT", path: itemsPath(), query: { marketplaceIds: MP }, body: { attributes: {} } })); + expect(fail.statusCode).toBe(400); + }); + + it("rejects a patch whose patch op is not a known enum value", async () => { + const fail = await expectFail( + request({ + method: "PATCH", + path: itemsPath(), + query: { marketplaceIds: MP }, + body: { productType: "PRODUCT", patches: [{ op: "increment", path: "/attributes/color" }] }, + }), + ); + expect(fail.statusCode).toBe(400); + }); + }); + + describe("identifiers pairing on search", () => { + it("rejects identifiers without identifiersType", async () => { + const fail = await expectFail(request({ path: `/listings/2021-08-01/items/${SELLER}`, query: { marketplaceIds: MP, identifiers: "SKU-1" } })); + expect(fail.statusCode).toBe(400); + }); + + it("rejects identifiersType without identifiers", async () => { + const fail = await expectFail(request({ path: `/listings/2021-08-01/items/${SELLER}`, query: { marketplaceIds: MP, identifiersType: "SKU" } })); + expect(fail.statusCode).toBe(400); + }); + + it("rejects identifiers combined with variationParentSku", async () => { + const fail = await expectFail( + request({ + path: `/listings/2021-08-01/items/${SELLER}`, + query: { marketplaceIds: MP, identifiers: "SKU-1", identifiersType: "SKU", variationParentSku: "PARENT-1" }, + }), + ); + expect(fail.statusCode).toBe(400); + }); + + it("rejects identifiers combined with packageHierarchySku", async () => { + const fail = await expectFail( + request({ + path: `/listings/2021-08-01/items/${SELLER}`, + query: { marketplaceIds: MP, identifiers: "SKU-1", identifiersType: "SKU", packageHierarchySku: "PKG-1" }, + }), + ); + expect(fail.statusCode).toBe(400); + }); + + it("accepts identifiers together with identifiersType", async () => { + const result = await validateRequest( + request({ path: `/listings/2021-08-01/items/${SELLER}`, query: { marketplaceIds: MP, identifiers: "SKU-1", identifiersType: "SKU" } }), + ); + expect(result.pass).toBe(true); + }); + }); + + /** + * A SKU is unique per seller, not globally, so a listing is keyed by both. + * This is a fidelity concern, not an access-control one: the sandbox never + * authenticates the caller, so `sellerId` is simply part of a listing's + * identity. Keying by SKU alone would make two sellers using the same SKU + * collide on one record. + */ + describe("seller + SKU composite key", () => { + const OTHER_SELLER = "A9OTHERSELLER1"; + + it("resolves the listing under the seller that holds it", async () => { + const result = await validateRequest(request({ path: itemsPath(), query: { marketplaceIds: MP } })); + expect(result.pass).toBe(true); + }); + + it("does not resolve the same SKU under a different seller on GET", async () => { + const fail = await expectFail(request({ path: `/listings/2021-08-01/items/${OTHER_SELLER}/SKU-1`, query: { marketplaceIds: MP } })); + + expect(fail.statusCode).toBe(404); + }); + + it("does not resolve the same SKU under a different seller on DELETE", async () => { + const fail = await expectFail( + request({ method: "DELETE", path: `/listings/2021-08-01/items/${OTHER_SELLER}/SKU-1`, query: { marketplaceIds: MP } }), + ); + + expect(fail.statusCode).toBe(404); + }); + + it("still resolves for the holding seller on DELETE", async () => { + const result = await validateRequest(request({ method: "DELETE", path: itemsPath(), query: { marketplaceIds: MP } })); + expect(result.pass).toBe(true); + }); + }); +}); diff --git a/local-ai-sandbox/test/validation/marketplaceIdValidation.test.ts b/local-ai-sandbox/test/validation/marketplaceIdValidation.test.ts new file mode 100644 index 000000000..68a8091ce --- /dev/null +++ b/local-ai-sandbox/test/validation/marketplaceIdValidation.test.ts @@ -0,0 +1,487 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { executeValidation, getAllowedMarketplaceIds } from "../../src/service/validationEngine.js"; +import { RequestContext, ValidationFail, ValidationPipeline } from "../../src/validation/validationTypes.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; +import { Api } from "../../src/database/Context.js"; +import { buildKey } from "../../src/registry/operationRegistry.js"; + +// Mock the validation registry so we can inject test pipelines +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + VALIDATION_REGISTRY: new Map(), + }; +}); + +// Mock the Context singleton (not used by this handler, but required by the engine) +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { + get instance() { + return { + engine: { + get: () => null, + }, + }; + }, + }, + }; +}); + +const TEST_API_NAME = "TestApi"; +const TEST_API_VERSION = "v1"; +const TEST_OP = "__test_marketplaceIdValidation__"; + +const marketplaceIdsQueryPipeline: ValidationPipeline = [ + { + checkType: "marketplaceIdValidation", + marketplaceIdsParam: { name: "marketplaceIds", source: "query" }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "One or more marketplace IDs are not valid for the configured region", + }, + }, +]; + +const marketplaceIdsBodyPipeline: ValidationPipeline = [ + { + checkType: "marketplaceIdValidation", + marketplaceIdsParam: { name: "marketplaceIds", source: "body" }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "One or more marketplace IDs are not valid for the configured region", + }, + }, +]; + +const marketplaceIdSingularQueryPipeline: ValidationPipeline = [ + { + checkType: "marketplaceIdValidation", + marketplaceIdsParam: { name: "marketplaceId", source: "query" }, + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "The marketplace ID is not valid for the configured region", + }, + }, +]; + +beforeEach(() => { + VALIDATION_REGISTRY.clear(); +}); + +afterEach(() => { + delete process.env.REGION; +}); + +describe("marketplaceIdValidation handler", () => { + describe("NA region (default)", () => { + beforeEach(() => { + delete process.env.REGION; + }); + + it("passes when marketplaceIds contains a valid NA marketplace ID in query", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: ["ATVPDKIKX0DER"] }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("passes when all marketplaceIds are valid NA marketplace IDs", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: ["ATVPDKIKX0DER", "A2EUQ1WTGCTBG2", "A1AM78C64UM0Y8", "A2Q3Y263D00KWC"] }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when marketplaceIds contains an invalid marketplace ID", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: ["INVALID_ID"] }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("INVALID_ID"); + expect(fail.body.errors[0].message).toContain("NA"); + }); + + it("fails when marketplaceIds contains an EU marketplace ID in NA region", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: ["A1F83G8C2ARO7P"] }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("A1F83G8C2ARO7P"); + }); + + it("fails when mix of valid and invalid marketplace IDs provided", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: ["ATVPDKIKX0DER", "INVALID_ID"] }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("INVALID_ID"); + // Only the invalid ID should appear in the "Invalid marketplace ID(s)" prefix + expect(fail.body.errors[0].message).toMatch(/^Invalid marketplace ID\(s\): INVALID_ID\./); + }); + }); + + describe("EU region", () => { + beforeEach(() => { + process.env.REGION = "EU"; + }); + + it("passes when marketplaceIds contains valid EU marketplace IDs", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: ["A1F83G8C2ARO7P", "A1PA6795UKMFR9"] }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when marketplaceIds contains an NA marketplace ID in EU region", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: ["ATVPDKIKX0DER"] }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("ATVPDKIKX0DER"); + expect(fail.body.errors[0].message).toContain("EU"); + }); + + it("passes for all 16 EU marketplace IDs", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const allEuIds = [ + "A28R8C7NBKEWEA", + "A1RKKUPIHCS9HS", + "A1F83G8C2ARO7P", + "A13V1IB3VIYZZH", + "AMEN7PMS3EDWL", + "A1805IZSGTT6HS", + "A1PA6795UKMFR9", + "APJ6JRA9NG5V4", + "A2NODRKZP88ZB9", + "AE08WJ6YKNBMC", + "A1C3SOZRARQ6R3", + "ARBP9OOSHTCHU", + "A33AVAJ2PDY3EV", + "A17E79C6D8DWNP", + "A2VIGQ35RCS4UG", + "A21TJRUUN4KGV", + ]; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: allEuIds }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + }); + + describe("FE region", () => { + beforeEach(() => { + process.env.REGION = "FE"; + }); + + it("passes when marketplaceIds contains valid FE marketplace IDs", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: ["A1VC38T7YXB528", "A39IBJ37TRP1C6", "A19VAU5U5O7RUS"] }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when marketplaceIds contains an NA marketplace ID in FE region", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: ["A2EUQ1WTGCTBG2"] }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("FE"); + }); + }); + + describe("body source", () => { + it("passes when marketplaceIds in body contains valid IDs", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsBodyPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "POST", + pathParams: {}, + queryParams: {}, + body: { marketplaceIds: ["ATVPDKIKX0DER", "A2EUQ1WTGCTBG2"] }, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when marketplaceIds in body contains invalid IDs", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsBodyPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "POST", + pathParams: {}, + queryParams: {}, + body: { marketplaceIds: ["INVALID_BODY_ID"] }, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("INVALID_BODY_ID"); + }); + }); + + describe("singular marketplaceId (query)", () => { + it("passes when singular marketplaceId is valid", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdSingularQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceId: "ATVPDKIKX0DER" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when singular marketplaceId is invalid", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdSingularQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceId: "INVALID_SINGLE" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("INVALID_SINGLE"); + }); + }); + + describe("skip when param absent", () => { + it("passes when marketplaceIds is undefined", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("passes when marketplaceIds is null", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: undefined }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("passes when marketplaceIds is empty string", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsQueryPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { marketplaceIds: "" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("passes when marketplaceIds is empty array in body", async () => { + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), marketplaceIdsBodyPipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "POST", + pathParams: {}, + queryParams: {}, + body: { marketplaceIds: [] }, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + }); + + describe("region fallback", () => { + it("defaults to NA when REGION is unset", () => { + delete process.env.REGION; + const allowed = getAllowedMarketplaceIds(); + expect(allowed).toContain("ATVPDKIKX0DER"); + expect(allowed).toHaveLength(4); + }); + + it("defaults to NA when REGION is an invalid value", () => { + process.env.REGION = "INVALID"; + const allowed = getAllowedMarketplaceIds(); + expect(allowed).toContain("ATVPDKIKX0DER"); + expect(allowed).toHaveLength(4); + }); + + it("returns EU marketplace IDs when REGION is EU", () => { + process.env.REGION = "EU"; + const allowed = getAllowedMarketplaceIds(); + expect(allowed).toContain("A1F83G8C2ARO7P"); + expect(allowed).toHaveLength(16); + }); + + it("returns FE marketplace IDs when REGION is FE", () => { + process.env.REGION = "FE"; + const allowed = getAllowedMarketplaceIds(); + expect(allowed).toContain("A1VC38T7YXB528"); + expect(allowed).toHaveLength(3); + }); + }); +}); diff --git a/local-ai-sandbox/test/validation/ordersDateFormatting.property.test.ts b/local-ai-sandbox/test/validation/ordersDateFormatting.property.test.ts new file mode 100644 index 000000000..8802cd4ee --- /dev/null +++ b/local-ai-sandbox/test/validation/ordersDateFormatting.property.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import fc from "fast-check"; + +/** + * Feature: orders-ui, Property 11: Date formatting preserves information + * + * For any valid ISO 8601 date-time string, formatting it for display and then parsing + * the displayed string back should represent the same point in time (to minute precision). + * + * **Validates: Requirements 1.2** + */ + +/** Inline format function matching the frontend implementation */ +function formatDateTime(isoString: string): string { + const d = new Date(isoString); + if (isNaN(d.getTime())) return isoString; + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +describe("Feature: orders-ui, Property 11: Date formatting preserves information", () => { + it("formatting and parsing back preserves the same point in time to minute precision", () => { + fc.assert( + fc.property(fc.date({ min: new Date("2000-01-01"), max: new Date("2030-12-31"), noInvalidDate: true }), (date) => { + const isoString = date.toISOString(); + const formatted = formatDateTime(isoString); + + // formatDateTime uses local time methods (getFullYear, getMonth, getHours, etc.) + // so the formatted string represents local time. Parse it back as local time. + const parsed = new Date(formatted.replace(" ", "T")); + + // Verify same point in time to minute precision (60000ms = 1 minute) + const originalMinutes = Math.floor(date.getTime() / 60000); + const parsedMinutes = Math.floor(parsed.getTime() / 60000); + expect(parsedMinutes).toBe(originalMinutes); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/validation/ordersItemCount.property.test.ts b/local-ai-sandbox/test/validation/ordersItemCount.property.test.ts new file mode 100644 index 000000000..850253075 --- /dev/null +++ b/local-ai-sandbox/test/validation/ordersItemCount.property.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import fc from "fast-check"; + +/** + * Feature: orders-ui, Property 10: Order item count bounded between 1 and 50 + * + * This test exercises a state machine that mirrors the real add/remove item + * guards in public/app.js: + * - `addOrderItem` returns early when items >= 50 + * - `removeOrderItem` is disabled (btn-remove-item disabled) when items <= 1 + * + * The state machine applies operations unconditionally (no pre-filtering), + * and the guards inside the implementation enforce the bounds. The property + * then asserts the invariant holds on the output. + * + * **Validates: Requirements 3.4** + */ + +const MIN_ITEMS = 1; +const MAX_ITEMS = 50; + +/** + * Models the order items state machine from the UI. + * Mirrors the guards in app.js `addOrderItem` (early return at >= 50) + * and `updateAddRemoveButtons` (disables remove at <= 1). + */ +function applyOperation(count: number, op: "add" | "remove"): number { + if (op === "add") { + // Mirrors: if (currentItems.length >= 50) return; + if (count >= MAX_ITEMS) return count; + return count + 1; + } + // Mirrors: removeBtn.disabled = count <= 1; + if (count <= MIN_ITEMS) return count; + return count - 1; +} + +describe("Feature: orders-ui, Property 10: Order item count bounded between 1 and 50", () => { + it("Property 10: Order item count is always between 1 and 50 after any sequence of operations", () => { + fc.assert( + fc.property( + fc.integer({ min: MIN_ITEMS, max: MAX_ITEMS }), + fc.array(fc.constantFrom("add", "remove"), { minLength: 1, maxLength: 200 }), + (initialCount, operations) => { + let count = initialCount; + for (const op of operations) { + count = applyOperation(count, op); + expect(count).toBeGreaterThanOrEqual(MIN_ITEMS); + expect(count).toBeLessThanOrEqual(MAX_ITEMS); + } + }, + ), + { numRuns: 200 }, + ); + }); + + it("Property 10: Adding at the maximum has no effect", () => { + const result = applyOperation(MAX_ITEMS, "add"); + expect(result).toBe(MAX_ITEMS); + }); + + it("Property 10: Removing at the minimum has no effect", () => { + const result = applyOperation(MIN_ITEMS, "remove"); + expect(result).toBe(MIN_ITEMS); + }); + + it("Property 10: Adding below the maximum increases count by 1", () => { + fc.assert( + fc.property(fc.integer({ min: MIN_ITEMS, max: MAX_ITEMS - 1 }), (count) => { + expect(applyOperation(count, "add")).toBe(count + 1); + }), + { numRuns: 100 }, + ); + }); + + it("Property 10: Removing above the minimum decreases count by 1", () => { + fc.assert( + fc.property(fc.integer({ min: MIN_ITEMS + 1, max: MAX_ITEMS }), (count) => { + expect(applyOperation(count, "remove")).toBe(count - 1); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/validation/ordersPrefill.property.test.ts b/local-ai-sandbox/test/validation/ordersPrefill.property.test.ts new file mode 100644 index 000000000..566d5fbdd --- /dev/null +++ b/local-ai-sandbox/test/validation/ordersPrefill.property.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; + +/** + * Feature: orders-ui, Property 9: Prefill generates valid order structure + * + * For any invocation of the prefill function, the generated order should have: + * an orderId matching `^\d{3}-\d{7}-\d{7}$`, valid ISO 8601 timestamps for + * createdTime and lastUpdatedTime, a salesChannel with a valid channelName, + * and at least one orderItem with a valid orderItemId, quantityOrdered >= 1, + * and a product with an ASIN matching `^B[0-9A-Z]{9}$`. + * + * These are statistical/fuzz tests — the generators use Math.random() internally, + * so fast-check cannot control or reproduce their randomness. Plain loops are used + * instead to be explicit about what the tests actually verify. + * + * **Validates: Requirements 5.2** + */ +describe("Feature: orders-ui, Property 9: Prefill generates valid order structure", () => { + // Replicate the prefill helper functions from public/app.js (pure functions) + function generateOrderId(): string { + const digits = (n: number): string => { + let s = ""; + for (let i = 0; i < n; i++) s += Math.floor(Math.random() * 10); + return s; + }; + return digits(3) + "-" + digits(7) + "-" + digits(7); + } + + function generateAsin(): string { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let result = "B"; + for (let i = 0; i < 9; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + } + + it("orderId matches \\d{3}-\\d{7}-\\d{7} format across 100 random invocations", () => { + for (let i = 0; i < 100; i++) { + expect(generateOrderId()).toMatch(/^\d{3}-\d{7}-\d{7}$/); + } + }); + + it("ASIN matches B followed by 9 alphanumeric characters across 100 random invocations", () => { + for (let i = 0; i < 100; i++) { + expect(generateAsin()).toMatch(/^B[0-9A-Z]{9}$/); + } + }); + + it("timestamps are valid ISO 8601 strings", () => { + for (let i = 0; i < 100; i++) { + const now = new Date().toISOString(); + const parsed = new Date(now); + expect(parsed.toISOString()).toBe(now); + expect(now).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/); + } + }); + + it("full prefill generates valid structure with all required fields", () => { + for (let i = 0; i < 100; i++) { + const orderId = generateOrderId(); + const now = new Date().toISOString(); + const asin = generateAsin(); + const orderItemId = generateOrderId(); + + const prefilled = { + orderId, + createdTime: now, + lastUpdatedTime: now, + salesChannel: { channelName: "AMAZON", marketplaceId: "ATVPDKIKX0DER" }, + orderItems: [ + { + orderItemId, + quantityOrdered: 1, + product: { asin, title: "Test Product", sellerSku: "SKU-001" }, + }, + ], + fulfillment: { fulfillmentStatus: "UNSHIPPED", fulfilledBy: "MERCHANT" }, + buyer: { buyerName: "Test Buyer" }, + }; + + // orderId format + expect(prefilled.orderId).toMatch(/^\d{3}-\d{7}-\d{7}$/); + + // Timestamps are valid ISO 8601 + expect(new Date(prefilled.createdTime).toISOString()).toBe(prefilled.createdTime); + expect(new Date(prefilled.lastUpdatedTime).toISOString()).toBe(prefilled.lastUpdatedTime); + + // salesChannel has valid channelName + expect(["AMAZON", "NON_AMAZON"]).toContain(prefilled.salesChannel.channelName); + + // At least one order item + expect(prefilled.orderItems.length).toBeGreaterThanOrEqual(1); + + // Order item validation + const item = prefilled.orderItems[0]; + expect(item.orderItemId).toMatch(/^\d{3}-\d{7}-\d{7}$/); + expect(item.quantityOrdered).toBeGreaterThanOrEqual(1); + expect(Number.isInteger(item.quantityOrdered)).toBe(true); + expect(item.product.asin).toMatch(/^B[0-9A-Z]{9}$/); + } + }); +}); diff --git a/local-ai-sandbox/test/validation/ordersValidation.prop.test.ts b/local-ai-sandbox/test/validation/ordersValidation.prop.test.ts new file mode 100644 index 000000000..4ef0de9bc --- /dev/null +++ b/local-ai-sandbox/test/validation/ordersValidation.prop.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import fc from "fast-check"; +import { executeValidation } from "../../src/service/validationEngine.js"; +import { QuantityLimitRule, RequestContext, ValidationFail, ValidationPipeline } from "../../src/validation/validationTypes.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; +import { Context, Api } from "../../src/database/Context.js"; +import { buildKey } from "../../src/registry/operationRegistry.js"; + +/** + * Feature: orders-agent-definitions, Property 13: Quantity validation rejects quantities exceeding ordered amount + * + * For any `quantity` in the request body's `packageDetail.orderItems[]` that exceeds the + * `quantityOrdered` of the corresponding order item in the resolved order entity, the + * validation pipeline returns HTTP 400 with an "InvalidInput" error code. + * + * **Validates: Requirements 15.7** + */ +describe("Feature: orders-agent-definitions, Property 13: Quantity validation rejects quantities exceeding ordered amount", () => { + const TEST_OPERATION_ID = "__test_quantityLimit__"; + const TEST_API_NAME = "TestOrders"; + const TEST_API_VERSION = "v1"; + + const quantityLimitRule: QuantityLimitRule = { + checkType: "quantityLimit", + entityLabel: "order", + failAction: { + statusCode: 400, + code: "InvalidInput", + message: "Quantity exceeds the ordered quantity", + }, + }; + + it("Property 13: Quantity validation returns HTTP 400 with InvalidInput for any quantity exceeding quantityOrdered", async () => { + // Generator for a positive quantityOrdered value + const quantityOrderedArb = fc.integer({ min: 1, max: 10000 }); + + // Generator for an orderItemId (non-empty, trimmed) + const orderItemIdArb = fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0); + + // Generator for orderId (non-empty, unique per run, excluding prototype-polluting keys) + const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype", "toString", "valueOf", "hasOwnProperty"]); + const orderIdArb = fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0 && !RESERVED_KEYS.has(s)); + + const excessArb = fc.integer({ min: 1, max: 10000 }); + + await fc.assert( + fc.asyncProperty(quantityOrderedArb, orderItemIdArb, orderIdArb, excessArb, async (quantityOrdered, orderItemId, orderId, excess) => { + // Generate a quantity that exceeds quantityOrdered (at least quantityOrdered + 1) + const exceedingQuantity = quantityOrdered + excess; + + const validationKey = buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OPERATION_ID); + + // Set up pipeline: entityExistence (to resolve order into resolvedEntities) → quantityLimit + VALIDATION_REGISTRY.set(validationKey, [ + { + checkType: "entityExistence", + entity: { + api: Api.ORDERS, + paramName: "orderId", + paramSource: "path", + entityLabel: "order", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Order not found", + }, + }, + quantityLimitRule, + ] as ValidationPipeline); + + // Insert test order with an orderItem having quantityOrdered + Context.instance.engine.put(Api.ORDERS, orderId, { + orderId, + orderItems: [ + { + orderItemId, + quantityOrdered, + }, + ], + }); + + try { + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OPERATION_ID, + method: "POST", + pathParams: { orderId }, + queryParams: {}, + body: { + packageDetail: { + orderItems: [ + { + orderItemId, + quantity: exceedingQuantity, + }, + ], + }, + }, + }; + + const result = await executeValidation(context); + + // Should fail: quantity exceeds quantityOrdered + expect(result.pass).toBe(false); + const failResult = result as ValidationFail; + expect(failResult.statusCode).toBe(400); + expect(failResult.body.errors).toHaveLength(1); + expect(failResult.body.errors[0].code).toBe("InvalidInput"); + } finally { + // Cleanup + Context.instance.engine.remove(Api.ORDERS, orderId); + VALIDATION_REGISTRY.delete(validationKey); + } + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/validation/ordersValidation.property.test.ts b/local-ai-sandbox/test/validation/ordersValidation.property.test.ts new file mode 100644 index 000000000..8779f85ad --- /dev/null +++ b/local-ai-sandbox/test/validation/ordersValidation.property.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import fc from "fast-check"; + +/** + * Feature: orders-ui, Property 7: Order ID format validation + * + * For any string matching the pattern `^\d{3}-\d{7}-\d{7}$`, the orderId validator + * should accept it. For any string not matching that pattern, the validator should reject it. + * + * **Validates: Requirements 4.4** + */ + +/** Inline validation function matching the frontend implementation */ +function validateOrderId(value: string): boolean { + return /^\d{3}-\d{7}-\d{7}$/.test(value); +} + +describe("Feature: orders-ui, Property 7: Order ID format validation", () => { + it("should accept any string matching the pattern ^\\d{3}-\\d{7}-\\d{7}$", () => { + // Generate strings that match the orderId format: 3 digits, dash, 7 digits, dash, 7 digits + const validOrderIdArb = fc + .tuple( + fc.stringMatching(/^\d{3}$/), + fc.stringMatching(/^\d{7}$/), + fc.stringMatching(/^\d{7}$/), + ) + .map(([a, b, c]) => `${a}-${b}-${c}`); + + fc.assert( + fc.property(validOrderIdArb, (orderId) => { + expect(validateOrderId(orderId)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + it("should reject any string not matching the pattern ^\\d{3}-\\d{7}-\\d{7}$", () => { + // Generate arbitrary strings that do NOT match the orderId pattern + const invalidOrderIdArb = fc.string().filter((s) => !/^\d{3}-\d{7}-\d{7}$/.test(s)); + + fc.assert( + fc.property(invalidOrderIdArb, (value) => { + expect(validateOrderId(value)).toBe(false); + }), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Feature: orders-ui, Property 8: Quantity validation + * + * For any integer >= 1, the quantityOrdered validator should accept it. + * For any non-integer number or value < 1, the validator should reject it. + * + * **Validates: Requirements 4.6** + */ + +/** Inline validation function matching the frontend implementation */ +function validateQuantity(value: string): boolean { + const num = Number(value); + return Number.isInteger(num) && num >= 1; +} + +describe("Feature: orders-ui, Property 8: Quantity validation", () => { + it("should accept any integer >= 1 passed as a string", () => { + // Generate integers >= 1 and pass them as strings (simulating form input) + const validQuantityArb = fc.integer({ min: 1, max: 1_000_000 }).map(String); + + fc.assert( + fc.property(validQuantityArb, (value) => { + expect(validateQuantity(value)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + it("should reject integers < 1 passed as strings", () => { + // Generate integers < 1 (0, -1, -100, etc.) + const invalidIntArb = fc.integer({ min: -1_000_000, max: 0 }).map(String); + + fc.assert( + fc.property(invalidIntArb, (value) => { + expect(validateQuantity(value)).toBe(false); + }), + { numRuns: 100 }, + ); + }); + + it("should reject non-integer numbers passed as strings", () => { + // Generate floating-point numbers that are not integers (e.g., 1.5, 2.7, -0.3) + const nonIntegerArb = fc + .tuple(fc.integer({ min: -10000, max: 10000 }), fc.integer({ min: 1, max: 99 })) + .map(([whole, frac]) => `${whole}.${frac}`); + + fc.assert( + fc.property(nonIntegerArb, (value) => { + expect(validateQuantity(value)).toBe(false); + }), + { numRuns: 100 }, + ); + }); + + it("should reject non-numeric strings", () => { + // Generate strings that are not valid numbers + const nonNumericArb = fc.oneof( + fc.string().filter((s) => isNaN(Number(s)) || s.trim() === ""), + fc.constant("abc"), + fc.constant(""), + fc.constant("hello world"), + fc.constant("1.2.3"), + fc.constant("NaN"), + ); + + fc.assert( + fc.property(nonNumericArb, (value) => { + expect(validateQuantity(value)).toBe(false); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/validation/ordersValidation.test.ts b/local-ai-sandbox/test/validation/ordersValidation.test.ts new file mode 100644 index 000000000..c2b6e246f --- /dev/null +++ b/local-ai-sandbox/test/validation/ordersValidation.test.ts @@ -0,0 +1,419 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { executeValidation } from "../../src/service/validationEngine.js"; +import { RequestContext, ValidationFail, ValidationPass } from "../../src/validation/validationTypes.js"; +import { Api } from "../../src/database/Context.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; + +// Mutable mock database state — tests can populate this before assertions +const mockDbData: Record> = {}; + +// Mock the Context singleton so database access uses our mutable mockDbData +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { + get instance() { + return { + engine: { + get: (api: string, key: string) => mockDbData[api]?.[key] ?? null, + }, + }; + }, + }, + }; +}); + +beforeEach(() => { + // Reset all API partitions in mock database + for (const api of Object.values(Api)) { + mockDbData[api] = {}; + } +}); + +// --- searchOrders validation pipeline integration tests --- + +describe("searchOrders validation pipeline", () => { + function buildSearchOrdersContext(queryParams: Record): RequestContext { + const filteredParams: Record = {}; + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined) { + filteredParams[key] = value; + } + } + return { + apiName: "Orders", + apiVersion: "2026-01-01", + operationId: "searchOrders", + method: "GET", + pathParams: {}, + queryParams: filteredParams, + body: undefined, + }; + } + + it("returns 400 when both createdAfter and lastUpdatedAfter are present (mutualExclusivity)", async () => { + const context = buildSearchOrdersContext({ + createdAfter: "2024-01-01T00:00:00Z", + lastUpdatedAfter: "2024-02-01T00:00:00Z", + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("mutually exclusive"); + }); + + it("returns 400 when no date filters are present (atLeastOneRequired)", async () => { + const context = buildSearchOrdersContext({}); + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + }); + + it("passes with valid single date filter (createdAfter only)", async () => { + const context = buildSearchOrdersContext({ + createdAfter: "2024-01-01T00:00:00Z", + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("passes with valid single date filter (lastUpdatedAfter only)", async () => { + const context = buildSearchOrdersContext({ + lastUpdatedAfter: "2024-03-15T00:00:00Z", + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("returns 400 when createdAfter is after createdBefore (invalid date ordering)", async () => { + const context = buildSearchOrdersContext({ + createdAfter: "2024-06-01T00:00:00Z", + createdBefore: "2024-01-01T00:00:00Z", + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("createdAfter"); + expect(fail.body.errors[0].message).toContain("createdBefore"); + }); + + it("returns 400 when createdAfter contains an unparseable date value", async () => { + const context = buildSearchOrdersContext({ + createdAfter: "not-a-date", + createdBefore: "2024-06-01T00:00:00Z", + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("unparseable date"); + }); + + it("returns 400 when createdBefore contains an unparseable date value", async () => { + const context = buildSearchOrdersContext({ + createdAfter: "2024-01-01T00:00:00Z", + createdBefore: "invalid-date-string", + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("unparseable date"); + }); + + it("passes when createdBefore is absent (skips date comparison)", async () => { + const context = buildSearchOrdersContext({ + createdAfter: "2024-01-01T00:00:00Z", + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + const pass = result as ValidationPass; + expect(pass.resolvedEntities).toBeDefined(); + }); + + it("passes when createdAfter equals createdBefore (beforeOrEqual)", async () => { + const context = buildSearchOrdersContext({ + createdAfter: "2024-03-15T00:00:00Z", + createdBefore: "2024-03-15T00:00:00Z", + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("passes when createdAfter is before createdBefore", async () => { + const context = buildSearchOrdersContext({ + createdAfter: "2024-01-01T00:00:00Z", + createdBefore: "2024-06-01T00:00:00Z", + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("returns 400 when lastUpdatedAfter is after lastUpdatedBefore (invalid date ordering)", async () => { + const context = buildSearchOrdersContext({ + lastUpdatedAfter: "2024-06-01T00:00:00Z", + lastUpdatedBefore: "2024-01-01T00:00:00Z", + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("lastUpdatedAfter"); + expect(fail.body.errors[0].message).toContain("lastUpdatedBefore"); + }); + + it("passes when lastUpdatedBefore is absent (skips date comparison)", async () => { + const context = buildSearchOrdersContext({ + lastUpdatedAfter: "2024-01-01T00:00:00Z", + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + const pass = result as ValidationPass; + expect(pass.resolvedEntities).toBeDefined(); + }); +}); + + +// --- getOrder validation pipeline integration tests --- + +describe("getOrder validation pipeline", () => { + const TEST_ORDER_ID = "test-order-get-001"; + const TEST_ORDER = { + orderId: TEST_ORDER_ID, + fulfillment: { fulfilledBy: "MERCHANT", fulfillmentStatus: "SHIPPED" }, + }; + + it("returns 404 when order does not exist", async () => { + mockDbData[Api.ORDERS] = {}; + + const context: RequestContext = { + apiName: "Orders", + apiVersion: "2026-01-01", + operationId: "getOrder", + method: "GET", + pathParams: { orderId: "nonexistent-order-id" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(404); + expect(fail.body.errors[0].code).toBe("NotFound"); + expect(fail.body.errors[0].message).toContain("order"); + expect(fail.body.errors[0].message).toContain("nonexistent-order-id"); + }); + + it("passes with resolved entity when order exists", async () => { + mockDbData[Api.ORDERS] = { [TEST_ORDER_ID]: TEST_ORDER }; + + const context: RequestContext = { + apiName: "Orders", + apiVersion: "2026-01-01", + operationId: "getOrder", + method: "GET", + pathParams: { orderId: TEST_ORDER_ID }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + + expect(result.pass).toBe(true); + const pass = result as ValidationPass; + expect(pass.resolvedEntities).toBeDefined(); + expect(pass.resolvedEntities["order"]).toBeDefined(); + expect(pass.resolvedEntities["order"].orderId).toBe(TEST_ORDER_ID); + }); + + it("pipeline has exactly 1 rule (entityExistence)", async () => { + // Import the registry to check pipeline structure + const { VALIDATION_REGISTRY } = await import("../../src/validation/validationRegistry.js"); + const pipeline = VALIDATION_REGISTRY.get("Orders:2026-01-01:getOrder"); + + expect(pipeline).toBeDefined(); + expect(pipeline!.length).toBe(1); + expect(pipeline![0].checkType).toBe("entityExistence"); + }); +}); + + +// --- confirmShipment orderItemId and quantity validation integration tests --- + +describe("confirmShipment orderItemId and quantity validation", () => { + const TEST_ORDER_ID = "test-order-confirm-001"; + + const VALID_ORDER = { + orderId: TEST_ORDER_ID, + fulfillment: { fulfilledBy: "MERCHANT", fulfillmentStatus: "UNSHIPPED" }, + orderItems: [ + { orderItemId: "item-001", quantityOrdered: 5 }, + { orderItemId: "item-002", quantityOrdered: 3 }, + ], + }; + + function buildConfirmShipmentContext(orderId: string, body?: Record): RequestContext { + return { + apiName: "Orders", + apiVersion: "v0", + operationId: "confirmShipment", + method: "POST", + pathParams: { orderId }, + queryParams: {}, + body, + }; + } + + it("returns 400 InvalidInput when orderItemId is not present in the resolved order", async () => { + mockDbData[Api.ORDERS] = { [TEST_ORDER_ID]: VALID_ORDER }; + + const context = buildConfirmShipmentContext(TEST_ORDER_ID, { + packageDetail: { + orderItems: [{ orderItemId: "nonexistent-item-999", quantity: 1 }], + }, + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("nonexistent-item-999"); + expect(fail.body.errors[0].message).toContain("not found"); + }); + + it("returns 400 InvalidInput when quantity exceeds quantityOrdered", async () => { + mockDbData[Api.ORDERS] = { [TEST_ORDER_ID]: VALID_ORDER }; + + const context = buildConfirmShipmentContext(TEST_ORDER_ID, { + packageDetail: { + orderItems: [{ orderItemId: "item-001", quantity: 10 }], + }, + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("item-001"); + expect(fail.body.errors[0].message).toContain("exceeds"); + }); + + it("passes validation with valid orderItemId and quantity ≤ quantityOrdered", async () => { + mockDbData[Api.ORDERS] = { [TEST_ORDER_ID]: VALID_ORDER }; + + const context = buildConfirmShipmentContext(TEST_ORDER_ID, { + packageDetail: { + orderItems: [ + { orderItemId: "item-001", quantity: 3 }, + { orderItemId: "item-002", quantity: 2 }, + ], + }, + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + const pass = result as ValidationPass; + expect(pass.resolvedEntities).toBeDefined(); + expect(pass.resolvedEntities["order"]).toBeDefined(); + expect(pass.resolvedEntities["order"].orderId).toBe(TEST_ORDER_ID); + }); + + it("short-circuits with 404 when order does not exist (entityExistence fails before orderItemId/quantity checks)", async () => { + mockDbData[Api.ORDERS] = {}; + + const context = buildConfirmShipmentContext("nonexistent-order", { + packageDetail: { + orderItems: [{ orderItemId: "any-item", quantity: 1 }], + }, + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(404); + expect(fail.body.errors[0].code).toBe("NotFound"); + }); + + it("short-circuits with 400 FBA error when order is FBA (fails before orderItemId/quantity checks)", async () => { + const fbaOrder = { + orderId: TEST_ORDER_ID, + fulfillment: { fulfilledBy: "AMAZON", fulfillmentStatus: "UNSHIPPED" }, + orderItems: [{ orderItemId: "item-001", quantityOrdered: 5 }], + }; + mockDbData[Api.ORDERS] = { [TEST_ORDER_ID]: fbaOrder }; + + const context = buildConfirmShipmentContext(TEST_ORDER_ID, { + packageDetail: { + orderItems: [{ orderItemId: "item-001", quantity: 1 }], + }, + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("FBA"); + }); + + it("short-circuits with 400 status error when order has non-shippable status (fails before orderItemId/quantity checks)", async () => { + const shippedOrder = { + orderId: TEST_ORDER_ID, + fulfillment: { fulfilledBy: "MERCHANT", fulfillmentStatus: "SHIPPED" }, + orderItems: [{ orderItemId: "item-001", quantityOrdered: 5 }], + }; + mockDbData[Api.ORDERS] = { [TEST_ORDER_ID]: shippedOrder }; + + const context = buildConfirmShipmentContext(TEST_ORDER_ID, { + packageDetail: { + orderItems: [{ orderItemId: "item-001", quantity: 1 }], + }, + }); + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + expect(fail.body.errors[0].message).toContain("UNSHIPPED"); + expect(fail.body.errors[0].message).toContain("PARTIALLY_SHIPPED"); + }); + + it("confirmShipment pipeline has 5 rules in correct order: entityExistence, businessRule(FBA), businessRule(status), orderItemExistence, quantityLimit", () => { + const pipeline = VALIDATION_REGISTRY.get("Orders:v0:confirmShipment"); + + expect(pipeline).toBeDefined(); + expect(pipeline!.length).toBe(5); + expect(pipeline![0].checkType).toBe("entityExistence"); + expect(pipeline![1].checkType).toBe("businessRule"); + expect(pipeline![2].checkType).toBe("businessRule"); + expect(pipeline![3].checkType).toBe("orderItemExistence"); + expect(pipeline![4].checkType).toBe("quantityLimit"); + }); +}); diff --git a/local-ai-sandbox/test/validation/resolvedEntities.test.ts b/local-ai-sandbox/test/validation/resolvedEntities.test.ts new file mode 100644 index 000000000..a232ec0c2 --- /dev/null +++ b/local-ai-sandbox/test/validation/resolvedEntities.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { executeValidation } from "../../src/service/validationEngine.js"; +import { RequestContext, ValidationFail, ValidationPass, ValidationPipeline } from "../../src/validation/validationTypes.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; +import { Api } from "../../src/database/Context.js"; +import { buildKey } from "../../src/registry/operationRegistry.js"; + +// Mutable mock database state — tests can populate this before assertions +const mockDbData: Record> = {}; + +// Mock the validation registry so we can inject test pipelines +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + VALIDATION_REGISTRY: new Map(), + }; +}); + +// Mock the Context singleton so database access uses our mutable mockDbData +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { + get instance() { + return { + engine: { + get: (api: string, key: string) => mockDbData[api]?.[key] ?? null, + }, + }; + }, + }, + }; +}); + +beforeEach(() => { + for (const api of Object.values(Api)) { + mockDbData[api] = {}; + } + VALIDATION_REGISTRY.clear(); +}); + +const TEST_API_NAME = "TestApi"; +const TEST_API_VERSION = "v1"; + +describe("resolvedEntities behavior", () => { + describe("entityExistence handler returns resolved entity data", () => { + it("returns resolved entity data on flat pass", async () => { + const operationId = "__resolvedEntities_flat__"; + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path", entityLabel: "order" }, + failAction: { statusCode: 404, code: "NotFound", message: "Order not found" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, operationId), pipeline); + + mockDbData[Api.ORDERS] = { + "order-100": { orderId: "order-100", status: "Shipped", amount: 99.99 }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId, + method: "GET", + pathParams: { orderId: "order-100" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + const passResult = result as ValidationPass; + expect(passResult.resolvedEntities).toHaveProperty("order"); + expect(passResult.resolvedEntities["order"]).toEqual({ orderId: "order-100", status: "Shipped", amount: 99.99 }); + }); + + it("returns both parent and child on nested pass", async () => { + const operationId = "__resolvedEntities_nested__"; + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.EXT_FULFILLMENT_SHIPMENTS, paramName: "shipmentId", paramSource: "path", entityLabel: "shipment" }, + nested: { + childParamName: "packageId", + childParamSource: "path", + childCollection: "packages", + childIdField: "id", + childLabel: "package", + }, + failAction: { statusCode: 404, code: "NotFound", message: "Not found" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, operationId), pipeline); + + mockDbData[Api.EXT_FULFILLMENT_SHIPMENTS] = { + "ship-001": { shipmentId: "ship-001", carrier: "UPS", packages: [{ id: "pkg-A", weight: 5 }, { id: "pkg-B", weight: 10 }] }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId, + method: "GET", + pathParams: { shipmentId: "ship-001", packageId: "pkg-A" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + const passResult = result as ValidationPass; + expect(passResult.resolvedEntities).toHaveProperty("shipment"); + expect(passResult.resolvedEntities).toHaveProperty("package"); + expect(passResult.resolvedEntities["shipment"]).toEqual({ shipmentId: "ship-001", carrier: "UPS", packages: [{ id: "pkg-A", weight: 5 }, { id: "pkg-B", weight: 10 }] }); + expect(passResult.resolvedEntities["package"]).toEqual({ id: "pkg-A", weight: 5 }); + }); + + it("returns empty resolvedEntities when entity ID is absent", async () => { + const operationId = "__resolvedEntities_absent_id__"; + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path", entityLabel: "order" }, + failAction: { statusCode: 404, code: "NotFound", message: "Order not found" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, operationId), pipeline); + + mockDbData[Api.ORDERS] = { "order-xyz": { orderId: "order-xyz" } }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + const passResult = result as ValidationPass; + expect(passResult.resolvedEntities).toEqual({}); + }); + }); + + describe("executeValidation accumulates resolvedEntities", () => { + it("accumulates resolvedEntities across multiple passing entity rules", async () => { + const operationId = "__resolvedEntities_accumulate__"; + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path", entityLabel: "order" }, + failAction: { statusCode: 404, code: "NotFound", message: "Order not found" }, + }, + { + checkType: "entityExistence", + entity: { api: Api.LISTINGS, paramName: "sku", paramSource: "query", entityLabel: "listing" }, + failAction: { statusCode: 404, code: "NotFound", message: "Listing not found" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, operationId), pipeline); + + mockDbData[Api.ORDERS] = { + "order-200": { orderId: "order-200", status: "Pending" }, + }; + mockDbData[Api.LISTINGS] = { + "SKU-ABC": { sku: "SKU-ABC", title: "Widget" }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId, + method: "GET", + pathParams: { orderId: "order-200" }, + queryParams: { sku: "SKU-ABC" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + const passResult = result as ValidationPass; + expect(passResult.resolvedEntities).toHaveProperty("order"); + expect(passResult.resolvedEntities).toHaveProperty("listing"); + expect(passResult.resolvedEntities["order"]).toEqual({ orderId: "order-200", status: "Pending" }); + expect(passResult.resolvedEntities["listing"]).toEqual({ sku: "SKU-ABC", title: "Widget" }); + }); + + it("returns empty resolvedEntities for pipelines with only parameter constraint rules", async () => { + const operationId = "__resolvedEntities_param_only__"; + const pipeline: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [ + { name: "keywords", source: "query" }, + { name: "identifiers", source: "query" }, + ], + failAction: { statusCode: 400, code: "InvalidInput", message: "At least one required" }, + }, + { + checkType: "mutualExclusivity", + params: [ + { name: "filterA", source: "query" }, + { name: "filterB", source: "query" }, + ], + failAction: { statusCode: 400, code: "InvalidInput", message: "Mutually exclusive" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, operationId), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId, + method: "GET", + pathParams: {}, + queryParams: { keywords: "laptop", filterA: "value" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + const passResult = result as ValidationPass; + expect(passResult.resolvedEntities).toEqual({}); + }); + + it("returns failure when no pipeline is registered", async () => { + const context: RequestContext = { + apiName: "UnknownApi", + apiVersion: "v99", + operationId: "unregisteredOperation_xyz_99999", + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const failResult = result as ValidationFail; + expect(failResult.statusCode).toBe(501); + expect(failResult.body?.errors[0].code).toBe("NoValidationPipeline"); + }); + }); + + describe("businessRule handler reads from resolvedEntities", () => { + it("reads entity from resolvedEntities instead of re-querying DB when available", async () => { + const operationId = "__resolvedEntities_business_rule__"; + // Pipeline: entityExistence resolves order, then businessRule uses it + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path", entityLabel: "order" }, + failAction: { statusCode: 404, code: "NotFound", message: "Order not found" }, + }, + { + checkType: "businessRule", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path" }, + condition: { field: "fulfillment.fulfilledBy", operator: "eq", value: "AMAZON" }, + failAction: { statusCode: 400, code: "InvalidInput", message: "FBA orders cannot confirm shipment" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, operationId), pipeline); + + // Put entity in DB with MERCHANT fulfillment (should pass business rule) + mockDbData[Api.ORDERS] = { + "order-300": { orderId: "order-300", fulfillment: { fulfilledBy: "MERCHANT" } }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId, + method: "POST", + pathParams: { orderId: "order-300" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + + // Now remove the entity from the DB — the business rule should still work + // because it reads from resolvedEntities populated by the prior entityExistence rule + mockDbData[Api.ORDERS] = {}; + + const result2 = await executeValidation(context); + // Without resolvedEntities, the entityExistence rule itself will fail (404) + // because the entity is no longer in the DB + expect(result2.pass).toBe(false); + }); + + it("businessRule uses resolvedEntities data and correctly evaluates condition", async () => { + const operationId = "__resolvedEntities_biz_eval__"; + // Pipeline: entityExistence resolves order, then businessRule checks it + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path", entityLabel: "order" }, + failAction: { statusCode: 404, code: "NotFound", message: "Order not found" }, + }, + { + checkType: "businessRule", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path" }, + condition: { field: "fulfillment.fulfilledBy", operator: "eq", value: "AMAZON" }, + failAction: { statusCode: 400, code: "InvalidInput", message: "FBA orders cannot confirm shipment" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, operationId), pipeline); + + // Entity is FBA — business rule condition IS satisfied → should fail + mockDbData[Api.ORDERS] = { + "order-400": { orderId: "order-400", fulfillment: { fulfilledBy: "AMAZON" } }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId, + method: "POST", + pathParams: { orderId: "order-400" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + }); + }); + + describe("last-write-wins semantics", () => { + it("later entity existence rule overwrites earlier entry with same label", async () => { + const operationId = "__resolvedEntities_lastwrite__"; + // Two entityExistence rules with the same entityLabel "order" but pointing to different APIs/entities + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path", entityLabel: "order" }, + failAction: { statusCode: 404, code: "NotFound", message: "Order not found" }, + }, + { + checkType: "entityExistence", + entity: { api: Api.LISTINGS, paramName: "listingId", paramSource: "query", entityLabel: "order" }, + failAction: { statusCode: 404, code: "NotFound", message: "Listing not found" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, operationId), pipeline); + + mockDbData[Api.ORDERS] = { + "order-first": { orderId: "order-first", source: "first-rule" }, + }; + mockDbData[Api.LISTINGS] = { + "listing-second": { listingId: "listing-second", source: "second-rule" }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId, + method: "GET", + pathParams: { orderId: "order-first" }, + queryParams: { listingId: "listing-second" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + const passResult = result as ValidationPass; + // The second rule overwrites the "order" key with its entity data + expect(passResult.resolvedEntities["order"]).toEqual({ listingId: "listing-second", source: "second-rule" }); + // Confirm that the first rule's data is NOT present + expect(passResult.resolvedEntities["order"]).not.toHaveProperty("orderId"); + }); + }); +}); diff --git a/local-ai-sandbox/test/validation/ruleHandlers.test.ts b/local-ai-sandbox/test/validation/ruleHandlers.test.ts new file mode 100644 index 000000000..41dd1870d --- /dev/null +++ b/local-ai-sandbox/test/validation/ruleHandlers.test.ts @@ -0,0 +1,876 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { executeValidation } from "../../src/service/validationEngine.js"; +import { RequestContext, ValidationFail, ValidationPipeline } from "../../src/validation/validationTypes.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; +import { Api } from "../../src/database/Context.js"; +import { buildKey } from "../../src/registry/operationRegistry.js"; + +// Mutable mock database state — tests can populate this before assertions +const mockDbData: Record> = {}; + +// Mock the validation registry so we can inject test pipelines +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + VALIDATION_REGISTRY: new Map(), + }; +}); + +// Mock the Context singleton so database access uses our mutable mockDbData +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { + get instance() { + return { + engine: { + get: (api: string, key: string) => mockDbData[api]?.[key] ?? null, + }, + }; + }, + }, + }; +}); + +beforeEach(() => { + // Reset all API partitions in mock database + for (const api of Object.values(Api)) { + mockDbData[api] = {}; + } + VALIDATION_REGISTRY.clear(); +}); + +// Test helper constants for composite key +const TEST_API_NAME = "TestApi"; +const TEST_API_VERSION = "v1"; + +// --- entityExistence handler tests --- + +describe("entityExistence handler", () => { + const TEST_OP = "__test_entityExistence__"; + + it("passes when entity exists (flat lookup)", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path", entityLabel: "order" }, + failAction: { statusCode: 404, code: "NotFound", message: "Order not found" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.ORDERS] = { "order-123": { orderId: "order-123" } }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: { orderId: "order-123" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("returns 404 when entity does not exist (flat lookup)", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path", entityLabel: "order" }, + failAction: { statusCode: 404, code: "NotFound", message: "Order not found" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.ORDERS] = {}; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: { orderId: "nonexistent-123" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(404); + expect(fail.body.errors[0].message).toContain("order"); + expect(fail.body.errors[0].message).toContain("nonexistent-123"); + }); + + it("passes when parent and child both exist (nested)", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.EXT_FULFILLMENT_SHIPMENTS, paramName: "shipmentId", paramSource: "path", entityLabel: "shipment" }, + nested: { + childParamName: "packageId", + childParamSource: "path", + childCollection: "packages", + childIdField: "id", + childLabel: "package", + }, + failAction: { statusCode: 404, code: "NotFound", message: "Not found" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.EXT_FULFILLMENT_SHIPMENTS] = { + "ship-001": { shipmentId: "ship-001", packages: [{ id: "pkg-001" }, { id: "pkg-002" }] }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: { shipmentId: "ship-001", packageId: "pkg-001" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("returns 404 for missing parent (nested)", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.EXT_FULFILLMENT_SHIPMENTS, paramName: "shipmentId", paramSource: "path", entityLabel: "shipment" }, + nested: { + childParamName: "packageId", + childParamSource: "path", + childCollection: "packages", + childIdField: "id", + childLabel: "package", + }, + failAction: { statusCode: 404, code: "NotFound", message: "Not found" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.EXT_FULFILLMENT_SHIPMENTS] = {}; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: { shipmentId: "ship-nonexistent", packageId: "pkg-001" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(404); + expect(fail.body.errors[0].message).toContain("shipment"); + }); + + it("returns 404 for missing child (nested)", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.EXT_FULFILLMENT_SHIPMENTS, paramName: "shipmentId", paramSource: "path", entityLabel: "shipment" }, + nested: { + childParamName: "packageId", + childParamSource: "path", + childCollection: "packages", + childIdField: "id", + childLabel: "package", + }, + failAction: { statusCode: 404, code: "NotFound", message: "Not found" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.EXT_FULFILLMENT_SHIPMENTS] = { + "ship-001": { shipmentId: "ship-001", packages: [{ id: "pkg-002" }] }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: { shipmentId: "ship-001", packageId: "pkg-nonexistent" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(404); + expect(fail.body.errors[0].message).toContain("package"); + }); + + it("passes when entity ID param is not provided", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path", entityLabel: "order" }, + failAction: { statusCode: 404, code: "NotFound", message: "Order not found" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.ORDERS] = {}; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); +}); + +// --- mutualExclusivity handler tests --- + +describe("mutualExclusivity handler", () => { + const TEST_OP = "__test_mutualExclusivity__"; + + it("passes when exactly one param is present", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "mutualExclusivity", + params: [ + { name: "createdAfter", source: "query" }, + { name: "lastUpdatedAfter", source: "query" }, + ], + failAction: { statusCode: 400, code: "InvalidInput", message: "Mutually exclusive" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { createdAfter: "2024-01-01" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails with 400 when zero params are present", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "mutualExclusivity", + params: [ + { name: "createdAfter", source: "query" }, + { name: "lastUpdatedAfter", source: "query" }, + ], + failAction: { statusCode: 400, code: "InvalidInput", message: "Mutually exclusive" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("createdAfter"); + expect(fail.body.errors[0].message).toContain("lastUpdatedAfter"); + }); + + it("fails with 400 when multiple params are present", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "mutualExclusivity", + params: [ + { name: "createdAfter", source: "query" }, + { name: "lastUpdatedAfter", source: "query" }, + ], + failAction: { statusCode: 400, code: "InvalidInput", message: "Mutually exclusive" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { createdAfter: "2024-01-01", lastUpdatedAfter: "2024-02-01" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("createdAfter"); + expect(fail.body.errors[0].message).toContain("lastUpdatedAfter"); + }); + + it("treats empty string param as absent", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "mutualExclusivity", + params: [ + { name: "paramA", source: "query" }, + { name: "paramB", source: "query" }, + ], + failAction: { statusCode: 400, code: "InvalidInput", message: "Mutually exclusive" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { paramA: "value", paramB: "" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); +}); + +// --- atLeastOneRequired handler tests --- + +describe("atLeastOneRequired handler", () => { + const TEST_OP = "__test_atLeastOneRequired__"; + + it("passes when at least one param is present", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [ + { name: "keywords", source: "query" }, + { name: "identifiers", source: "query" }, + { name: "brandNames", source: "query" }, + ], + failAction: { statusCode: 400, code: "InvalidInput", message: "At least one required" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { keywords: "laptop" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("passes when multiple params are present", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [ + { name: "keywords", source: "query" }, + { name: "identifiers", source: "query" }, + ], + failAction: { statusCode: 400, code: "InvalidInput", message: "At least one required" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { keywords: "laptop", identifiers: "B001" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails with 400 when no params are present", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [ + { name: "keywords", source: "query" }, + { name: "identifiers", source: "query" }, + { name: "brandNames", source: "query" }, + ], + failAction: { statusCode: 400, code: "InvalidInput", message: "At least one required" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("keywords"); + expect(fail.body.errors[0].message).toContain("identifiers"); + expect(fail.body.errors[0].message).toContain("brandNames"); + }); + + it("treats empty string params as absent", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [ + { name: "keywords", source: "query" }, + { name: "identifiers", source: "query" }, + ], + failAction: { statusCode: 400, code: "InvalidInput", message: "At least one required" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { keywords: "", identifiers: "" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + }); +}); + +// --- conditionalExclusion handler tests --- + +describe("conditionalExclusion handler", () => { + const TEST_OP = "__test_conditionalExclusion__"; + + it("passes when trigger is absent", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "conditionalExclusion", + trigger: { name: "createdAfter", source: "query" }, + forbidden: [{ name: "lastUpdatedAfter", source: "query" }], + failAction: { statusCode: 400, code: "InvalidInput", message: "Conditional exclusion violated" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { lastUpdatedAfter: "2024-01-01" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("passes when trigger is present but forbidden params are absent", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "conditionalExclusion", + trigger: { name: "createdAfter", source: "query" }, + forbidden: [ + { name: "lastUpdatedAfter", source: "query" }, + { name: "anotherParam", source: "query" }, + ], + failAction: { statusCode: 400, code: "InvalidInput", message: "Conditional exclusion violated" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { createdAfter: "2024-01-01" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails with 400 when trigger is present and forbidden param is also present", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "conditionalExclusion", + trigger: { name: "createdAfter", source: "query" }, + forbidden: [{ name: "lastUpdatedAfter", source: "query" }], + failAction: { statusCode: 400, code: "InvalidInput", message: "Conditional exclusion violated" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { createdAfter: "2024-01-01", lastUpdatedAfter: "2024-02-01" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].message).toContain("createdAfter"); + expect(fail.body.errors[0].message).toContain("lastUpdatedAfter"); + }); + + it("passes when trigger is empty string (treated as absent)", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "conditionalExclusion", + trigger: { name: "createdAfter", source: "query" }, + forbidden: [{ name: "lastUpdatedAfter", source: "query" }], + failAction: { statusCode: 400, code: "InvalidInput", message: "Conditional exclusion violated" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "GET", + pathParams: {}, + queryParams: { createdAfter: "", lastUpdatedAfter: "2024-02-01" }, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); +}); + +// --- businessRule handler tests --- + +describe("businessRule handler", () => { + const TEST_OP = "__test_businessRule__"; + + it("fails when condition with 'eq' operator is satisfied (constraint violated)", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "businessRule", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path" }, + condition: { field: "fulfillment.fulfilledBy", operator: "eq", value: "AMAZON" }, + failAction: { statusCode: 400, code: "InvalidInput", message: "FBA orders cannot confirm shipment" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.ORDERS] = { + "order-123": { orderId: "order-123", fulfillment: { fulfilledBy: "AMAZON" } }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "POST", + pathParams: { orderId: "order-123" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + }); + + it("passes when condition with 'eq' operator is NOT satisfied", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "businessRule", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path" }, + condition: { field: "fulfillment.fulfilledBy", operator: "eq", value: "AMAZON" }, + failAction: { statusCode: 400, code: "InvalidInput", message: "FBA orders cannot confirm shipment" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.ORDERS] = { + "order-123": { orderId: "order-123", fulfillment: { fulfilledBy: "MERCHANT" } }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "POST", + pathParams: { orderId: "order-123" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when condition with 'neq' operator is satisfied", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "businessRule", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path" }, + condition: { field: "status", operator: "neq", value: "SHIPPED" }, + failAction: { statusCode: 400, code: "InvalidInput", message: "Only shipped orders allowed" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.ORDERS] = { + "order-456": { orderId: "order-456", status: "PENDING" }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "POST", + pathParams: { orderId: "order-456" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + }); + + it("passes when condition with 'neq' operator is NOT satisfied", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "businessRule", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path" }, + condition: { field: "status", operator: "neq", value: "SHIPPED" }, + failAction: { statusCode: 400, code: "InvalidInput", message: "Only shipped orders allowed" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.ORDERS] = { + "order-456": { orderId: "order-456", status: "SHIPPED" }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "POST", + pathParams: { orderId: "order-456" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when condition with 'in' operator is satisfied", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "businessRule", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path" }, + condition: { field: "status", operator: "in", value: ["CANCELLED", "RETURNED"] }, + failAction: { statusCode: 400, code: "InvalidInput", message: "Cannot process cancelled/returned orders" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.ORDERS] = { + "order-789": { orderId: "order-789", status: "CANCELLED" }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "POST", + pathParams: { orderId: "order-789" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + }); + + it("passes when condition with 'in' operator is NOT satisfied", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "businessRule", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path" }, + condition: { field: "status", operator: "in", value: ["CANCELLED", "RETURNED"] }, + failAction: { statusCode: 400, code: "InvalidInput", message: "Cannot process cancelled/returned orders" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.ORDERS] = { + "order-789": { orderId: "order-789", status: "SHIPPED" }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "POST", + pathParams: { orderId: "order-789" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("fails when condition with 'notIn' operator is satisfied", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "businessRule", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path" }, + condition: { field: "status", operator: "notIn", value: ["SHIPPED", "DELIVERED"] }, + failAction: { statusCode: 400, code: "InvalidInput", message: "Order must be shipped or delivered" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.ORDERS] = { + "order-abc": { orderId: "order-abc", status: "PENDING" }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "POST", + pathParams: { orderId: "order-abc" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + }); + + it("passes when condition with 'notIn' operator is NOT satisfied", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "businessRule", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path" }, + condition: { field: "status", operator: "notIn", value: ["SHIPPED", "DELIVERED"] }, + failAction: { statusCode: 400, code: "InvalidInput", message: "Order must be shipped or delivered" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.ORDERS] = { + "order-abc": { orderId: "order-abc", status: "SHIPPED" }, + }; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "POST", + pathParams: { orderId: "order-abc" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("skips evaluation (passes) when entity is not found in database", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "businessRule", + entity: { api: Api.ORDERS, paramName: "orderId", paramSource: "path" }, + condition: { field: "fulfillment.fulfilledBy", operator: "eq", value: "AMAZON" }, + failAction: { statusCode: 400, code: "InvalidInput", message: "FBA orders cannot confirm shipment" }, + }, + ]; + VALIDATION_REGISTRY.set(buildKey(TEST_API_NAME, TEST_API_VERSION, TEST_OP), pipeline); + + mockDbData[Api.ORDERS] = {}; + + const context: RequestContext = { + apiName: TEST_API_NAME, + apiVersion: TEST_API_VERSION, + operationId: TEST_OP, + method: "POST", + pathParams: { orderId: "nonexistent-order" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); +}); diff --git a/local-ai-sandbox/test/validation/stringLengthLimit.test.ts b/local-ai-sandbox/test/validation/stringLengthLimit.test.ts new file mode 100644 index 000000000..116ae4280 --- /dev/null +++ b/local-ai-sandbox/test/validation/stringLengthLimit.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import * as fc from "fast-check"; +import { executeValidation } from "../../src/service/validationEngine.js"; +import { RequestContext, ValidationFail, ValidationPipeline } from "../../src/validation/validationTypes.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; +import { buildKey } from "../../src/registry/operationRegistry.js"; + +// Inject test pipelines into an empty registry. +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, VALIDATION_REGISTRY: new Map() }; +}); + +// The engine touches Context for other rule types; stringLengthLimit does not use it. +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Context: { get instance() { return { engine: { get: () => null, find: () => [] } }; } }, + }; +}); + +const API = "Test"; +const VERSION = "v1"; +const OP = "__stringLengthLimit__"; + +function pipeline(max: number, normalizeWhitespace: boolean): ValidationPipeline { + return [ + { + checkType: "stringLengthLimit", + param: { name: "query", source: "body" }, + max, + normalizeWhitespace, + failAction: { statusCode: 400, code: "InvalidInput", message: `must be at most ${String(max)} characters` }, + }, + ]; +} + +function ctx(query: unknown): RequestContext { + return { apiName: API, apiVersion: VERSION, operationId: OP, method: "POST", pathParams: {}, queryParams: {}, body: query === undefined ? {} : { query } }; +} + +beforeEach(() => { + VALIDATION_REGISTRY.clear(); +}); + +describe("stringLengthLimit rule handler", () => { + it("passes when length is within the limit", async () => { + VALIDATION_REGISTRY.set(buildKey(API, VERSION, OP), pipeline(10, false)); + expect((await executeValidation(ctx("short"))).pass).toBe(true); + }); + + it("fails with the configured failAction when length exceeds the limit", async () => { + VALIDATION_REGISTRY.set(buildKey(API, VERSION, OP), pipeline(5, false)); + const result = await executeValidation(ctx("waytoolong")); + expect(result.pass).toBe(false); + const fail = result as ValidationFail; + expect(fail.statusCode).toBe(400); + expect(fail.body.errors[0].code).toBe("InvalidInput"); + }); + + it("skips when the value is absent or empty (length check is not a presence check)", async () => { + VALIDATION_REGISTRY.set(buildKey(API, VERSION, OP), pipeline(3, false)); + expect((await executeValidation(ctx(undefined))).pass).toBe(true); + expect((await executeValidation(ctx(""))).pass).toBe(true); + }); + + it("normalizeWhitespace collapses runs of whitespace before measuring", async () => { + VALIDATION_REGISTRY.set(buildKey(API, VERSION, OP), pipeline(5, true)); + // Raw length 11, normalized "a b c" length 5 -> passes. + expect((await executeValidation(ctx("a b c"))).pass).toBe(true); + // Without normalization the same value would exceed 5. + VALIDATION_REGISTRY.set(buildKey(API, VERSION, OP), pipeline(5, false)); + expect((await executeValidation(ctx("a b c"))).pass).toBe(false); + }); + + // Feature: data-kiosk, Property 10: 8000-Character Boundary + // **Validates: Requirements 9.1** + it("Property 10: normalized length <= 8000 passes; > 8000 fails", async () => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 0, max: 16000 }), async (n) => { + VALIDATION_REGISTRY.set(buildKey(API, VERSION, OP), pipeline(8000, true)); + // A run of `n` non-space chars normalizes to length n. + const result = await executeValidation(ctx("x".repeat(n))); + expect(result.pass).toBe(n <= 8000); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/local-ai-sandbox/test/validation/validationRegistry.test.ts b/local-ai-sandbox/test/validation/validationRegistry.test.ts new file mode 100644 index 000000000..0989e5740 --- /dev/null +++ b/local-ai-sandbox/test/validation/validationRegistry.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { executeValidation } from "../../src/service/validationEngine.js"; +import { RequestContext, ValidationPipeline } from "../../src/validation/validationTypes.js"; +import { VALIDATION_REGISTRY } from "../../src/validation/validationRegistry.js"; +import { Api } from "../../src/database/Context.js"; +import { buildKey } from "../../src/registry/operationRegistry.js"; + +// Mock the validation registry so we can inject test pipelines +vi.mock("../../src/validation/validationRegistry.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + VALIDATION_REGISTRY: new Map(), + }; +}); + +// Mock the Context singleton so database access uses a controlled mock +vi.mock("../../src/database/Context.js", async (importOriginal) => { + const original = await importOriginal(); + const data: Record> = { + [original.Api.ORDERS]: { + "order-123": { orderId: "order-123", status: "Shipped" }, + "order-456": { orderId: "order-456", status: "Pending" }, + }, + [original.Api.LISTINGS]: {}, + [original.Api.INVENTORY]: {}, + [original.Api.EXT_FULFILLMENT_INVENTORY]: {}, + [original.Api.EXT_FULFILLMENT_RETURNS]: {}, + [original.Api.EXT_FULFILLMENT_SHIPMENTS]: {}, + [original.Api.CATALOG]: {}, + [original.Api.PRICING]: {}, + [original.Api.REPORTS]: {}, + }; + return { + ...original, + Context: { + get instance() { + return { + engine: { + get: (api: string, key: string) => data[api]?.[key] ?? null, + }, + }; + }, + }, + }; +}); + +describe("buildKey", () => { + it('returns "Orders:v0:confirmShipment" for apiName="Orders", apiVersion="v0", operationId="confirmShipment"', () => { + expect(buildKey("Orders", "v0", "confirmShipment")).toBe("Orders:v0:confirmShipment"); + }); + + it('returns "Orders:2026-01-01:getOrder" for apiName="Orders", apiVersion="2026-01-01", operationId="getOrder"', () => { + expect(buildKey("Orders", "2026-01-01", "getOrder")).toBe("Orders:2026-01-01:getOrder"); + }); + + it("handles empty strings", () => { + expect(buildKey("", "", "")).toBe("::"); + expect(buildKey("Orders", "", "getOrder")).toBe("Orders::getOrder"); + expect(buildKey("", "v0", "")).toBe(":v0:"); + }); + + it("handles special characters in components", () => { + expect(buildKey("Catalog Items", "2022-04-01", "searchCatalogItems")).toBe("Catalog Items:2022-04-01:searchCatalogItems"); + expect(buildKey("External Fulfillment Shipments", "2024-09-11", "getShipment")).toBe( + "External Fulfillment Shipments:2024-09-11:getShipment", + ); + }); + + it("preserves colons within components (no escaping)", () => { + // Edge case: if a component itself contains a colon, it still concatenates with colons + expect(buildKey("Api:Name", "v:1", "op:Id")).toBe("Api:Name:v:1:op:Id"); + }); +}); + +describe("Three-part composite key lookup via executeValidation", () => { + beforeEach(() => { + VALIDATION_REGISTRY.clear(); + }); + + it("finds the correct pipeline for a known apiName + apiVersion + operationId", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { + api: Api.ORDERS, + paramName: "orderId", + paramSource: "path", + entityLabel: "order", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Order not found", + }, + }, + ]; + + VALIDATION_REGISTRY.set(buildKey("Orders", "v0", "getOrder"), pipeline); + + // Request with an existing order should pass + const context: RequestContext = { + apiName: "Orders", + apiVersion: "v0", + operationId: "getOrder", + method: "GET", + pathParams: { orderId: "order-123" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(true); + }); + + it("returns pass when apiVersion does not match but apiName and operationId are known", async () => { + const pipeline: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { + api: Api.ORDERS, + paramName: "orderId", + paramSource: "path", + entityLabel: "order", + }, + failAction: { + statusCode: 404, + code: "NotFound", + message: "Order not found", + }, + }, + ]; + + // Register pipeline only for v0 + VALIDATION_REGISTRY.set(buildKey("Orders", "v0", "getOrder"), pipeline); + + // Request with unknown apiVersion — should fail (no pipeline found) + const context: RequestContext = { + apiName: "Orders", + apiVersion: "2099-01-01", + operationId: "getOrder", + method: "GET", + pathParams: { orderId: "nonexistent-order" }, + queryParams: {}, + body: undefined, + }; + + const result = await executeValidation(context); + expect(result.pass).toBe(false); + if (!result.pass) { + expect(result.statusCode).toBe(501); + expect(result.body?.errors[0].code).toBe("NoValidationPipeline"); + } + }); + + it("same operationId under different apiName:apiVersion combos resolves to different pipelines", async () => { + // Pipeline for Orders:v0:getOrder — uses entityExistence with "order" label + const pipelineV0: ValidationPipeline = [ + { + checkType: "entityExistence", + entity: { + api: Api.ORDERS, + paramName: "orderId", + paramSource: "path", + entityLabel: "order", + }, + failAction: { + statusCode: 404, + code: "NotFoundV0", + message: "Order not found (v0)", + }, + }, + ]; + + // Pipeline for Orders:2026-01-01:getOrder — uses atLeastOneRequired as a distinct pipeline + const pipeline2026: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [{ name: "orderId", source: "path" }], + failAction: { + statusCode: 400, + code: "MissingParam2026", + message: "orderId is required (2026-01-01)", + }, + }, + ]; + + VALIDATION_REGISTRY.set(buildKey("Orders", "v0", "getOrder"), pipelineV0); + VALIDATION_REGISTRY.set(buildKey("Orders", "2026-01-01", "getOrder"), pipeline2026); + + // Verify the registry has distinct entries + expect(VALIDATION_REGISTRY.get(buildKey("Orders", "v0", "getOrder"))).toBe(pipelineV0); + expect(VALIDATION_REGISTRY.get(buildKey("Orders", "2026-01-01", "getOrder"))).toBe(pipeline2026); + expect(VALIDATION_REGISTRY.get(buildKey("Orders", "v0", "getOrder"))).not.toBe(pipeline2026); + + // Execute v0 with a non-existent order — should fail with NotFoundV0 + const contextV0: RequestContext = { + apiName: "Orders", + apiVersion: "v0", + operationId: "getOrder", + method: "GET", + pathParams: { orderId: "nonexistent" }, + queryParams: {}, + body: undefined, + }; + + const resultV0 = await executeValidation(contextV0); + expect(resultV0.pass).toBe(false); + if (!resultV0.pass) { + expect(resultV0.statusCode).toBe(404); + expect(resultV0.body.errors[0].code).toBe("NotFoundV0"); + } + + // Execute 2026-01-01 with orderId present — should pass (atLeastOneRequired satisfied) + const context2026: RequestContext = { + apiName: "Orders", + apiVersion: "2026-01-01", + operationId: "getOrder", + method: "GET", + pathParams: { orderId: "any-value" }, + queryParams: {}, + body: undefined, + }; + + const result2026 = await executeValidation(context2026); + expect(result2026.pass).toBe(true); + + // Execute 2026-01-01 without orderId — should fail with MissingParam2026 + const context2026NoId: RequestContext = { + apiName: "Orders", + apiVersion: "2026-01-01", + operationId: "getOrder", + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const result2026NoId = await executeValidation(context2026NoId); + expect(result2026NoId.pass).toBe(false); + if (!result2026NoId.pass) { + expect(result2026NoId.statusCode).toBe(400); + expect(result2026NoId.body.errors[0].code).toBe("MissingParam2026"); + } + }); + + it("different apiName with same apiVersion and operationId resolves to different pipelines", async () => { + // Two distinct APIs with same version and operationId + const pipelineOrdersGet: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [{ name: "orderId", source: "path" }], + failAction: { + statusCode: 400, + code: "OrdersPipelineCode", + message: "Orders pipeline triggered", + }, + }, + ]; + + const pipelineListingsGet: ValidationPipeline = [ + { + checkType: "atLeastOneRequired", + params: [{ name: "sku", source: "path" }], + failAction: { + statusCode: 400, + code: "ListingsPipelineCode", + message: "Listings pipeline triggered", + }, + }, + ]; + + VALIDATION_REGISTRY.set(buildKey("Orders", "v1", "getItem"), pipelineOrdersGet); + VALIDATION_REGISTRY.set(buildKey("Listings", "v1", "getItem"), pipelineListingsGet); + + // Orders:v1:getItem without orderId should fail with OrdersPipelineCode + const ordersContext: RequestContext = { + apiName: "Orders", + apiVersion: "v1", + operationId: "getItem", + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const ordersResult = await executeValidation(ordersContext); + expect(ordersResult.pass).toBe(false); + if (!ordersResult.pass) { + expect(ordersResult.body.errors[0].code).toBe("OrdersPipelineCode"); + } + + // Listings:v1:getItem without sku should fail with ListingsPipelineCode + const listingsContext: RequestContext = { + apiName: "Listings", + apiVersion: "v1", + operationId: "getItem", + method: "GET", + pathParams: {}, + queryParams: {}, + body: undefined, + }; + + const listingsResult = await executeValidation(listingsContext); + expect(listingsResult.pass).toBe(false); + if (!listingsResult.pass) { + expect(listingsResult.body.errors[0].code).toBe("ListingsPipelineCode"); + } + }); +}); diff --git a/local-ai-sandbox/vitest.config.ts b/local-ai-sandbox/vitest.config.ts index 3f824fb95..0d61b2c74 100644 --- a/local-ai-sandbox/vitest.config.ts +++ b/local-ai-sandbox/vitest.config.ts @@ -4,5 +4,9 @@ export default defineConfig({ test: { globals: true, environment: "node", + env: { + MODE: "Seller", + TZ: "UTC", + }, }, }); From 1442b654816d24d7820b253ce10a1a65ec85f52c Mon Sep 17 00:00:00 2001 From: Marc Gerzimbke Date: Thu, 3 Sep 2026 09:16:19 +0200 Subject: [PATCH 2/2] Review improvements --- local-ai-sandbox/res/scenarios/launch-a-product.json | 8 +++----- local-ai-sandbox/scripts/config/apiRegistrationConfig.ts | 2 +- .../test/controller/scenariosController.test.ts | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/local-ai-sandbox/res/scenarios/launch-a-product.json b/local-ai-sandbox/res/scenarios/launch-a-product.json index 8d24eb1b4..762e9ae84 100644 --- a/local-ai-sandbox/res/scenarios/launch-a-product.json +++ b/local-ai-sandbox/res/scenarios/launch-a-product.json @@ -28,9 +28,10 @@ }, { "api": "listings", - "id": "LAUNCH-SKU-001", + "id": "A1SANDBOXSELLER|LAUNCH-SKU-001", "entity": { "sku": "LAUNCH-SKU-001", + "sellerId": "A1SANDBOXSELLER", "asin": "B0LAUNCH01", "productType": "PRODUCT", "item_name": [{ "value": "Wireless Bluetooth Speaker 20W", "marketplace_id": "ATVPDKIKX0DER" }], @@ -41,10 +42,7 @@ "our_price": [{ "schedule": [{ "value_with_tax": 49.99 }] }] } ], - "fulfillment_availability": [ - { "fulfillment_channel_code": "DEFAULT", "quantity": 25 }, - { "fulfillment_channel_code": "AMAZON_NA", "quantity": 50 } - ] + "fulfillment_availability": [{ "fulfillment_channel_code": "DEFAULT", "quantity": 25 }, { "fulfillment_channel_code": "AMAZON_NA" }] } }, { diff --git a/local-ai-sandbox/scripts/config/apiRegistrationConfig.ts b/local-ai-sandbox/scripts/config/apiRegistrationConfig.ts index cff64f9b7..c3d7e2260 100644 --- a/local-ai-sandbox/scripts/config/apiRegistrationConfig.ts +++ b/local-ai-sandbox/scripts/config/apiRegistrationConfig.ts @@ -28,7 +28,7 @@ export interface ApiMetadataOverride { * "…Return Item" -> "External Fulfillment Returns". Listings also needs a resourcePath override. */ export const API_METADATA_OVERRIDES: Record = { - "listingsItems_2021-08-01.json": { apiName: "Listings", dbNamespace: "listings", resourcePath: "./res/pt-definitions/PRODUCT.json" }, + "listingsItems_2021-08-01.json": { apiName: "Listings", dbNamespace: "listings" }, "catalogItems_2022-04-01.json": { apiName: "Catalog Items", dbNamespace: "catalog" }, "productPricing_2022-05-01.json": { apiName: "Product Pricing", dbNamespace: "pricing" }, "fbaInventory.json": { apiName: "FBA Inventory", dbNamespace: "inventory" }, diff --git a/local-ai-sandbox/test/controller/scenariosController.test.ts b/local-ai-sandbox/test/controller/scenariosController.test.ts index 4ebfb42cf..835e3c729 100644 --- a/local-ai-sandbox/test/controller/scenariosController.test.ts +++ b/local-ai-sandbox/test/controller/scenariosController.test.ts @@ -156,7 +156,7 @@ describe("POST /scenarios/:scenarioId/seed (seedScenario)", () => { expect(Context.instance.engine.get(Api.ORDERS, "111-0000001-0000001")?.orderId).toBe("111-0000001-0000001"); expect(Context.instance.engine.get(Api.ORDERS, "111-0000002-0000002")?.orderId).toBe("111-0000002-0000002"); - expect(Context.instance.engine.get(Api.LISTINGS, "LAUNCH-SKU-001")?.sku).toBe("LAUNCH-SKU-001"); + expect(Context.instance.engine.get(Api.LISTINGS, "A1SANDBOXSELLER|LAUNCH-SKU-001")?.sku).toBe("LAUNCH-SKU-001"); expect(Context.instance.engine.get(Api.CATALOG, "B0LAUNCH01")?.asin).toBe("B0LAUNCH01"); expect(Context.instance.engine.get(Api.INVENTORY, "LAUNCH-SKU-001")?.sellerSku).toBe("LAUNCH-SKU-001"); });