From dea279f55ad2b1317ddd639276b64081b8d8d509 Mon Sep 17 00:00:00 2001 From: fern-bot Date: Mon, 15 Jul 2024 11:27:07 -0400 Subject: [PATCH] Release 0.1.2 --- .github/workflows/ci.yml | 36 +- .mock/definition/__package__.yml | 630 +++++++++--------- .mock/definition/api.yml | 14 +- .mock/definition/headless.yml | 364 +++++----- .mock/fern.config.json | 6 +- package.json | 10 +- tests/custom/custom.test.ts | 13 + tests/integration/client.test.ts | 53 ++ tests/unit/fetcher/Fetcher.test.ts | 46 ++ tests/unit/fetcher/createRequestUrl.test.ts | 51 ++ tests/unit/fetcher/getFetchFn.test.ts | 18 + tests/unit/fetcher/getRequestBody.test.ts | 62 ++ tests/unit/fetcher/getResponseBody.test.ts | 57 ++ tests/unit/fetcher/makeRequest.test.ts | 58 ++ tests/unit/fetcher/requestWithRetries.test.ts | 85 +++ tests/unit/fetcher/signals.test.ts | 69 ++ tests/unit/serializer/date/date.test.ts | 31 + tests/unit/serializer/enum/enum.test.ts | 30 + tests/unit/serializer/itSchema.ts | 78 +++ tests/unit/serializer/itValidate.ts | 56 ++ tests/unit/serializer/lazy/lazy.test.ts | 60 ++ tests/unit/serializer/lazy/lazyObject.test.ts | 20 + tests/unit/serializer/lazy/recursive/a.ts | 7 + tests/unit/serializer/lazy/recursive/b.ts | 8 + tests/unit/serializer/list/list.test.ts | 43 ++ .../serializer/literals/stringLiteral.test.ts | 21 + .../object-like/withParsedProperties.test.ts | 60 ++ tests/unit/serializer/object/extend.test.ts | 92 +++ tests/unit/serializer/object/object.test.ts | 258 +++++++ .../objectWithoutOptionalProperties.test.ts | 23 + tests/unit/serializer/primitives/any.test.ts | 6 + .../serializer/primitives/boolean.test.ts | 14 + .../unit/serializer/primitives/number.test.ts | 14 + .../unit/serializer/primitives/string.test.ts | 14 + .../serializer/primitives/unknown.test.ts | 6 + tests/unit/serializer/record/record.test.ts | 35 + .../schema-utils/getSchemaUtils.test.ts | 55 ++ tests/unit/serializer/schema.test.ts | 78 +++ tests/unit/serializer/set/set.test.ts | 49 ++ tests/unit/serializer/skipValidation.test.ts | 45 ++ .../undiscriminatedUnion.test.ts | 46 ++ tests/unit/serializer/union/union.test.ts | 116 ++++ tests/unit/serializer/utils/itSchema.ts | 78 +++ tests/unit/serializer/utils/itValidate.ts | 56 ++ yarn.lock | 5 + 45 files changed, 2448 insertions(+), 528 deletions(-) create mode 100644 tests/custom/custom.test.ts create mode 100644 tests/integration/client.test.ts create mode 100644 tests/unit/fetcher/Fetcher.test.ts create mode 100644 tests/unit/fetcher/createRequestUrl.test.ts create mode 100644 tests/unit/fetcher/getFetchFn.test.ts create mode 100644 tests/unit/fetcher/getRequestBody.test.ts create mode 100644 tests/unit/fetcher/getResponseBody.test.ts create mode 100644 tests/unit/fetcher/makeRequest.test.ts create mode 100644 tests/unit/fetcher/requestWithRetries.test.ts create mode 100644 tests/unit/fetcher/signals.test.ts create mode 100644 tests/unit/serializer/date/date.test.ts create mode 100644 tests/unit/serializer/enum/enum.test.ts create mode 100644 tests/unit/serializer/itSchema.ts create mode 100644 tests/unit/serializer/itValidate.ts create mode 100644 tests/unit/serializer/lazy/lazy.test.ts create mode 100644 tests/unit/serializer/lazy/lazyObject.test.ts create mode 100644 tests/unit/serializer/lazy/recursive/a.ts create mode 100644 tests/unit/serializer/lazy/recursive/b.ts create mode 100644 tests/unit/serializer/list/list.test.ts create mode 100644 tests/unit/serializer/literals/stringLiteral.test.ts create mode 100644 tests/unit/serializer/object-like/withParsedProperties.test.ts create mode 100644 tests/unit/serializer/object/extend.test.ts create mode 100644 tests/unit/serializer/object/object.test.ts create mode 100644 tests/unit/serializer/object/objectWithoutOptionalProperties.test.ts create mode 100644 tests/unit/serializer/primitives/any.test.ts create mode 100644 tests/unit/serializer/primitives/boolean.test.ts create mode 100644 tests/unit/serializer/primitives/number.test.ts create mode 100644 tests/unit/serializer/primitives/string.test.ts create mode 100644 tests/unit/serializer/primitives/unknown.test.ts create mode 100644 tests/unit/serializer/record/record.test.ts create mode 100644 tests/unit/serializer/schema-utils/getSchemaUtils.test.ts create mode 100644 tests/unit/serializer/schema.test.ts create mode 100644 tests/unit/serializer/set/set.test.ts create mode 100644 tests/unit/serializer/skipValidation.test.ts create mode 100644 tests/unit/serializer/undiscriminated-union/undiscriminatedUnion.test.ts create mode 100644 tests/unit/serializer/union/union.test.ts create mode 100644 tests/unit/serializer/utils/itSchema.ts create mode 100644 tests/unit/serializer/utils/itValidate.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf13c1c..f69d2ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,28 +3,28 @@ name: ci on: [push] jobs: - compile: - runs-on: ubuntu-latest + compile: + runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v3 + steps: + - name: Checkout repo + uses: actions/checkout@v3 - - name: Set up node - uses: actions/setup-node@v3 + - name: Set up node + uses: actions/setup-node@v3 - - name: Compile - run: yarn && yarn build + - name: Compile + run: yarn && yarn build - test: - runs-on: ubuntu-latest + test: + runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v3 + steps: + - name: Checkout repo + uses: actions/checkout@v3 - - name: Set up node - uses: actions/setup-node@v3 + - name: Set up node + uses: actions/setup-node@v3 - - name: Compile - run: yarn && yarn test + - name: Compile + run: yarn && yarn test diff --git a/.mock/definition/__package__.yml b/.mock/definition/__package__.yml index fc68fef..086c141 100644 --- a/.mock/definition/__package__.yml +++ b/.mock/definition/__package__.yml @@ -1,322 +1,322 @@ errors: - BadRequestError: - status-code: 400 - type: Response400 - docs: >- - Invalid arguments, please make sure you're following the api - specification. - ForbiddenError: - status-code: 403 - type: Response403 - docs: Forbidden error, please ensure the credentials are correct. - NotFoundError: - status-code: 404 - type: Response404 - docs: Not found error. - ServiceUnavailableError: - status-code: 503 - type: Response503 - docs: >- - Please try again in a few minutes. If the issue still persists, contact - Crossmint support. -types: - Email: - docs: >- - Recipient of the items being purchased. Crossmint will create a custodial - wallet address for the user on the fly, that they can later log in to. If - no recipient is passed, an order will be created with the status - 'requires-recipient', until you pass one. - properties: - email: - type: string - validation: - format: email - Wallet: - docs: >- - Recipient of the items being purchased. If specifying a recipient by - wallet address, ensure the address is valid for the chain your - **collection** is on, which may differ from the chain the payment is - performed on. - properties: - walletAddress: string - Recipient: - discriminated: false - union: - - type: Email - docs: >- - Recipient of the items being purchased. Crossmint will create a - custodial wallet address for the user on the fly, that they can later - log in to. If no recipient is passed, an order will be created with - the status 'requires-recipient', until you pass one. - - type: Wallet + BadRequestError: + status-code: 400 + type: Response400 docs: >- - Recipient of the items being purchased. If specifying a recipient by - wallet address, ensure the address is valid for the chain your - **collection** is on, which may differ from the chain the payment is - performed on. - Locale: - enum: - - value: en-US - name: EnUs - - value: es-ES - name: EsEs - - value: fr-FR - name: FrFr - - value: it-IT - name: ItIt - - value: ko-KR - name: KoKr - - value: pt-PT - name: PtPt - - value: ja-JP - name: JaJp - - value: zh-CN - name: ZhCn - - value: zh-TW - name: ZhTw - - value: de-DE - name: DeDe - - value: ru-RU - name: RuRu - - value: tr-TR - name: TrTr - - value: uk-UA - name: UkUa - - value: th-TH - name: ThTh - - value: vi-VN - name: ViVn - - Klingon - docs: >- - Locale for the checkout, in IETF BCP 47. It impacts the email receipt - language. Ensure your UI is set to the same language as specified here. - Throws an error if passed an invalid language. - PaymentZeroMethod: - enum: - - value: arbitrum-sepolia - name: ArbitrumSepolia - - value: base-sepolia - name: BaseSepolia - - value: ethereum-sepolia - name: EthereumSepolia - - value: optimism-sepolia - name: OptimismSepolia - - arbitrum - - bsc - - ethereum - - optimism - PaymentZeroCurrency: - enum: - - eth - - usdc - - degen - - brett - - toshi - EVM: - properties: - receiptEmail: - type: optional - docs: Email that the receipt will be sent to. - validation: - format: email - method: PaymentZeroMethod - currency: PaymentZeroCurrency - payerAddress: - type: optional - docs: An EVM wallet address. - validation: - pattern: ^0x[0-9a-fA-F]{40}$ - PaymentOneCurrency: - enum: - - sol - - usdc - - bonk - - wif - - mother - Solana: - properties: - receiptEmail: - type: optional - docs: Email that the receipt will be sent to. - validation: - format: email - method: literal<"solana"> - currency: PaymentOneCurrency - payerAddress: - type: optional - docs: A Solana public key. - validation: - pattern: ^[1-9A-HJ-NP-Za-km-z]{32,44}$ - PaymentCurrencyCurrency: - enum: - - usd - - eur - - aud - - gbp - - jpy - - sgd - - hkd - - krw - - inr - - vnd - Fiat: - properties: - receiptEmail: - type: optional - docs: Email that the receipt will be sent to. - validation: - format: email - method: literal<"stripe-payment-element"> - currency: optional - Payment: - discriminated: false - union: - - EVM - - Solana - - Fiat - LineItemsCallDataCallData: - docs: Information that you pass to your contract mint function. - properties: - totalPrice: - type: optional + Invalid arguments, please make sure you're following the api + specification. + ForbiddenError: + status-code: 403 + type: Response403 + docs: Forbidden error, please ensure the credentials are correct. + NotFoundError: + status-code: 404 + type: Response404 + docs: Not found error. + ServiceUnavailableError: + status-code: 503 + type: Response503 docs: >- - The total price of the line item. It must be the same as the contract - expects to receive. Read - https://docs.crossmint.com/nft-checkout/advanced/component-properties#mintconfig - extra-properties: true - LineItemsCallData: - properties: - collectionLocator: - type: string + Please try again in a few minutes. If the issue still persists, contact + Crossmint support. +types: + Email: docs: >- - The collection locator of the line item. For example: - 'crossmint:'. These fields can be retrieved from the - Crossmint console. - callData: - type: optional - docs: Information that you pass to your contract mint function. - LineItemsItemCallData: - docs: Information that you pass to your contract mint function. - properties: - totalPrice: - type: optional + Recipient of the items being purchased. Crossmint will create a custodial + wallet address for the user on the fly, that they can later log in to. If + no recipient is passed, an order will be created with the status + 'requires-recipient', until you pass one. + properties: + email: + type: string + validation: + format: email + Wallet: docs: >- - The total price of the line item. It must be the same as the contract - expects to receive. Read - https://docs.crossmint.com/nft-checkout/advanced/component-properties#mintconfig - extra-properties: true - LineItemsItem: - properties: - collectionLocator: - type: string + Recipient of the items being purchased. If specifying a recipient by + wallet address, ensure the address is valid for the chain your + **collection** is on, which may differ from the chain the payment is + performed on. + properties: + walletAddress: string + Recipient: + discriminated: false + union: + - type: Email + docs: >- + Recipient of the items being purchased. Crossmint will create a + custodial wallet address for the user on the fly, that they can later + log in to. If no recipient is passed, an order will be created with + the status 'requires-recipient', until you pass one. + - type: Wallet + docs: >- + Recipient of the items being purchased. If specifying a recipient by + wallet address, ensure the address is valid for the chain your + **collection** is on, which may differ from the chain the payment is + performed on. + Locale: + enum: + - value: en-US + name: EnUs + - value: es-ES + name: EsEs + - value: fr-FR + name: FrFr + - value: it-IT + name: ItIt + - value: ko-KR + name: KoKr + - value: pt-PT + name: PtPt + - value: ja-JP + name: JaJp + - value: zh-CN + name: ZhCn + - value: zh-TW + name: ZhTw + - value: de-DE + name: DeDe + - value: ru-RU + name: RuRu + - value: tr-TR + name: TrTr + - value: uk-UA + name: UkUa + - value: th-TH + name: ThTh + - value: vi-VN + name: ViVn + - Klingon docs: >- - The collection locator of the line item. Eg. - 'crossmint::', 'crossmint:'. - These fields can be retrieved from the Crossmint console. - callData: - type: optional + Locale for the checkout, in IETF BCP 47. It impacts the email receipt + language. Ensure your UI is set to the same language as specified here. + Throws an error if passed an invalid language. + PaymentZeroMethod: + enum: + - value: arbitrum-sepolia + name: ArbitrumSepolia + - value: base-sepolia + name: BaseSepolia + - value: ethereum-sepolia + name: EthereumSepolia + - value: optimism-sepolia + name: OptimismSepolia + - arbitrum + - bsc + - ethereum + - optimism + PaymentZeroCurrency: + enum: + - eth + - usdc + - degen + - brett + - toshi + EVM: + properties: + receiptEmail: + type: optional + docs: Email that the receipt will be sent to. + validation: + format: email + method: PaymentZeroMethod + currency: PaymentZeroCurrency + payerAddress: + type: optional + docs: An EVM wallet address. + validation: + pattern: ^0x[0-9a-fA-F]{40}$ + PaymentOneCurrency: + enum: + - sol + - usdc + - bonk + - wif + - mother + Solana: + properties: + receiptEmail: + type: optional + docs: Email that the receipt will be sent to. + validation: + format: email + method: literal<"solana"> + currency: PaymentOneCurrency + payerAddress: + type: optional + docs: A Solana public key. + validation: + pattern: ^[1-9A-HJ-NP-Za-km-z]{32,44}$ + PaymentCurrencyCurrency: + enum: + - usd + - eur + - aud + - gbp + - jpy + - sgd + - hkd + - krw + - inr + - vnd + Fiat: + properties: + receiptEmail: + type: optional + docs: Email that the receipt will be sent to. + validation: + format: email + method: literal<"stripe-payment-element"> + currency: optional + Payment: + discriminated: false + union: + - EVM + - Solana + - Fiat + LineItemsCallDataCallData: docs: Information that you pass to your contract mint function. - LineItems: - discriminated: false - union: - - LineItemsCallData - - list - CreateOrderResponse: - properties: - clientSecret: - type: optional - docs: >- - A token exclusively scoped to a particular order, allowing for the - reading or updating of that order. - order: optional - OrderObjectLineItemsItemCallData: - properties: - quantity: optional - ADDITIONAL_PROPERTIES: optional - OrderObjectLineItemsItemMetadata: - properties: - name: optional - description: optional - imageUrl: optional - OrderObjectLineItemsItemQuoteChargesUnit: - properties: - amount: optional - currency: optional - OrderObjectLineItemsItemQuoteCharges: - properties: - unit: optional - OrderObjectLineItemsItemQuoteTotalPrice: - properties: - amount: optional - currency: optional - OrderObjectLineItemsItemQuote: - properties: - status: optional - charges: optional - totalPrice: optional - OrderObjectLineItemsItemDeliveryRecipient: - properties: - locator: optional - email: optional - walletAddress: optional - OrderObjectLineItemsItemDelivery: - properties: - status: optional - recipient: optional - OrderObjectLineItemsItem: - properties: - chain: optional - quantity: optional - callData: optional - metadata: optional - quote: optional - delivery: optional - OrderObjectQuoteTotalPrice: - properties: - amount: optional - currency: optional - OrderObjectQuote: - properties: - status: optional - quotedAt: optional - expiresAt: optional - totalPrice: optional - OrderObjectPaymentPreparation: - properties: - chain: optional - payerAddress: optional - serializedTransaction: optional - OrderObjectPayment: - properties: - status: optional - method: optional - currency: optional - preparation: optional - OrderObject: - properties: - orderId: optional - phase: optional - locale: optional - lineItems: optional> - quote: optional - payment: optional - Response400: - properties: - error: optional - message: optional - Response403: - properties: - error: optional - message: optional - Response404: - properties: - error: optional - message: optional - Response503: - properties: - error: optional - message: optional - Response524: - properties: - error: optional - message: optional + properties: + totalPrice: + type: optional + docs: >- + The total price of the line item. It must be the same as the contract + expects to receive. Read + https://docs.crossmint.com/nft-checkout/advanced/component-properties#mintconfig + extra-properties: true + LineItemsCallData: + properties: + collectionLocator: + type: string + docs: >- + The collection locator of the line item. For example: + 'crossmint:'. These fields can be retrieved from the + Crossmint console. + callData: + type: optional + docs: Information that you pass to your contract mint function. + LineItemsItemCallData: + docs: Information that you pass to your contract mint function. + properties: + totalPrice: + type: optional + docs: >- + The total price of the line item. It must be the same as the contract + expects to receive. Read + https://docs.crossmint.com/nft-checkout/advanced/component-properties#mintconfig + extra-properties: true + LineItemsItem: + properties: + collectionLocator: + type: string + docs: >- + The collection locator of the line item. Eg. + 'crossmint::', 'crossmint:'. + These fields can be retrieved from the Crossmint console. + callData: + type: optional + docs: Information that you pass to your contract mint function. + LineItems: + discriminated: false + union: + - LineItemsCallData + - list + CreateOrderResponse: + properties: + clientSecret: + type: optional + docs: >- + A token exclusively scoped to a particular order, allowing for the + reading or updating of that order. + order: optional + OrderObjectLineItemsItemCallData: + properties: + quantity: optional + ADDITIONAL_PROPERTIES: optional + OrderObjectLineItemsItemMetadata: + properties: + name: optional + description: optional + imageUrl: optional + OrderObjectLineItemsItemQuoteChargesUnit: + properties: + amount: optional + currency: optional + OrderObjectLineItemsItemQuoteCharges: + properties: + unit: optional + OrderObjectLineItemsItemQuoteTotalPrice: + properties: + amount: optional + currency: optional + OrderObjectLineItemsItemQuote: + properties: + status: optional + charges: optional + totalPrice: optional + OrderObjectLineItemsItemDeliveryRecipient: + properties: + locator: optional + email: optional + walletAddress: optional + OrderObjectLineItemsItemDelivery: + properties: + status: optional + recipient: optional + OrderObjectLineItemsItem: + properties: + chain: optional + quantity: optional + callData: optional + metadata: optional + quote: optional + delivery: optional + OrderObjectQuoteTotalPrice: + properties: + amount: optional + currency: optional + OrderObjectQuote: + properties: + status: optional + quotedAt: optional + expiresAt: optional + totalPrice: optional + OrderObjectPaymentPreparation: + properties: + chain: optional + payerAddress: optional + serializedTransaction: optional + OrderObjectPayment: + properties: + status: optional + method: optional + currency: optional + preparation: optional + OrderObject: + properties: + orderId: optional + phase: optional + locale: optional + lineItems: optional> + quote: optional + payment: optional + Response400: + properties: + error: optional + message: optional + Response403: + properties: + error: optional + message: optional + Response404: + properties: + error: optional + message: optional + Response503: + properties: + error: optional + message: optional + Response524: + properties: + error: optional + message: optional diff --git a/.mock/definition/api.yml b/.mock/definition/api.yml index a092262..3a17b47 100644 --- a/.mock/definition/api.yml +++ b/.mock/definition/api.yml @@ -1,14 +1,14 @@ name: api error-discrimination: - strategy: status-code + strategy: status-code display-name: Headless Checkout default-environment: Staging environments: - Staging: https://staging.crossmint.com/api - Production: https://www.crossmint.com/api + Staging: https://staging.crossmint.com/api + Production: https://www.crossmint.com/api auth-schemes: - apiKey: - header: X-API-KEY - name: apiKey - type: string + apiKey: + header: X-API-KEY + name: apiKey + type: string auth: apiKey diff --git a/.mock/definition/headless.yml b/.mock/definition/headless.yml index e2d8da7..8b50ddc 100644 --- a/.mock/definition/headless.yml +++ b/.mock/definition/headless.yml @@ -1,186 +1,186 @@ imports: - root: __package__.yml + root: __package__.yml service: - auth: false - base-path: '' - endpoints: - create-order: - path: /2022-06-09/orders - method: POST - auth: true - docs: Creates a new order that can be used to complete a headless checkout. - display-name: Create Order - request: - name: CreateOrderRequest - body: - properties: - recipient: optional - locale: optional - payment: root.Payment - lineItems: root.LineItems - response: - docs: Order successfully created. - type: root.CreateOrderResponse - errors: - - root.BadRequestError - - root.ForbiddenError - - root.NotFoundError - - root.ServiceUnavailableError - examples: - - request: - payment: - method: arbitrum-sepolia - currency: eth - lineItems: - collectionLocator: crossmint: - response: - body: - clientSecret: _removed_ - order: - orderId: b2959ca5-65e4-466a-bd26-1bd05cb4f837 - phase: payment - locale: en-US - lineItems: - - chain: polygon-amoy - quantity: 1 - quote: - status: valid - quotedAt: '2024-06-07T16:55:44.653Z' - expiresAt: '2024-06-07T17:55:44.653Z' - totalPrice: - amount: '0.0001375741' - currency: eth - payment: - status: awaiting-payment - method: base-sepolia - currency: eth - preparation: - chain: base-sepolia - payerAddress: 0x1234abcd... - serializedTransaction: 0x02f90..... - get-order: - path: /2022-06-09/orders/{orderId} - method: GET - auth: true - docs: Get specific order by ID - path-parameters: - orderId: - type: string - docs: | - This is the identifier for the order with UUID format. + auth: false + base-path: "" + endpoints: + create-order: + path: /2022-06-09/orders + method: POST + auth: true + docs: Creates a new order that can be used to complete a headless checkout. + display-name: Create Order + request: + name: CreateOrderRequest + body: + properties: + recipient: optional + locale: optional + payment: root.Payment + lineItems: root.LineItems + response: + docs: Order successfully created. + type: root.CreateOrderResponse + errors: + - root.BadRequestError + - root.ForbiddenError + - root.NotFoundError + - root.ServiceUnavailableError + examples: + - request: + payment: + method: arbitrum-sepolia + currency: eth + lineItems: + collectionLocator: crossmint: + response: + body: + clientSecret: _removed_ + order: + orderId: b2959ca5-65e4-466a-bd26-1bd05cb4f837 + phase: payment + locale: en-US + lineItems: + - chain: polygon-amoy + quantity: 1 + quote: + status: valid + quotedAt: "2024-06-07T16:55:44.653Z" + expiresAt: "2024-06-07T17:55:44.653Z" + totalPrice: + amount: "0.0001375741" + currency: eth + payment: + status: awaiting-payment + method: base-sepolia + currency: eth + preparation: + chain: base-sepolia + payerAddress: 0x1234abcd... + serializedTransaction: 0x02f90..... + get-order: + path: /2022-06-09/orders/{orderId} + method: GET + auth: true + docs: Get specific order by ID + path-parameters: + orderId: + type: string + docs: | + This is the identifier for the order with UUID format. - **Example:** `9c82ef99-617f-497d-9abb-fd355291681b` - display-name: Get Order - response: - docs: Order successfully created. - type: root.OrderObject - errors: - - root.BadRequestError - - root.ForbiddenError - - root.NotFoundError - - root.ServiceUnavailableError - examples: - - path-parameters: - orderId: orderId - response: - body: - orderId: b2959ca5-65e4-466a-bd26-1bd05cb4f837 - phase: payment - locale: en-US - lineItems: - - chain: polygon-amoy - quantity: 1 - callData: - quantity: 1 - ADDITIONAL_PROPERTIES: Your other mint function arguments - metadata: - name: Headless Checkout Demo - description: NFT Description - imageUrl: https://cdn.io/image.png - quote: - status: valid - delivery: - status: awaiting-payment - quote: - status: valid - quotedAt: '2024-06-07T16:55:44.653Z' - expiresAt: '2024-06-07T17:55:44.653Z' - totalPrice: - amount: '0.0001375741' - currency: eth - payment: - status: awaiting-payment - method: base-sepolia - currency: eth - preparation: - chain: base-sepolia - payerAddress: 0x1234abcd... - serializedTransaction: 0x02f90..... - edit-order: - path: /2022-06-09/orders/{orderId} - method: PATCH - auth: true - docs: >- - Edit an existing order. You can update the recipient, the payment - method, and/or the locale. - path-parameters: - orderId: - type: string - docs: | - This is the identifier for the order with UUID format. + **Example:** `9c82ef99-617f-497d-9abb-fd355291681b` + display-name: Get Order + response: + docs: Order successfully created. + type: root.OrderObject + errors: + - root.BadRequestError + - root.ForbiddenError + - root.NotFoundError + - root.ServiceUnavailableError + examples: + - path-parameters: + orderId: orderId + response: + body: + orderId: b2959ca5-65e4-466a-bd26-1bd05cb4f837 + phase: payment + locale: en-US + lineItems: + - chain: polygon-amoy + quantity: 1 + callData: + quantity: 1 + ADDITIONAL_PROPERTIES: Your other mint function arguments + metadata: + name: Headless Checkout Demo + description: NFT Description + imageUrl: https://cdn.io/image.png + quote: + status: valid + delivery: + status: awaiting-payment + quote: + status: valid + quotedAt: "2024-06-07T16:55:44.653Z" + expiresAt: "2024-06-07T17:55:44.653Z" + totalPrice: + amount: "0.0001375741" + currency: eth + payment: + status: awaiting-payment + method: base-sepolia + currency: eth + preparation: + chain: base-sepolia + payerAddress: 0x1234abcd... + serializedTransaction: 0x02f90..... + edit-order: + path: /2022-06-09/orders/{orderId} + method: PATCH + auth: true + docs: >- + Edit an existing order. You can update the recipient, the payment + method, and/or the locale. + path-parameters: + orderId: + type: string + docs: | + This is the identifier for the order with UUID format. - **Example:** `9c82ef99-617f-497d-9abb-fd355291681b` - display-name: Edit Order - request: - name: OrderDto - body: - properties: - recipient: optional - locale: optional - payment: optional - response: - docs: Order successfully created. - type: root.OrderObject - errors: - - root.BadRequestError - - root.ForbiddenError - - root.NotFoundError - - root.ServiceUnavailableError - examples: - - path-parameters: - orderId: orderId - request: {} - response: - body: - orderId: b2959ca5-65e4-466a-bd26-1bd05cb4f837 - phase: payment - locale: en-US - lineItems: - - chain: polygon-amoy - quantity: 1 - callData: - quantity: 1 - ADDITIONAL_PROPERTIES: Your other mint function arguments - metadata: - name: Headless Checkout Demo - description: NFT Description - imageUrl: https://cdn.io/image.png - quote: - status: valid - delivery: - status: awaiting-payment - quote: - status: valid - quotedAt: '2024-06-07T16:55:44.653Z' - expiresAt: '2024-06-07T17:55:44.653Z' - totalPrice: - amount: '0.0001375741' - currency: eth - payment: - status: awaiting-payment - method: base-sepolia - currency: eth - preparation: - chain: base-sepolia - payerAddress: 0x1234abcd... - serializedTransaction: 0x02f90..... + **Example:** `9c82ef99-617f-497d-9abb-fd355291681b` + display-name: Edit Order + request: + name: OrderDto + body: + properties: + recipient: optional + locale: optional + payment: optional + response: + docs: Order successfully created. + type: root.OrderObject + errors: + - root.BadRequestError + - root.ForbiddenError + - root.NotFoundError + - root.ServiceUnavailableError + examples: + - path-parameters: + orderId: orderId + request: {} + response: + body: + orderId: b2959ca5-65e4-466a-bd26-1bd05cb4f837 + phase: payment + locale: en-US + lineItems: + - chain: polygon-amoy + quantity: 1 + callData: + quantity: 1 + ADDITIONAL_PROPERTIES: Your other mint function arguments + metadata: + name: Headless Checkout Demo + description: NFT Description + imageUrl: https://cdn.io/image.png + quote: + status: valid + delivery: + status: awaiting-payment + quote: + status: valid + quotedAt: "2024-06-07T16:55:44.653Z" + expiresAt: "2024-06-07T17:55:44.653Z" + totalPrice: + amount: "0.0001375741" + currency: eth + payment: + status: awaiting-payment + method: base-sepolia + currency: eth + preparation: + chain: base-sepolia + payerAddress: 0x1234abcd... + serializedTransaction: 0x02f90..... diff --git a/.mock/fern.config.json b/.mock/fern.config.json index 197432e..b428a3a 100644 --- a/.mock/fern.config.json +++ b/.mock/fern.config.json @@ -1,4 +1,4 @@ { - "organization" : "crossmint", - "version" : "0.31.24" -} \ No newline at end of file + "organization": "crossmint", + "version": "0.31.24" +} diff --git a/package.json b/package.json index b374aa5..d2e9d27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crossmint", - "version": "0.1.1", + "version": "0.1.2", "private": false, "repository": "https://github.com/fern-demo/crossmint-typescript-sdk", "main": "./index.js", @@ -9,7 +9,10 @@ "format": "prettier . --write --ignore-unknown", "build": "tsc", "prepack": "cp -rv dist/. .", - "test": "jest" + "test": "jest", + "test:unit": "jest tests/unit", + "test:mock": "fern test --command 'yarn jest tests/integration'", + "test:integration": "jest tests/integration tests/custom" }, "dependencies": { "url-join": "4.0.1", @@ -28,7 +31,8 @@ "jest-environment-jsdom": "29.7.0", "@types/node": "17.0.33", "prettier": "2.7.1", - "typescript": "4.6.4" + "typescript": "4.6.4", + "fern-api": "^0.31.22" }, "browser": { "fs": false, diff --git a/tests/custom/custom.test.ts b/tests/custom/custom.test.ts new file mode 100644 index 0000000..4fa0fb1 --- /dev/null +++ b/tests/custom/custom.test.ts @@ -0,0 +1,13 @@ +/** + * This is a custom test file, if you wish to add more tests + * to your SDK. + * Be sure to mark this file in `.fernignore`. + * + * If you include example requests/responses in your fern definition, + * you will have tests automatically generated for you. + */ +describe("test", () => { + it("default", () => { + expect(true).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/integration/client.test.ts b/tests/integration/client.test.ts new file mode 100644 index 0000000..7ae8128 --- /dev/null +++ b/tests/integration/client.test.ts @@ -0,0 +1,53 @@ +import { CrossmintClient } from "../../src"; + +describe("Integration Tests", () => { + it("Test Create Order", async () => { + const client = new CrossmintClient({ + apiKey: process.env.CROSSMINT_API_KEY ?? "", + environment: process.env.CROSSMINT_BASE_URL ?? process.env.TESTS_BASE_URL, + }); + const response = await client.headless.createOrder({ + payment: { + method: "arbitrum-sepolia", + currency: "eth", + }, + lineItems: { + collectionLocator: "crossmint:", + }, + }); + const expected = { + clientSecret: "_removed_", + order: { + orderId: "b2959ca5-65e4-466a-bd26-1bd05cb4f837", + phase: "payment", + locale: "en-US", + lineItems: [ + { + chain: "polygon-amoy", + quantity: 1, + }, + ], + quote: { + status: "valid", + quotedAt: "2024-06-07T16:55:44.653Z", + expiresAt: "2024-06-07T17:55:44.653Z", + totalPrice: { + amount: "0.0001375741", + currency: "eth", + }, + }, + payment: { + status: "awaiting-payment", + method: "base-sepolia", + currency: "eth", + preparation: { + chain: "base-sepolia", + payerAddress: "0x1234abcd...", + serializedTransaction: "0x02f90.....", + }, + }, + }, + }; + expect(response).toEqual(expected); + }, 10000); +}); diff --git a/tests/unit/fetcher/Fetcher.test.ts b/tests/unit/fetcher/Fetcher.test.ts new file mode 100644 index 0000000..0acfdda --- /dev/null +++ b/tests/unit/fetcher/Fetcher.test.ts @@ -0,0 +1,46 @@ +import { Fetcher, fetcherImpl } from "../../../src/core/fetcher/Fetcher"; + +describe("Test fetcherImpl", () => { + let mockCreateUrl: jest.Mock; + let mockGetBody: jest.Mock; + let mockGetFetchFn: jest.Mock; + let mockRequestWithRetries: jest.Mock; + let mockGetResponseBody: jest.Mock; + + beforeEach(() => { + mockCreateUrl = jest.fn(); + mockGetBody = jest.fn(); + mockGetFetchFn = jest.fn(); + mockRequestWithRetries = jest.fn(); + mockGetResponseBody = jest.fn(); + + jest.mock("../../../src/core/fetcher/Fetcher", () => ({ + createUrl: mockCreateUrl, + getBody: mockGetBody, + getFetchFn: mockGetFetchFn, + requestWithRetries: mockRequestWithRetries, + getResponseBody: mockGetResponseBody, + })); + }); + + it("should handle successful request", async () => { + const mockArgs: Fetcher.Args = { + url: "https://httpbin.org/post", + method: "POST", + headers: { "X-Test": "x-test-header" }, + body: { data: "test" }, + contentType: "application/json", + }; + + mockCreateUrl.mockReturnValue("https://test.com"); + mockGetBody.mockResolvedValue(JSON.stringify({ data: "test" })); + mockGetFetchFn.mockResolvedValue(() => Promise.resolve()); + mockRequestWithRetries.mockResolvedValue({ status: 200 }); + mockGetResponseBody.mockResolvedValue({ result: "success" }); + + const result = await fetcherImpl(mockArgs); + expect(result.ok).toBe(true); + // @ts-expect-error + expect(result.body.json).toEqual({ data: "test" }); + }); +}); diff --git a/tests/unit/fetcher/createRequestUrl.test.ts b/tests/unit/fetcher/createRequestUrl.test.ts new file mode 100644 index 0000000..f2cd24b --- /dev/null +++ b/tests/unit/fetcher/createRequestUrl.test.ts @@ -0,0 +1,51 @@ +import { createRequestUrl } from "../../../src/core/fetcher/createRequestUrl"; + +describe("Test createRequestUrl", () => { + it("should return the base URL when no query parameters are provided", () => { + const baseUrl = "https://api.example.com"; + expect(createRequestUrl(baseUrl)).toBe(baseUrl); + }); + + it("should append simple query parameters", () => { + const baseUrl = "https://api.example.com"; + const queryParams = { key: "value", another: "param" }; + expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?key=value&another=param"); + }); + + it("should handle array query parameters", () => { + const baseUrl = "https://api.example.com"; + const queryParams = { items: ["a", "b", "c"] }; + expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?items=a&items=b&items=c"); + }); + + it("should handle object query parameters", () => { + const baseUrl = "https://api.example.com"; + const queryParams = { filter: { name: "John", age: 30 } }; + expect(createRequestUrl(baseUrl, queryParams)).toBe( + "https://api.example.com?filter%5Bname%5D=John&filter%5Bage%5D=30" + ); + }); + + it("should handle mixed types of query parameters", () => { + const baseUrl = "https://api.example.com"; + const queryParams = { + simple: "value", + array: ["x", "y"], + object: { key: "value" }, + }; + expect(createRequestUrl(baseUrl, queryParams)).toBe( + "https://api.example.com?simple=value&array=x&array=y&object%5Bkey%5D=value" + ); + }); + + it("should handle empty query parameters object", () => { + const baseUrl = "https://api.example.com"; + expect(createRequestUrl(baseUrl, {})).toBe(baseUrl); + }); + + it("should encode special characters in query parameters", () => { + const baseUrl = "https://api.example.com"; + const queryParams = { special: "a&b=c d" }; + expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?special=a%26b%3Dc%20d"); + }); +}); diff --git a/tests/unit/fetcher/getFetchFn.test.ts b/tests/unit/fetcher/getFetchFn.test.ts new file mode 100644 index 0000000..48a3952 --- /dev/null +++ b/tests/unit/fetcher/getFetchFn.test.ts @@ -0,0 +1,18 @@ +import { RUNTIME } from "../../../src/core/runtime"; +import { getFetchFn } from "../../../src/core/fetcher/getFetchFn"; + +describe("Test for getFetchFn", () => { + it("should get node-fetch function", async () => { + if (RUNTIME.type == "node") { + expect(await getFetchFn()).toEqual((await import("node-fetch")).default as any); + } + }); + + it("should get fetch function", async () => { + if (RUNTIME.type == "browser") { + const fetchFn = await getFetchFn(); + expect(typeof fetchFn).toBe("function"); + expect(fetchFn.name).toBe("fetch"); + } + }); +}); diff --git a/tests/unit/fetcher/getRequestBody.test.ts b/tests/unit/fetcher/getRequestBody.test.ts new file mode 100644 index 0000000..8e47b7a --- /dev/null +++ b/tests/unit/fetcher/getRequestBody.test.ts @@ -0,0 +1,62 @@ +import { RUNTIME } from "../../../src/core/runtime"; +import { getRequestBody, maybeStringifyBody } from "../../../src/core/fetcher/getRequestBody"; + +if (RUNTIME.type === "browser") { + require("jest-fetch-mock").enableMocks(); +} + +describe("Test getRequestBody", () => { + it("should return FormData as is in Node environment", async () => { + if (RUNTIME.type === "node") { + const formData = new (await import("formdata-node")).FormData(); + formData.append("key", "value"); + const result = await getRequestBody(formData, "multipart/form-data"); + expect(result).toBe(formData); + } + }); + + it("should stringify body if not FormData in Node environment", async () => { + if (RUNTIME.type === "node") { + const body = { key: "value" }; + const result = await getRequestBody(body, "application/json"); + expect(result).toBe('{"key":"value"}'); + } + }); + + it("should return FormData in browser environment", async () => { + if (RUNTIME.type === "browser") { + const formData = new (await import("form-data")).default(); + formData.append("key", "value"); + const result = await getRequestBody(formData, "multipart/form-data"); + expect(result).toBe(formData); + } + }); + + it("should stringify body if not FormData in browser environment", async () => { + if (RUNTIME.type === "browser") { + const body = { key: "value" }; + const result = await getRequestBody(body, "application/json"); + expect(result).toBe('{"key":"value"}'); + } + }); +}); + +describe("Test maybeStringifyBody", () => { + it("should return the Uint8Array", () => { + const input = new Uint8Array([1, 2, 3]); + const result = maybeStringifyBody(input, "application/octet-stream"); + expect(result).toBe(input); + }); + + it("should return the input for content-type 'application/x-www-form-urlencoded'", () => { + const input = "key=value&another=param"; + const result = maybeStringifyBody(input, "application/x-www-form-urlencoded"); + expect(result).toBe(input); + }); + + it("should JSON stringify objects", () => { + const input = { key: "value" }; + const result = maybeStringifyBody(input, "application/json"); + expect(result).toBe('{"key":"value"}'); + }); +}); diff --git a/tests/unit/fetcher/getResponseBody.test.ts b/tests/unit/fetcher/getResponseBody.test.ts new file mode 100644 index 0000000..f3563ff --- /dev/null +++ b/tests/unit/fetcher/getResponseBody.test.ts @@ -0,0 +1,57 @@ +import { RUNTIME } from "../../../src/core/runtime"; +import { getResponseBody } from "../../../src/core/fetcher/getResponseBody"; + +if (RUNTIME.type === "browser") { + require("jest-fetch-mock").enableMocks(); +} + +describe("Test getResponseBody", () => { + it("should handle blob response type", async () => { + const mockBlob = new Blob(["test"], { type: "text/plain" }); + const mockResponse = new Response(mockBlob); + const result = await getResponseBody(mockResponse, "blob"); + // @ts-expect-error + expect(result.constructor.name).toBe("Blob"); + }); + + it("should handle streaming response type", async () => { + if (RUNTIME.type === "node") { + const mockStream = new ReadableStream(); + const mockResponse = new Response(mockStream); + const result = await getResponseBody(mockResponse, "streaming"); + expect(result).toBe(mockStream); + } + }); + + it("should handle text response type", async () => { + const mockResponse = new Response("test text"); + const result = await getResponseBody(mockResponse, "text"); + expect(result).toBe("test text"); + }); + + it("should handle JSON response", async () => { + const mockJson = { key: "value" }; + const mockResponse = new Response(JSON.stringify(mockJson)); + const result = await getResponseBody(mockResponse); + expect(result).toEqual(mockJson); + }); + + it("should handle empty response", async () => { + const mockResponse = new Response(""); + const result = await getResponseBody(mockResponse); + expect(result).toBeUndefined(); + }); + + it("should handle non-JSON response", async () => { + const mockResponse = new Response("invalid json"); + const result = await getResponseBody(mockResponse); + expect(result).toEqual({ + ok: false, + error: { + reason: "non-json", + statusCode: 200, + rawBody: "invalid json", + }, + }); + }); +}); diff --git a/tests/unit/fetcher/makeRequest.test.ts b/tests/unit/fetcher/makeRequest.test.ts new file mode 100644 index 0000000..5969d51 --- /dev/null +++ b/tests/unit/fetcher/makeRequest.test.ts @@ -0,0 +1,58 @@ +import { RUNTIME } from "../../../src/core/runtime"; +import { makeRequest } from "../../../src/core/fetcher/makeRequest"; + +if (RUNTIME.type === "browser") { + require("jest-fetch-mock").enableMocks(); +} + +describe("Test makeRequest", () => { + const mockPostUrl = "https://httpbin.org/post"; + const mockGetUrl = "https://httpbin.org/get"; + const mockHeaders = { "Content-Type": "application/json" }; + const mockBody = JSON.stringify({ key: "value" }); + + let mockFetch: jest.Mock; + + beforeEach(() => { + mockFetch = jest.fn(); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ test: "successful" }), { status: 200 })); + }); + + it("should handle POST request correctly", async () => { + const response = await makeRequest(mockFetch, mockPostUrl, "POST", mockHeaders, mockBody); + const responseBody = await response.json(); + expect(responseBody).toEqual({ test: "successful" }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [calledUrl, calledOptions] = mockFetch.mock.calls[0]; + expect(calledUrl).toBe(mockPostUrl); + expect(calledOptions).toEqual( + expect.objectContaining({ + method: "POST", + headers: mockHeaders, + body: mockBody, + credentials: undefined, + }) + ); + expect(calledOptions.signal).toBeDefined(); + expect(calledOptions.signal).toBeInstanceOf(AbortSignal); + }); + + it("should handle GET request correctly", async () => { + const response = await makeRequest(mockFetch, mockGetUrl, "GET", mockHeaders, undefined); + const responseBody = await response.json(); + expect(responseBody).toEqual({ test: "successful" }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [calledUrl, calledOptions] = mockFetch.mock.calls[0]; + expect(calledUrl).toBe(mockGetUrl); + expect(calledOptions).toEqual( + expect.objectContaining({ + method: "GET", + headers: mockHeaders, + body: undefined, + credentials: undefined, + }) + ); + expect(calledOptions.signal).toBeDefined(); + expect(calledOptions.signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/tests/unit/fetcher/requestWithRetries.test.ts b/tests/unit/fetcher/requestWithRetries.test.ts new file mode 100644 index 0000000..b53e043 --- /dev/null +++ b/tests/unit/fetcher/requestWithRetries.test.ts @@ -0,0 +1,85 @@ +import { RUNTIME } from "../../../src/core/runtime"; +import { requestWithRetries } from "../../../src/core/fetcher/requestWithRetries"; + +if (RUNTIME.type === "browser") { + require("jest-fetch-mock").enableMocks(); +} + +describe("Test exponential backoff", () => { + let mockFetch: jest.Mock; + let originalSetTimeout: typeof setTimeout; + + beforeEach(() => { + mockFetch = jest.fn(); + originalSetTimeout = global.setTimeout; + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + global.setTimeout = originalSetTimeout; + }); + + it("should retry on 408, 409, 429, 500+", async () => { + mockFetch + .mockResolvedValueOnce(new Response("", { status: 408 })) + .mockResolvedValueOnce(new Response("", { status: 409 })) + .mockResolvedValueOnce(new Response("", { status: 429 })) + .mockResolvedValueOnce(new Response("", { status: 500 })) + .mockResolvedValueOnce(new Response("", { status: 502 })) + .mockResolvedValueOnce(new Response("", { status: 200 })) + .mockResolvedValueOnce(new Response("", { status: 408 })); + + const responsePromise = requestWithRetries(() => mockFetch(), 10); + + await jest.advanceTimersByTimeAsync(10000); + const response = await responsePromise; + + expect(mockFetch).toHaveBeenCalledTimes(6); + expect(response.status).toBe(200); + }); + + it("should retry max 3 times", async () => { + mockFetch + .mockResolvedValueOnce(new Response("", { status: 408 })) + .mockResolvedValueOnce(new Response("", { status: 409 })) + .mockResolvedValueOnce(new Response("", { status: 429 })) + .mockResolvedValueOnce(new Response("", { status: 429 })); + + const responsePromise = requestWithRetries(() => mockFetch(), 3); + + await jest.advanceTimersByTimeAsync(10000); + const response = await responsePromise; + + expect(mockFetch).toHaveBeenCalledTimes(4); + expect(response.status).toBe(429); + }); + it("should not retry on 200", async () => { + mockFetch + .mockResolvedValueOnce(new Response("", { status: 200 })) + .mockResolvedValueOnce(new Response("", { status: 409 })); + + const responsePromise = requestWithRetries(() => mockFetch(), 3); + + await jest.advanceTimersByTimeAsync(10000); + const response = await responsePromise; + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(response.status).toBe(200); + }); + + it("should retry with exponential backoff timing", async () => { + mockFetch.mockResolvedValue(new Response("", { status: 500 })); + const maxRetries = 7; + const responsePromise = requestWithRetries(() => mockFetch(), maxRetries); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const delays = [1, 2, 4, 8, 16, 32, 64]; + for (let i = 0; i < delays.length; i++) { + await jest.advanceTimersByTimeAsync(delays[i] as number); + expect(mockFetch).toHaveBeenCalledTimes(Math.min(i + 2, maxRetries + 1)); + } + const response = await responsePromise; + expect(response.status).toBe(500); + }); +}); diff --git a/tests/unit/fetcher/signals.test.ts b/tests/unit/fetcher/signals.test.ts new file mode 100644 index 0000000..9cabfa0 --- /dev/null +++ b/tests/unit/fetcher/signals.test.ts @@ -0,0 +1,69 @@ +import { anySignal, getTimeoutSignal } from "../../../src/core/fetcher/signals"; + +describe("Test getTimeoutSignal", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should return an object with signal and abortId", () => { + const { signal, abortId } = getTimeoutSignal(1000); + + expect(signal).toBeDefined(); + expect(abortId).toBeDefined(); + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + }); + + it("should create a signal that aborts after the specified timeout", () => { + const timeoutMs = 5000; + const { signal } = getTimeoutSignal(timeoutMs); + + expect(signal.aborted).toBe(false); + + jest.advanceTimersByTime(timeoutMs - 1); + expect(signal.aborted).toBe(false); + + jest.advanceTimersByTime(1); + expect(signal.aborted).toBe(true); + }); +}); + +describe("Test anySignal", () => { + it("should return an AbortSignal", () => { + const signal = anySignal(new AbortController().signal); + expect(signal).toBeInstanceOf(AbortSignal); + }); + + it("should abort when any of the input signals is aborted", () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const signal = anySignal(controller1.signal, controller2.signal); + + expect(signal.aborted).toBe(false); + controller1.abort(); + expect(signal.aborted).toBe(true); + }); + + it("should handle an array of signals", () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const signal = anySignal([controller1.signal, controller2.signal]); + + expect(signal.aborted).toBe(false); + controller2.abort(); + expect(signal.aborted).toBe(true); + }); + + it("should abort immediately if one of the input signals is already aborted", () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + controller1.abort(); + + const signal = anySignal(controller1.signal, controller2.signal); + expect(signal.aborted).toBe(true); + }); +}); diff --git a/tests/unit/serializer/date/date.test.ts b/tests/unit/serializer/date/date.test.ts new file mode 100644 index 0000000..7a76629 --- /dev/null +++ b/tests/unit/serializer/date/date.test.ts @@ -0,0 +1,31 @@ +import { itSchema } from "../itSchema"; +import { itValidateJson, itValidateParse } from "../itValidate"; +import { date } from "../../../../src/core/schemas"; + +describe("date", () => { + itSchema("converts between raw ISO string and parsed Date", date(), { + raw: "2022-09-29T05:41:21.939Z", + parsed: new Date("2022-09-29T05:41:21.939Z"), + }); + + itValidateParse("non-string", date(), 42, [ + { + message: "Expected string. Received 42.", + path: [], + }, + ]); + + itValidateParse("non-ISO", date(), "hello world", [ + { + message: 'Expected ISO 8601 date string. Received "hello world".', + path: [], + }, + ]); + + itValidateJson("non-Date", date(), "hello", [ + { + message: 'Expected Date object. Received "hello".', + path: [], + }, + ]); +}); diff --git a/tests/unit/serializer/enum/enum.test.ts b/tests/unit/serializer/enum/enum.test.ts new file mode 100644 index 0000000..1619ab3 --- /dev/null +++ b/tests/unit/serializer/enum/enum.test.ts @@ -0,0 +1,30 @@ +import { itSchemaIdentity } from "../itSchema"; +import { itValidate } from "../itValidate"; +import { enum_ } from "../../../../src/core/schemas"; + +describe("enum", () => { + itSchemaIdentity(enum_(["A", "B", "C"]), "A"); + + itSchemaIdentity(enum_(["A", "B", "C"]), "D" as any, { + opts: { allowUnrecognizedEnumValues: true }, + }); + + itValidate("invalid enum", enum_(["A", "B", "C"]), "D", [ + { + message: 'Expected enum. Received "D".', + path: [], + }, + ]); + + itValidate( + "non-string", + enum_(["A", "B", "C"]), + [], + [ + { + message: "Expected string. Received list.", + path: [], + }, + ] + ); +}); diff --git a/tests/unit/serializer/itSchema.ts b/tests/unit/serializer/itSchema.ts new file mode 100644 index 0000000..255bae4 --- /dev/null +++ b/tests/unit/serializer/itSchema.ts @@ -0,0 +1,78 @@ +/* eslint-disable jest/no-export */ +import { Schema, SchemaOptions } from "../../../src/core/schemas/Schema"; + +export function itSchemaIdentity( + schema: Schema, + value: T, + { title = "functions as identity", opts }: { title?: string; opts?: SchemaOptions } = {} +): void { + itSchema(title, schema, { raw: value, parsed: value, opts }); +} + +export function itSchema( + title: string, + schema: Schema, + { + raw, + parsed, + opts, + only = false, + }: { + raw: Raw; + parsed: Parsed; + opts?: SchemaOptions; + only?: boolean; + } +): void { + // eslint-disable-next-line jest/valid-title + (only ? describe.only : describe)(title, () => { + itParse("parse()", schema, { raw, parsed, opts }); + itJson("json()", schema, { raw, parsed, opts }); + }); +} + +export function itParse( + title: string, + schema: Schema, + { + raw, + parsed, + opts, + }: { + raw: Raw; + parsed: Parsed; + opts?: SchemaOptions; + } +): void { + // eslint-disable-next-line jest/valid-title + it(title, () => { + const maybeValid = schema.parse(raw, opts); + if (!maybeValid.ok) { + throw new Error("Failed to parse() " + JSON.stringify(maybeValid.errors, undefined, 4)); + } + expect(maybeValid.value).toStrictEqual(parsed); + }); +} + +export function itJson( + title: string, + schema: Schema, + { + raw, + parsed, + opts, + }: { + raw: Raw; + parsed: Parsed; + opts?: SchemaOptions; + } +): void { + // eslint-disable-next-line jest/valid-title + it(title, () => { + const maybeValid = schema.json(parsed, opts); + if (!maybeValid.ok) { + throw new Error("Failed to json() " + JSON.stringify(maybeValid.errors, undefined, 4)); + } + expect(maybeValid.value).toStrictEqual(raw); + }); +} diff --git a/tests/unit/serializer/itValidate.ts b/tests/unit/serializer/itValidate.ts new file mode 100644 index 0000000..e806578 --- /dev/null +++ b/tests/unit/serializer/itValidate.ts @@ -0,0 +1,56 @@ +/* eslint-disable jest/no-export */ +import { Schema, SchemaOptions, ValidationError } from "../../../src/core/schemas/Schema"; + +export function itValidate( + title: string, + schema: Schema, + input: unknown, + errors: ValidationError[], + opts?: SchemaOptions +): void { + // eslint-disable-next-line jest/valid-title + describe("parse()", () => { + itValidateParse(title, schema, input, errors, opts); + }); + describe("json()", () => { + itValidateJson(title, schema, input, errors, opts); + }); +} + +export function itValidateParse( + title: string, + schema: Schema, + raw: unknown, + errors: ValidationError[], + opts?: SchemaOptions +): void { + describe("parse", () => { + // eslint-disable-next-line jest/valid-title + it(title, async () => { + const maybeValid = await schema.parse(raw, opts); + if (maybeValid.ok) { + throw new Error("Value passed validation"); + } + expect(maybeValid.errors).toStrictEqual(errors); + }); + }); +} + +export function itValidateJson( + title: string, + schema: Schema, + parsed: unknown, + errors: ValidationError[], + opts?: SchemaOptions +): void { + describe("json", () => { + // eslint-disable-next-line jest/valid-title + it(title, async () => { + const maybeValid = await schema.json(parsed, opts); + if (maybeValid.ok) { + throw new Error("Value passed validation"); + } + expect(maybeValid.errors).toStrictEqual(errors); + }); + }); +} diff --git a/tests/unit/serializer/lazy/lazy.test.ts b/tests/unit/serializer/lazy/lazy.test.ts new file mode 100644 index 0000000..dca4e2e --- /dev/null +++ b/tests/unit/serializer/lazy/lazy.test.ts @@ -0,0 +1,60 @@ +import { Schema } from "../../../../src/core/schemas"; +import { itSchemaIdentity } from "../itSchema"; +import { list } from "../../../../src/core/schemas"; +import { object } from "../../../../src/core/schemas"; +import { string } from "../../../../src/core/schemas"; +import { lazy } from "../../../../src/core/schemas"; + +describe("lazy", () => { + it("doesn't run immediately", () => { + let wasRun = false; + lazy(() => { + wasRun = true; + return string(); + }); + expect(wasRun).toBe(false); + }); + + it("only runs first time", async () => { + let count = 0; + const schema = lazy(() => { + count++; + return string(); + }); + await schema.parse("hello"); + await schema.json("world"); + expect(count).toBe(1); + }); + + itSchemaIdentity( + lazy(() => object({})), + { foo: "hello" }, + { + title: "passes opts through", + opts: { unrecognizedObjectKeys: "passthrough" }, + } + ); + + itSchemaIdentity( + lazy(() => object({ foo: string() })), + { foo: "hello" } + ); + + // eslint-disable-next-line jest/expect-expect + it("self-referencial schema doesn't compile", () => { + () => { + // @ts-expect-error + const a = lazy(() => object({ foo: a })); + }; + }); + + // eslint-disable-next-line jest/expect-expect + it("self-referencial compiles with explicit type", () => { + () => { + interface TreeNode { + children: TreeNode[]; + } + const TreeNode: Schema = lazy(() => object({ children: list(TreeNode) })); + }; + }); +}); diff --git a/tests/unit/serializer/lazy/lazyObject.test.ts b/tests/unit/serializer/lazy/lazyObject.test.ts new file mode 100644 index 0000000..e010a0d --- /dev/null +++ b/tests/unit/serializer/lazy/lazyObject.test.ts @@ -0,0 +1,20 @@ +import { itSchemaIdentity } from "../itSchema"; +import { object } from "../../../../src/core/schemas"; +import { number, string } from "../../../../src/core/schemas"; +import { lazyObject } from "../../../../src/core/schemas"; + +describe("lazy", () => { + itSchemaIdentity( + lazyObject(() => object({ foo: string() })), + { foo: "hello" } + ); + + itSchemaIdentity( + lazyObject(() => object({ foo: string() })).extend(object({ bar: number() })), + { + foo: "hello", + bar: 42, + }, + { title: "returned schema has object utils" } + ); +}); diff --git a/tests/unit/serializer/lazy/recursive/a.ts b/tests/unit/serializer/lazy/recursive/a.ts new file mode 100644 index 0000000..a88702b --- /dev/null +++ b/tests/unit/serializer/lazy/recursive/a.ts @@ -0,0 +1,7 @@ +import { object } from "../../../../object"; +import { schemaB } from "./b"; + +// @ts-expect-error +export const schemaA = object({ + b: schemaB, +}); diff --git a/tests/unit/serializer/lazy/recursive/b.ts b/tests/unit/serializer/lazy/recursive/b.ts new file mode 100644 index 0000000..0bbed91 --- /dev/null +++ b/tests/unit/serializer/lazy/recursive/b.ts @@ -0,0 +1,8 @@ +import { object } from "../../../../object"; +import { optional } from "../../../../schema-utils"; +import { schemaA } from "./a"; + +// @ts-expect-error +export const schemaB = object({ + a: optional(schemaA), +}); diff --git a/tests/unit/serializer/list/list.test.ts b/tests/unit/serializer/list/list.test.ts new file mode 100644 index 0000000..46033da --- /dev/null +++ b/tests/unit/serializer/list/list.test.ts @@ -0,0 +1,43 @@ +import { itSchema, itSchemaIdentity } from "../itSchema"; +import { itValidate } from "../itValidate"; +import { object, property } from "../../../../src/core/schemas"; +import { string } from "../../../../src/core/schemas"; +import { list } from "../../../../src/core/schemas"; + +describe("list", () => { + itSchemaIdentity(list(string()), ["hello", "world"], { + title: "functions as identity when item type is primitive", + }); + + itSchema( + "converts objects correctly", + list( + object({ + helloWorld: property("hello_world", string()), + }) + ), + { + raw: [{ hello_world: "123" }], + parsed: [{ helloWorld: "123" }], + } + ); + + itValidate("not a list", list(string()), 42, [ + { + path: [], + message: "Expected list. Received 42.", + }, + ]); + + itValidate( + "invalid item type", + list(string()), + [42], + [ + { + path: ["[0]"], + message: "Expected string. Received 42.", + }, + ] + ); +}); diff --git a/tests/unit/serializer/literals/stringLiteral.test.ts b/tests/unit/serializer/literals/stringLiteral.test.ts new file mode 100644 index 0000000..ed7b31f --- /dev/null +++ b/tests/unit/serializer/literals/stringLiteral.test.ts @@ -0,0 +1,21 @@ +import { itSchemaIdentity } from "../itSchema"; +import { itValidate } from "../itValidate"; +import { stringLiteral } from "../../../../src/core/schemas"; + +describe("stringLiteral", () => { + itSchemaIdentity(stringLiteral("A"), "A"); + + itValidate("incorrect string", stringLiteral("A"), "B", [ + { + path: [], + message: 'Expected "A". Received "B".', + }, + ]); + + itValidate("non-string", stringLiteral("A"), 42, [ + { + path: [], + message: 'Expected "A". Received 42.', + }, + ]); +}); diff --git a/tests/unit/serializer/object-like/withParsedProperties.test.ts b/tests/unit/serializer/object-like/withParsedProperties.test.ts new file mode 100644 index 0000000..90109c2 --- /dev/null +++ b/tests/unit/serializer/object-like/withParsedProperties.test.ts @@ -0,0 +1,60 @@ +import { stringLiteral } from "../../../../src/core/schemas"; +import { object } from "../../../../src/core/schemas"; +import { property } from "../../../../src/core/schemas"; +import { string } from "../../../../src/core/schemas"; + +describe("withParsedProperties", () => { + it("Added properties included on parsed object", async () => { + const schema = object({ + foo: property("raw_foo", string()), + bar: stringLiteral("bar"), + }).withParsedProperties({ + printFoo: (parsed) => () => parsed.foo, + printHelloWorld: () => () => "Hello world", + helloWorld: "Hello world", + }); + + const parsed = await schema.parse({ raw_foo: "value of foo", bar: "bar" }); + if (!parsed.ok) { + throw new Error("Failed to parse"); + } + expect(parsed.value.printFoo()).toBe("value of foo"); + expect(parsed.value.printHelloWorld()).toBe("Hello world"); + expect(parsed.value.helloWorld).toBe("Hello world"); + }); + + it("Added property is removed on raw object", async () => { + const schema = object({ + foo: property("raw_foo", string()), + bar: stringLiteral("bar"), + }).withParsedProperties({ + printFoo: (parsed) => () => parsed.foo, + }); + + const original = { raw_foo: "value of foo", bar: "bar" } as const; + const parsed = await schema.parse(original); + if (!parsed.ok) { + throw new Error("Failed to parse()"); + } + + const raw = await schema.json(parsed.value); + + if (!raw.ok) { + throw new Error("Failed to json()"); + } + + expect(raw.value).toEqual(original); + }); + + describe("compile", () => { + // eslint-disable-next-line jest/expect-expect + it("doesn't compile with non-object schema", () => { + () => + object({ + foo: string(), + }) + // @ts-expect-error + .withParsedProperties(42); + }); + }); +}); diff --git a/tests/unit/serializer/object/extend.test.ts b/tests/unit/serializer/object/extend.test.ts new file mode 100644 index 0000000..1987207 --- /dev/null +++ b/tests/unit/serializer/object/extend.test.ts @@ -0,0 +1,92 @@ +import { itSchema, itSchemaIdentity } from "../itSchema"; +import { stringLiteral } from "../../../../src/core/schemas"; +import { boolean, string } from "../../../../src/core/schemas"; +import { object } from "../../../../src/core/schemas"; +import { property } from "../../../../src/core/schemas"; + +describe("extend", () => { + itSchemaIdentity( + object({ + foo: string(), + }).extend( + object({ + bar: stringLiteral("bar"), + }) + ), + { + foo: "", + bar: "bar", + } as const, + { + title: "extended properties are included in schema", + } + ); + + itSchemaIdentity( + object({ + foo: string(), + }) + .extend( + object({ + bar: stringLiteral("bar"), + }) + ) + .extend( + object({ + baz: boolean(), + }) + ), + { + foo: "", + bar: "bar", + baz: true, + } as const, + { + title: "extensions can be extended", + } + ); + + itSchema( + "converts nested object", + object({ + item: object({ + helloWorld: property("hello_world", string()), + }), + }).extend( + object({ + goodbye: property("goodbye_raw", string()), + }) + ), + { + raw: { item: { hello_world: "yo" }, goodbye_raw: "peace" }, + parsed: { item: { helloWorld: "yo" }, goodbye: "peace" }, + } + ); + + itSchema( + "extensions work with raw/parsed property name conversions", + object({ + item: property("item_raw", string()), + }).extend( + object({ + goodbye: property("goodbye_raw", string()), + }) + ), + { + raw: { item_raw: "hi", goodbye_raw: "peace" }, + parsed: { item: "hi", goodbye: "peace" }, + } + ); + + describe("compile", () => { + // eslint-disable-next-line jest/expect-expect + it("doesn't compile with non-object schema", () => { + () => + object({ + foo: string(), + }) + // @ts-expect-error + .extend([]); + }); + }); +}); diff --git a/tests/unit/serializer/object/object.test.ts b/tests/unit/serializer/object/object.test.ts new file mode 100644 index 0000000..eb726a1 --- /dev/null +++ b/tests/unit/serializer/object/object.test.ts @@ -0,0 +1,258 @@ +import { itJson, itParse, itSchema, itSchemaIdentity } from "../itSchema"; +import { itValidate } from "../itValidate"; +import { stringLiteral } from "../../../../src/core/schemas"; +import { any, number, string, unknown } from "../../../../src/core/schemas"; +import { object } from "../../../../src/core/schemas"; +import { property } from "../../../../src/core/schemas"; + +describe("object", () => { + itSchemaIdentity( + object({ + foo: string(), + bar: stringLiteral("bar"), + }), + { + foo: "", + bar: "bar", + }, + { + title: "functions as identity when values are primitives and property() isn't used", + } + ); + + itSchema( + "uses raw key from property()", + object({ + foo: property("raw_foo", string()), + bar: stringLiteral("bar"), + }), + { + raw: { raw_foo: "foo", bar: "bar" }, + parsed: { foo: "foo", bar: "bar" }, + } + ); + + itSchema( + "keys with unknown type can be omitted", + object({ + foo: unknown(), + }), + { + raw: {}, + parsed: {}, + } + ); + + itSchema( + "keys with any type can be omitted", + object({ + foo: any(), + }), + { + raw: {}, + parsed: {}, + } + ); + + describe("unrecognizedObjectKeys", () => { + describe("parse", () => { + itParse( + 'includes unknown values when unrecognizedObjectKeys === "passthrough"', + object({ + foo: property("raw_foo", string()), + bar: stringLiteral("bar"), + }), + { + raw: { + raw_foo: "foo", + bar: "bar", + // @ts-expect-error + baz: "yoyo", + }, + parsed: { + foo: "foo", + bar: "bar", + // @ts-expect-error + baz: "yoyo", + }, + opts: { + unrecognizedObjectKeys: "passthrough", + }, + } + ); + + itParse( + 'strips unknown values when unrecognizedObjectKeys === "strip"', + object({ + foo: property("raw_foo", string()), + bar: stringLiteral("bar"), + }), + { + raw: { + raw_foo: "foo", + bar: "bar", + // @ts-expect-error + baz: "yoyo", + }, + parsed: { + foo: "foo", + bar: "bar", + }, + opts: { + unrecognizedObjectKeys: "strip", + }, + } + ); + }); + + describe("json", () => { + itJson( + 'includes unknown values when unrecognizedObjectKeys === "passthrough"', + object({ + foo: property("raw_foo", string()), + bar: stringLiteral("bar"), + }), + { + raw: { + raw_foo: "foo", + bar: "bar", + // @ts-expect-error + baz: "yoyo", + }, + parsed: { + foo: "foo", + bar: "bar", + // @ts-expect-error + baz: "yoyo", + }, + opts: { + unrecognizedObjectKeys: "passthrough", + }, + } + ); + + itJson( + 'strips unknown values when unrecognizedObjectKeys === "strip"', + object({ + foo: property("raw_foo", string()), + bar: stringLiteral("bar"), + }), + { + raw: { + raw_foo: "foo", + bar: "bar", + }, + parsed: { + foo: "foo", + bar: "bar", + // @ts-expect-error + baz: "yoyo", + }, + opts: { + unrecognizedObjectKeys: "strip", + }, + } + ); + }); + }); + + describe("nullish properties", () => { + itSchema("missing properties are not added", object({ foo: property("raw_foo", string().optional()) }), { + raw: {}, + parsed: {}, + }); + + itSchema("undefined properties are not dropped", object({ foo: property("raw_foo", string().optional()) }), { + raw: { raw_foo: null }, + parsed: { foo: undefined }, + }); + + itSchema("null properties are not dropped", object({ foo: property("raw_foo", string().optional()) }), { + raw: { raw_foo: null }, + parsed: { foo: undefined }, + }); + + describe("extensions", () => { + itSchema( + "undefined properties are not dropped", + object({}).extend(object({ foo: property("raw_foo", string().optional()) })), + { + raw: { raw_foo: null }, + parsed: { foo: undefined }, + } + ); + + describe("parse()", () => { + itParse( + "null properties are not dropped", + object({}).extend(object({ foo: property("raw_foo", string().optional()) })), + { + raw: { raw_foo: null }, + parsed: { foo: undefined }, + } + ); + }); + }); + }); + + itValidate( + "missing property", + object({ + foo: string(), + bar: stringLiteral("bar"), + }), + { foo: "hello" }, + [ + { + path: [], + message: 'Missing required key "bar"', + }, + ] + ); + + itValidate( + "extra property", + object({ + foo: string(), + bar: stringLiteral("bar"), + }), + { foo: "hello", bar: "bar", baz: 42 }, + [ + { + path: ["baz"], + message: 'Unexpected key "baz"', + }, + ] + ); + + itValidate( + "not an object", + object({ + foo: string(), + bar: stringLiteral("bar"), + }), + [], + [ + { + path: [], + message: "Expected object. Received list.", + }, + ] + ); + + itValidate( + "nested validation error", + object({ + foo: object({ + bar: number(), + }), + }), + { foo: { bar: "hello" } }, + [ + { + path: ["foo", "bar"], + message: 'Expected number. Received "hello".', + }, + ] + ); +}); diff --git a/tests/unit/serializer/object/objectWithoutOptionalProperties.test.ts b/tests/unit/serializer/object/objectWithoutOptionalProperties.test.ts new file mode 100644 index 0000000..1f298bd --- /dev/null +++ b/tests/unit/serializer/object/objectWithoutOptionalProperties.test.ts @@ -0,0 +1,23 @@ +import { itSchema } from "../itSchema"; +import { stringLiteral } from "../../../../src/core/schemas"; +import { string } from "../../../../src/core/schemas"; +import { objectWithoutOptionalProperties } from "../../../../src/core/schemas"; + +describe("objectWithoutOptionalProperties", () => { + itSchema( + "all properties are required", + objectWithoutOptionalProperties({ + foo: string(), + bar: stringLiteral("bar").optional(), + }), + { + raw: { + foo: "hello", + }, + // @ts-expect-error + parsed: { + foo: "hello", + }, + } + ); +}); diff --git a/tests/unit/serializer/primitives/any.test.ts b/tests/unit/serializer/primitives/any.test.ts new file mode 100644 index 0000000..8d3b473 --- /dev/null +++ b/tests/unit/serializer/primitives/any.test.ts @@ -0,0 +1,6 @@ +import { itSchemaIdentity } from "../itSchema"; +import { any } from "../../../../src/core/schemas"; + +describe("any", () => { + itSchemaIdentity(any(), true); +}); diff --git a/tests/unit/serializer/primitives/boolean.test.ts b/tests/unit/serializer/primitives/boolean.test.ts new file mode 100644 index 0000000..3bf62e0 --- /dev/null +++ b/tests/unit/serializer/primitives/boolean.test.ts @@ -0,0 +1,14 @@ +import { itSchemaIdentity } from "../itSchema"; +import { itValidate } from "../itValidate"; +import { boolean } from "../../../../src/core/schemas"; + +describe("boolean", () => { + itSchemaIdentity(boolean(), true); + + itValidate("non-boolean", boolean(), {}, [ + { + path: [], + message: "Expected boolean. Received object.", + }, + ]); +}); diff --git a/tests/unit/serializer/primitives/number.test.ts b/tests/unit/serializer/primitives/number.test.ts new file mode 100644 index 0000000..ad004f5 --- /dev/null +++ b/tests/unit/serializer/primitives/number.test.ts @@ -0,0 +1,14 @@ +import { itSchemaIdentity } from "../itSchema"; +import { itValidate } from "../itValidate"; +import { number } from "../../../../src/core/schemas"; + +describe("number", () => { + itSchemaIdentity(number(), 42); + + itValidate("non-number", number(), "hello", [ + { + path: [], + message: 'Expected number. Received "hello".', + }, + ]); +}); diff --git a/tests/unit/serializer/primitives/string.test.ts b/tests/unit/serializer/primitives/string.test.ts new file mode 100644 index 0000000..dfb6fbd --- /dev/null +++ b/tests/unit/serializer/primitives/string.test.ts @@ -0,0 +1,14 @@ +import { itSchemaIdentity } from "../itSchema"; +import { itValidate } from "../itValidate"; +import { string } from "../../../../src/core/schemas"; + +describe("string", () => { + itSchemaIdentity(string(), "hello"); + + itValidate("non-string", string(), 42, [ + { + path: [], + message: "Expected string. Received 42.", + }, + ]); +}); diff --git a/tests/unit/serializer/primitives/unknown.test.ts b/tests/unit/serializer/primitives/unknown.test.ts new file mode 100644 index 0000000..09457d6 --- /dev/null +++ b/tests/unit/serializer/primitives/unknown.test.ts @@ -0,0 +1,6 @@ +import { itSchemaIdentity } from "../itSchema"; +import { unknown } from "../../../../src/core/schemas"; + +describe("unknown", () => { + itSchemaIdentity(unknown(), true); +}); diff --git a/tests/unit/serializer/record/record.test.ts b/tests/unit/serializer/record/record.test.ts new file mode 100644 index 0000000..79bbfff --- /dev/null +++ b/tests/unit/serializer/record/record.test.ts @@ -0,0 +1,35 @@ +import { itSchemaIdentity } from "../itSchema"; +import { itValidate } from "../itValidate"; +import { number, string } from "../../../../src/core/schemas"; +import { record } from "../../../../src/core/schemas"; + +describe("record", () => { + itSchemaIdentity(record(string(), string()), { hello: "world" }); + itSchemaIdentity(record(number(), string()), { 42: "world" }); + + itValidate( + "non-record", + record(number(), string()), + [], + [ + { + path: [], + message: "Expected object. Received list.", + }, + ] + ); + + itValidate("invalid key type", record(number(), string()), { hello: "world" }, [ + { + path: ["hello (key)"], + message: 'Expected number. Received "hello".', + }, + ]); + + itValidate("invalid value type", record(string(), number()), { hello: "world" }, [ + { + path: ["hello"], + message: 'Expected number. Received "world".', + }, + ]); +}); diff --git a/tests/unit/serializer/schema-utils/getSchemaUtils.test.ts b/tests/unit/serializer/schema-utils/getSchemaUtils.test.ts new file mode 100644 index 0000000..378f701 --- /dev/null +++ b/tests/unit/serializer/schema-utils/getSchemaUtils.test.ts @@ -0,0 +1,55 @@ +import { itSchema } from "../itSchema"; +import { object } from "../../../../src/core/schemas"; +import { string } from "../../../../src/core/schemas"; + +describe("getSchemaUtils", () => { + describe("optional()", () => { + itSchema("optional fields allow original schema", string().optional(), { + raw: "hello", + parsed: "hello", + }); + + itSchema("optional fields are not required", string().optional(), { + raw: null, + parsed: undefined, + }); + }); + + describe("transform()", () => { + itSchema( + "transorm and untransform run correctly", + string().transform({ + transform: (x) => x + "X", + untransform: (x) => (x as string).slice(0, -1), + }), + { + raw: "hello", + parsed: "helloX", + } + ); + }); + + describe("parseOrThrow()", () => { + it("parses valid value", async () => { + const value = string().parseOrThrow("hello"); + expect(value).toBe("hello"); + }); + + it("throws on invalid value", async () => { + const value = () => object({ a: string(), b: string() }).parseOrThrow({ a: 24 }); + expect(value).toThrowError(new Error('a: Expected string. Received 24.; Missing required key "b"')); + }); + }); + + describe("jsonOrThrow()", () => { + it("serializes valid value", async () => { + const value = string().jsonOrThrow("hello"); + expect(value).toBe("hello"); + }); + + it("throws on invalid value", async () => { + const value = () => object({ a: string(), b: string() }).jsonOrThrow({ a: 24 }); + expect(value).toThrowError(new Error('a: Expected string. Received 24.; Missing required key "b"')); + }); + }); +}); diff --git a/tests/unit/serializer/schema.test.ts b/tests/unit/serializer/schema.test.ts new file mode 100644 index 0000000..94089a9 --- /dev/null +++ b/tests/unit/serializer/schema.test.ts @@ -0,0 +1,78 @@ +import { + boolean, + discriminant, + list, + number, + object, + string, + stringLiteral, + union, +} from "../../../src/core/schemas/builders"; +import { booleanLiteral } from "../../../src/core/schemas/builders/literals/booleanLiteral"; +import { property } from "../../../src/core/schemas/builders/object/property"; +import { itSchema } from "./utils/itSchema"; + +describe("Schema", () => { + itSchema( + "large nested object", + object({ + a: string(), + b: stringLiteral("b value"), + c: property( + "raw_c", + list( + object({ + animal: union(discriminant("type", "_type"), { + dog: object({ value: boolean() }), + cat: object({ value: property("raw_cat", number()) }), + }), + }) + ) + ), + d: property("raw_d", boolean()), + e: booleanLiteral(true), + }), + { + raw: { + a: "hello", + b: "b value", + raw_c: [ + { + animal: { + _type: "dog", + value: true, + }, + }, + { + animal: { + _type: "cat", + raw_cat: 42, + }, + }, + ], + raw_d: false, + e: true, + }, + parsed: { + a: "hello", + b: "b value", + c: [ + { + animal: { + type: "dog", + value: true, + }, + }, + { + animal: { + type: "cat", + value: 42, + }, + }, + ], + d: false, + e: true, + }, + } + ); +}); diff --git a/tests/unit/serializer/set/set.test.ts b/tests/unit/serializer/set/set.test.ts new file mode 100644 index 0000000..2cad2d6 --- /dev/null +++ b/tests/unit/serializer/set/set.test.ts @@ -0,0 +1,49 @@ +import { itSchema } from "../itSchema"; +import { itValidateJson, itValidateParse } from "../itValidate"; +import { string } from "../../../../src/core/schemas"; +import { set } from "../../../../src/core/schemas"; + +describe("set", () => { + itSchema("converts between raw list and parsed Set", set(string()), { + raw: ["A", "B"], + parsed: new Set(["A", "B"]), + }); + + itValidateParse("not a list", set(string()), 42, [ + { + path: [], + message: "Expected list. Received 42.", + }, + ]); + + itValidateJson( + "not a Set", + set(string()), + [], + [ + { + path: [], + message: "Expected Set. Received list.", + }, + ] + ); + + itValidateParse( + "invalid item type", + set(string()), + [42], + [ + { + path: ["[0]"], + message: "Expected string. Received 42.", + }, + ] + ); + + itValidateJson("invalid item type", set(string()), new Set([42]), [ + { + path: ["[0]"], + message: "Expected string. Received 42.", + }, + ]); +}); diff --git a/tests/unit/serializer/skipValidation.test.ts b/tests/unit/serializer/skipValidation.test.ts new file mode 100644 index 0000000..5dc8809 --- /dev/null +++ b/tests/unit/serializer/skipValidation.test.ts @@ -0,0 +1,45 @@ +/* eslint-disable no-console */ + +import { boolean, number, object, property, string, undiscriminatedUnion } from "../../../src/core/schemas/builders"; + +describe("skipValidation", () => { + it("allows data that doesn't conform to the schema", async () => { + const warningLogs: string[] = []; + const originalConsoleWarn = console.warn; + console.warn = (...args) => warningLogs.push(args.join(" ")); + + const schema = object({ + camelCase: property("snake_case", string()), + numberProperty: number(), + requiredProperty: boolean(), + anyPrimitive: undiscriminatedUnion([string(), number(), boolean()]), + }); + + const parsed = await schema.parse( + { + snake_case: "hello", + numberProperty: "oops", + anyPrimitive: true, + }, + { + skipValidation: true, + } + ); + + expect(parsed).toEqual({ + ok: true, + value: { + camelCase: "hello", + numberProperty: "oops", + anyPrimitive: true, + }, + }); + + expect(warningLogs).toEqual([ + `Failed to validate. + - numberProperty: Expected number. Received "oops".`, + ]); + + console.warn = originalConsoleWarn; + }); +}); diff --git a/tests/unit/serializer/undiscriminated-union/undiscriminatedUnion.test.ts b/tests/unit/serializer/undiscriminated-union/undiscriminatedUnion.test.ts new file mode 100644 index 0000000..b7eea32 --- /dev/null +++ b/tests/unit/serializer/undiscriminated-union/undiscriminatedUnion.test.ts @@ -0,0 +1,46 @@ +import { itSchema, itSchemaIdentity } from "../itSchema"; +import { object, property } from "../../../../src/core/schemas"; +import { number, string } from "../../../../src/core/schemas"; +import { undiscriminatedUnion } from "../../../../src/core/schemas"; + +describe("undiscriminatedUnion", () => { + itSchemaIdentity(undiscriminatedUnion([string(), number()]), "hello world"); + + itSchemaIdentity(undiscriminatedUnion([object({ hello: string() }), object({ goodbye: string() })]), { + goodbye: "foo", + }); + + itSchema( + "Correctly transforms", + undiscriminatedUnion([object({ hello: string() }), object({ helloWorld: property("hello_world", string()) })]), + { + raw: { hello_world: "foo " }, + parsed: { helloWorld: "foo " }, + } + ); + + it("Returns errors for all variants", async () => { + const result = await undiscriminatedUnion([string(), number()]).parse(true); + if (result.ok) { + throw new Error("Unexpectedly passed validation"); + } + expect(result.errors).toEqual([ + { + message: "[Variant 0] Expected string. Received true.", + path: [], + }, + { + message: "[Variant 1] Expected number. Received true.", + path: [], + }, + ]); + }); + + describe("compile", () => { + // eslint-disable-next-line jest/expect-expect + it("doesn't compile with zero members", () => { + // @ts-expect-error + () => undiscriminatedUnion([]); + }); + }); +}); diff --git a/tests/unit/serializer/union/union.test.ts b/tests/unit/serializer/union/union.test.ts new file mode 100644 index 0000000..f782a8e --- /dev/null +++ b/tests/unit/serializer/union/union.test.ts @@ -0,0 +1,116 @@ +import { itSchema, itSchemaIdentity } from "../itSchema"; +import { itValidate } from "../itValidate"; +import { object } from "../../../../src/core/schemas"; +import { boolean, number, string } from "../../../../src/core/schemas"; +import { discriminant } from "../../../../src/core/schemas"; +import { union } from "../../../../src/core/schemas"; + +describe("union", () => { + itSchemaIdentity( + union("type", { + lion: object({ + meows: boolean(), + }), + giraffe: object({ + heightInInches: number(), + }), + }), + { type: "lion", meows: true }, + { title: "doesn't transform discriminant when it's a string" } + ); + + itSchema( + "transforms discriminant when it's a discriminant()", + union(discriminant("type", "_type"), { + lion: object({ meows: boolean() }), + giraffe: object({ heightInInches: number() }), + }), + { + raw: { _type: "lion", meows: true }, + parsed: { type: "lion", meows: true }, + } + ); + + describe("allowUnrecognizedUnionMembers", () => { + itSchema( + "transforms discriminant & passes through values when discriminant value is unrecognized", + union(discriminant("type", "_type"), { + lion: object({ meows: boolean() }), + giraffe: object({ heightInInches: number() }), + }), + { + // @ts-expect-error + raw: { _type: "moose", isAMoose: true }, + // @ts-expect-error + parsed: { type: "moose", isAMoose: true }, + opts: { + allowUnrecognizedUnionMembers: true, + }, + } + ); + }); + + describe("withParsedProperties", () => { + it("Added property is included on parsed object", async () => { + const schema = union("type", { + lion: object({}), + tiger: object({ value: string() }), + }).withParsedProperties({ + printType: (parsed) => () => parsed.type, + }); + + const parsed = await schema.parse({ type: "lion" }); + if (!parsed.ok) { + throw new Error("Failed to parse"); + } + expect(parsed.value.printType()).toBe("lion"); + }); + }); + + itValidate( + "non-object", + union("type", { + lion: object({}), + tiger: object({ value: string() }), + }), + [], + [ + { + path: [], + message: "Expected object. Received list.", + }, + ] + ); + + itValidate( + "missing discriminant", + union("type", { + lion: object({}), + tiger: object({ value: string() }), + }), + {}, + [ + { + path: [], + message: 'Missing discriminant ("type")', + }, + ] + ); + + itValidate( + "unrecognized discriminant value", + union("type", { + lion: object({}), + tiger: object({ value: string() }), + }), + { + type: "bear", + }, + [ + { + path: ["type"], + message: 'Expected enum. Received "bear".', + }, + ] + ); +}); diff --git a/tests/unit/serializer/utils/itSchema.ts b/tests/unit/serializer/utils/itSchema.ts new file mode 100644 index 0000000..67b6c92 --- /dev/null +++ b/tests/unit/serializer/utils/itSchema.ts @@ -0,0 +1,78 @@ +/* eslint-disable jest/no-export */ +import { Schema, SchemaOptions } from "../../../../src/core/schemas/Schema"; + +export function itSchemaIdentity( + schema: Schema, + value: T, + { title = "functions as identity", opts }: { title?: string; opts?: SchemaOptions } = {} +): void { + itSchema(title, schema, { raw: value, parsed: value, opts }); +} + +export function itSchema( + title: string, + schema: Schema, + { + raw, + parsed, + opts, + only = false, + }: { + raw: Raw; + parsed: Parsed; + opts?: SchemaOptions; + only?: boolean; + } +): void { + // eslint-disable-next-line jest/valid-title + (only ? describe.only : describe)(title, () => { + itParse("parse()", schema, { raw, parsed, opts }); + itJson("json()", schema, { raw, parsed, opts }); + }); +} + +export function itParse( + title: string, + schema: Schema, + { + raw, + parsed, + opts, + }: { + raw: Raw; + parsed: Parsed; + opts?: SchemaOptions; + } +): void { + // eslint-disable-next-line jest/valid-title + it(title, () => { + const maybeValid = schema.parse(raw, opts); + if (!maybeValid.ok) { + throw new Error("Failed to parse() " + JSON.stringify(maybeValid.errors, undefined, 4)); + } + expect(maybeValid.value).toStrictEqual(parsed); + }); +} + +export function itJson( + title: string, + schema: Schema, + { + raw, + parsed, + opts, + }: { + raw: Raw; + parsed: Parsed; + opts?: SchemaOptions; + } +): void { + // eslint-disable-next-line jest/valid-title + it(title, () => { + const maybeValid = schema.json(parsed, opts); + if (!maybeValid.ok) { + throw new Error("Failed to json() " + JSON.stringify(maybeValid.errors, undefined, 4)); + } + expect(maybeValid.value).toStrictEqual(raw); + }); +} diff --git a/tests/unit/serializer/utils/itValidate.ts b/tests/unit/serializer/utils/itValidate.ts new file mode 100644 index 0000000..75b2c08 --- /dev/null +++ b/tests/unit/serializer/utils/itValidate.ts @@ -0,0 +1,56 @@ +/* eslint-disable jest/no-export */ +import { Schema, SchemaOptions, ValidationError } from "../../../../src/core/schemas/Schema"; + +export function itValidate( + title: string, + schema: Schema, + input: unknown, + errors: ValidationError[], + opts?: SchemaOptions +): void { + // eslint-disable-next-line jest/valid-title + describe("parse()", () => { + itValidateParse(title, schema, input, errors, opts); + }); + describe("json()", () => { + itValidateJson(title, schema, input, errors, opts); + }); +} + +export function itValidateParse( + title: string, + schema: Schema, + raw: unknown, + errors: ValidationError[], + opts?: SchemaOptions +): void { + describe("parse", () => { + // eslint-disable-next-line jest/valid-title + it(title, async () => { + const maybeValid = await schema.parse(raw, opts); + if (maybeValid.ok) { + throw new Error("Value passed validation"); + } + expect(maybeValid.errors).toStrictEqual(errors); + }); + }); +} + +export function itValidateJson( + title: string, + schema: Schema, + parsed: unknown, + errors: ValidationError[], + opts?: SchemaOptions +): void { + describe("json", () => { + // eslint-disable-next-line jest/valid-title + it(title, async () => { + const maybeValid = await schema.json(parsed, opts); + if (maybeValid.ok) { + throw new Error("Value passed validation"); + } + expect(maybeValid.errors).toStrictEqual(errors); + }); + }); +} diff --git a/yarn.lock b/yarn.lock index f4d65f6..68239e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1232,6 +1232,11 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fern-api@^0.31.22: + version "0.31.24" + resolved "https://registry.yarnpkg.com/fern-api/-/fern-api-0.31.24.tgz#352d9151b618fcd8e4a0cc3a27151a2a1a4f28eb" + integrity sha512-hO0BY0q3+//OVLALI6875Sh6OlMPRJG4HeIRjIaX4ZmMtPbsZKMoowPSKWyewwnw2uYotklIgIsZWLKoAo7C3A== + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"