From f0ddb4f8dc8bd49f815ae611f6021aef44fd2aac Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Thu, 5 Feb 2026 16:59:58 +0100 Subject: [PATCH 1/3] Exclude non-shippable products from shipping zones diagnostics --- src/fragments/products.ts | 1 + src/graphql/hooks.generated.ts | 1 + src/graphql/types.generated.ts | 4 +- ...useProductAvailabilityDiagnostics.test.tsx | 1 + .../utils/availabilityChecks.test.ts | 262 ++++++++++++++++++ .../ProductDoctor/utils/availabilityChecks.ts | 21 +- .../utils/mapProductToDiagnosticData.test.ts | 117 +++++++- .../utils/mapProductToDiagnosticData.ts | 2 + .../components/ProductDoctor/utils/types.ts | 14 + src/products/fixtures.ts | 6 +- 10 files changed, 408 insertions(+), 21 deletions(-) create mode 100644 src/products/components/ProductDoctor/utils/availabilityChecks.test.ts diff --git a/src/fragments/products.ts b/src/fragments/products.ts index 872aa7fe1be..1cc5a983648 100644 --- a/src/fragments/products.ts +++ b/src/fragments/products.ts @@ -230,6 +230,7 @@ export const productFragmentDetails = gql` id name hasVariants + isShippingRequired } weight { ...Weight diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index c366e302768..47f2fc9e849 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -3014,6 +3014,7 @@ export const ProductFragmentDoc = gql` id name hasVariants + isShippingRequired } weight { ...Weight diff --git a/src/graphql/types.generated.ts b/src/graphql/types.generated.ts index b59d6a02710..8107bd1f443 100644 --- a/src/graphql/types.generated.ts +++ b/src/graphql/types.generated.ts @@ -10483,7 +10483,7 @@ export type ProductVariantAttributesFragment = { __typename: 'Product', id: stri export type ProductDetailsVariantFragment = { __typename: 'ProductVariant', id: string, sku: string | null, name: string, trackInventory: boolean, quantityLimitPerCustomer: number | null, attributes: Array<{ __typename: 'SelectedAttribute', attribute: { __typename: 'Attribute', id: string, name: string | null }, values: Array<{ __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null }> }>, media: Array<{ __typename: 'ProductMedia', url: string }> | null, stocks: Array<{ __typename: 'Stock', id: string, quantity: number, quantityAllocated: number, warehouse: { __typename: 'Warehouse', id: string, name: string } }> | null, preorder: { __typename: 'PreorderData', globalThreshold: number | null, globalSoldUnits: number, endDate: any | null } | null, channelListings: Array<{ __typename: 'ProductVariantChannelListing', id: string, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string }, price: { __typename: 'Money', amount: number, currency: string } | null, costPrice: { __typename: 'Money', amount: number, currency: string } | null, preorderThreshold: { __typename: 'PreorderThreshold', quantity: number | null, soldUnits: number } | null }> | null }; -export type ProductFragment = { __typename: 'Product', name: string, slug: string, description: any | null, seoTitle: string | null, seoDescription: string | null, rating: number | null, isAvailable: boolean | null, id: string, defaultVariant: { __typename: 'ProductVariant', id: string } | null, category: { __typename: 'Category', id: string, name: string } | null, collections: Array<{ __typename: 'Collection', id: string, name: string }> | null, channelListings: Array<{ __typename: 'ProductChannelListing', id: string, isPublished: boolean, publishedAt: any | null, isAvailableForPurchase: boolean | null, availableForPurchaseAt: any | null, visibleInListings: boolean, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string, slug: string } }> | null, media: Array<{ __typename: 'ProductMedia', id: string, alt: string, sortOrder: number | null, url: string, type: ProductMediaType, oembedData: any }> | null, variants: Array<{ __typename: 'ProductVariant', id: string, sku: string | null, name: string, trackInventory: boolean, quantityLimitPerCustomer: number | null, attributes: Array<{ __typename: 'SelectedAttribute', attribute: { __typename: 'Attribute', id: string, name: string | null }, values: Array<{ __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null }> }>, media: Array<{ __typename: 'ProductMedia', url: string }> | null, stocks: Array<{ __typename: 'Stock', id: string, quantity: number, quantityAllocated: number, warehouse: { __typename: 'Warehouse', id: string, name: string } }> | null, preorder: { __typename: 'PreorderData', globalThreshold: number | null, globalSoldUnits: number, endDate: any | null } | null, channelListings: Array<{ __typename: 'ProductVariantChannelListing', id: string, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string }, price: { __typename: 'Money', amount: number, currency: string } | null, costPrice: { __typename: 'Money', amount: number, currency: string } | null, preorderThreshold: { __typename: 'PreorderThreshold', quantity: number | null, soldUnits: number } | null }> | null }> | null, productType: { __typename: 'ProductType', id: string, name: string, hasVariants: boolean, variantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null, selectionVariantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null, nonSelectionVariantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null }, weight: { __typename: 'Weight', unit: WeightUnitsEnum, value: number } | null, taxClass: { __typename: 'TaxClass', id: string, name: string } | null, attributes: Array<{ __typename: 'SelectedAttribute', attribute: { __typename: 'Attribute', availableInGrid: boolean, entityType: AttributeEntityTypeEnum | null, storefrontSearchPosition: number, valueRequired: boolean, id: string, name: string | null, slug: string | null, type: AttributeTypeEnum | null, visibleInStorefront: boolean, filterableInDashboard: boolean, filterableInStorefront: boolean, unit: MeasurementUnitsEnum | null, inputType: AttributeInputTypeEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }, values: Array<{ __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null }> }>, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }> }; +export type ProductFragment = { __typename: 'Product', name: string, slug: string, description: any | null, seoTitle: string | null, seoDescription: string | null, rating: number | null, isAvailable: boolean | null, id: string, defaultVariant: { __typename: 'ProductVariant', id: string } | null, category: { __typename: 'Category', id: string, name: string } | null, collections: Array<{ __typename: 'Collection', id: string, name: string }> | null, channelListings: Array<{ __typename: 'ProductChannelListing', id: string, isPublished: boolean, publishedAt: any | null, isAvailableForPurchase: boolean | null, availableForPurchaseAt: any | null, visibleInListings: boolean, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string, slug: string } }> | null, media: Array<{ __typename: 'ProductMedia', id: string, alt: string, sortOrder: number | null, url: string, type: ProductMediaType, oembedData: any }> | null, variants: Array<{ __typename: 'ProductVariant', id: string, sku: string | null, name: string, trackInventory: boolean, quantityLimitPerCustomer: number | null, attributes: Array<{ __typename: 'SelectedAttribute', attribute: { __typename: 'Attribute', id: string, name: string | null }, values: Array<{ __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null }> }>, media: Array<{ __typename: 'ProductMedia', url: string }> | null, stocks: Array<{ __typename: 'Stock', id: string, quantity: number, quantityAllocated: number, warehouse: { __typename: 'Warehouse', id: string, name: string } }> | null, preorder: { __typename: 'PreorderData', globalThreshold: number | null, globalSoldUnits: number, endDate: any | null } | null, channelListings: Array<{ __typename: 'ProductVariantChannelListing', id: string, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string }, price: { __typename: 'Money', amount: number, currency: string } | null, costPrice: { __typename: 'Money', amount: number, currency: string } | null, preorderThreshold: { __typename: 'PreorderThreshold', quantity: number | null, soldUnits: number } | null }> | null }> | null, productType: { __typename: 'ProductType', id: string, name: string, hasVariants: boolean, isShippingRequired: boolean, variantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null, selectionVariantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null, nonSelectionVariantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null }, weight: { __typename: 'Weight', unit: WeightUnitsEnum, value: number } | null, taxClass: { __typename: 'TaxClass', id: string, name: string } | null, attributes: Array<{ __typename: 'SelectedAttribute', attribute: { __typename: 'Attribute', availableInGrid: boolean, entityType: AttributeEntityTypeEnum | null, storefrontSearchPosition: number, valueRequired: boolean, id: string, name: string | null, slug: string | null, type: AttributeTypeEnum | null, visibleInStorefront: boolean, filterableInDashboard: boolean, filterableInStorefront: boolean, unit: MeasurementUnitsEnum | null, inputType: AttributeInputTypeEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }, values: Array<{ __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null }> }>, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }> }; export type VariantAttributeFragment = { __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }; @@ -11701,7 +11701,7 @@ export type ProductDetailsQueryVariables = Exact<{ }>; -export type ProductDetailsQuery = { __typename: 'Query', product: { __typename: 'Product', name: string, slug: string, description: any | null, seoTitle: string | null, seoDescription: string | null, rating: number | null, isAvailable: boolean | null, id: string, category: { __typename: 'Category', id: string, name: string, level: number, parent: { __typename: 'Category', id: string, name: string } | null, ancestors: { __typename: 'CategoryCountableConnection', edges: Array<{ __typename: 'CategoryCountableEdge', node: { __typename: 'Category', id: string, name: string } }> } | null } | null, defaultVariant: { __typename: 'ProductVariant', id: string } | null, collections: Array<{ __typename: 'Collection', id: string, name: string }> | null, channelListings: Array<{ __typename: 'ProductChannelListing', id: string, isPublished: boolean, publishedAt: any | null, isAvailableForPurchase: boolean | null, availableForPurchaseAt: any | null, visibleInListings: boolean, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string, slug: string } }> | null, media: Array<{ __typename: 'ProductMedia', id: string, alt: string, sortOrder: number | null, url: string, type: ProductMediaType, oembedData: any }> | null, variants: Array<{ __typename: 'ProductVariant', id: string, sku: string | null, name: string, trackInventory: boolean, quantityLimitPerCustomer: number | null, attributes: Array<{ __typename: 'SelectedAttribute', attribute: { __typename: 'Attribute', id: string, name: string | null }, values: Array<{ __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null }> }>, media: Array<{ __typename: 'ProductMedia', url: string }> | null, stocks: Array<{ __typename: 'Stock', id: string, quantity: number, quantityAllocated: number, warehouse: { __typename: 'Warehouse', id: string, name: string } }> | null, preorder: { __typename: 'PreorderData', globalThreshold: number | null, globalSoldUnits: number, endDate: any | null } | null, channelListings: Array<{ __typename: 'ProductVariantChannelListing', id: string, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string }, price: { __typename: 'Money', amount: number, currency: string } | null, costPrice: { __typename: 'Money', amount: number, currency: string } | null, preorderThreshold: { __typename: 'PreorderThreshold', quantity: number | null, soldUnits: number } | null }> | null }> | null, productType: { __typename: 'ProductType', id: string, name: string, hasVariants: boolean, variantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null, selectionVariantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null, nonSelectionVariantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null }, weight: { __typename: 'Weight', unit: WeightUnitsEnum, value: number } | null, taxClass: { __typename: 'TaxClass', id: string, name: string } | null, attributes: Array<{ __typename: 'SelectedAttribute', attribute: { __typename: 'Attribute', availableInGrid: boolean, entityType: AttributeEntityTypeEnum | null, storefrontSearchPosition: number, valueRequired: boolean, id: string, name: string | null, slug: string | null, type: AttributeTypeEnum | null, visibleInStorefront: boolean, filterableInDashboard: boolean, filterableInStorefront: boolean, unit: MeasurementUnitsEnum | null, inputType: AttributeInputTypeEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }, values: Array<{ __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null }> }>, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }> } | null }; +export type ProductDetailsQuery = { __typename: 'Query', product: { __typename: 'Product', name: string, slug: string, description: any | null, seoTitle: string | null, seoDescription: string | null, rating: number | null, isAvailable: boolean | null, id: string, category: { __typename: 'Category', id: string, name: string, level: number, parent: { __typename: 'Category', id: string, name: string } | null, ancestors: { __typename: 'CategoryCountableConnection', edges: Array<{ __typename: 'CategoryCountableEdge', node: { __typename: 'Category', id: string, name: string } }> } | null } | null, defaultVariant: { __typename: 'ProductVariant', id: string } | null, collections: Array<{ __typename: 'Collection', id: string, name: string }> | null, channelListings: Array<{ __typename: 'ProductChannelListing', id: string, isPublished: boolean, publishedAt: any | null, isAvailableForPurchase: boolean | null, availableForPurchaseAt: any | null, visibleInListings: boolean, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string, slug: string } }> | null, media: Array<{ __typename: 'ProductMedia', id: string, alt: string, sortOrder: number | null, url: string, type: ProductMediaType, oembedData: any }> | null, variants: Array<{ __typename: 'ProductVariant', id: string, sku: string | null, name: string, trackInventory: boolean, quantityLimitPerCustomer: number | null, attributes: Array<{ __typename: 'SelectedAttribute', attribute: { __typename: 'Attribute', id: string, name: string | null }, values: Array<{ __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null }> }>, media: Array<{ __typename: 'ProductMedia', url: string }> | null, stocks: Array<{ __typename: 'Stock', id: string, quantity: number, quantityAllocated: number, warehouse: { __typename: 'Warehouse', id: string, name: string } }> | null, preorder: { __typename: 'PreorderData', globalThreshold: number | null, globalSoldUnits: number, endDate: any | null } | null, channelListings: Array<{ __typename: 'ProductVariantChannelListing', id: string, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string }, price: { __typename: 'Money', amount: number, currency: string } | null, costPrice: { __typename: 'Money', amount: number, currency: string } | null, preorderThreshold: { __typename: 'PreorderThreshold', quantity: number | null, soldUnits: number } | null }> | null }> | null, productType: { __typename: 'ProductType', id: string, name: string, hasVariants: boolean, isShippingRequired: boolean, variantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null, selectionVariantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null, nonSelectionVariantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null }, weight: { __typename: 'Weight', unit: WeightUnitsEnum, value: number } | null, taxClass: { __typename: 'TaxClass', id: string, name: string } | null, attributes: Array<{ __typename: 'SelectedAttribute', attribute: { __typename: 'Attribute', availableInGrid: boolean, entityType: AttributeEntityTypeEnum | null, storefrontSearchPosition: number, valueRequired: boolean, id: string, name: string | null, slug: string | null, type: AttributeTypeEnum | null, visibleInStorefront: boolean, filterableInDashboard: boolean, filterableInStorefront: boolean, unit: MeasurementUnitsEnum | null, inputType: AttributeInputTypeEnum | null, referenceTypes: Array<{ __typename: 'PageType', id: string, name: string } | { __typename: 'ProductType', id: string, name: string }> | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }, values: Array<{ __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: string | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null }> }>, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }> } | null }; export type ProductTypeQueryVariables = Exact<{ id: Scalars['ID']; diff --git a/src/products/components/ProductDoctor/hooks/useProductAvailabilityDiagnostics.test.tsx b/src/products/components/ProductDoctor/hooks/useProductAvailabilityDiagnostics.test.tsx index eab21170b1c..fd0eeff122a 100644 --- a/src/products/components/ProductDoctor/hooks/useProductAvailabilityDiagnostics.test.tsx +++ b/src/products/components/ProductDoctor/hooks/useProductAvailabilityDiagnostics.test.tsx @@ -30,6 +30,7 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( const createMockProduct = (overrides?: Partial): ProductDiagnosticData => ({ id: "product-123", name: "Test Product", + isShippingRequired: true, channelListings: [ { channel: { diff --git a/src/products/components/ProductDoctor/utils/availabilityChecks.test.ts b/src/products/components/ProductDoctor/utils/availabilityChecks.test.ts new file mode 100644 index 00000000000..a85f6055667 --- /dev/null +++ b/src/products/components/ProductDoctor/utils/availabilityChecks.test.ts @@ -0,0 +1,262 @@ +import { IntlShape, MessageDescriptor } from "react-intl"; + +import { runAvailabilityChecks } from "./availabilityChecks"; +import { ChannelDiagnosticData, ProductDiagnosticData } from "./types"; + +// Mock intl for testing - validates that message placeholders are provided +const createMockIntl = (): IntlShape => + ({ + formatMessage: (descriptor: MessageDescriptor, values?: Record) => { + const message = String(descriptor.defaultMessage ?? ""); + + // Extract placeholders like {channelName} from the message + const placeholders: string[] = message.match(/\{(\w+)\}/g) ?? []; + + // Verify all placeholders have corresponding values + placeholders.forEach((placeholder: string) => { + const key = placeholder.slice(1, -1); // Remove { and } + + if (!values || !(key in values)) { + throw new Error(`Missing value for placeholder "${key}" in message: "${message}"`); + } + }); + + // Replace placeholders with values for readable test output + let result = message; + + if (values) { + Object.entries(values).forEach(([key, value]) => { + result = result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value)); + }); + } + + return result; + }, + }) as unknown as IntlShape; + +const mockIntl = createMockIntl(); + +// Helper to create a minimal product diagnostic data +const createProduct = (overrides: Partial = {}): ProductDiagnosticData => ({ + id: "product-1", + name: "Test Product", + isShippingRequired: true, + channelListings: [ + { + channel: { id: "channel-1", name: "Default Channel", slug: "default-channel" }, + isPublished: true, + publishedAt: "2024-01-01T00:00:00Z", + isAvailableForPurchase: true, + availableForPurchaseAt: "2024-01-01T00:00:00Z", + visibleInListings: true, + }, + ], + variants: [ + { + id: "variant-1", + name: "Variant A", + channelListings: [{ channel: { id: "channel-1" }, price: { amount: 10.0 } }], + stocks: [{ warehouse: { id: "warehouse-1" }, quantity: 100 }], + }, + ], + ...overrides, +}); + +// Helper to create channel diagnostic data +const createChannelData = ( + overrides: Partial = {}, +): ChannelDiagnosticData => ({ + id: "channel-1", + name: "Default Channel", + slug: "default-channel", + isActive: true, + warehouses: [{ id: "warehouse-1", name: "Main Warehouse" }], + shippingZones: [], + ...overrides, +}); + +describe("runAvailabilityChecks", () => { + describe("shipping checks for shippable products", () => { + it("should return no-shipping-zones warning for shippable product without shipping zones", () => { + // Arrange + const product = createProduct({ isShippingRequired: true }); + const channelData = createChannelData({ shippingZones: [] }); + const channelListing = product.channelListings[0]; + + // Act + const issues = runAvailabilityChecks(product, channelData, channelListing, mockIntl); + + // Assert + const shippingIssue = issues.find(i => i.id === "no-shipping-zones"); + + expect(shippingIssue).toBeDefined(); + expect(shippingIssue?.severity).toBe("warning"); + }); + + it("should return warehouse-not-in-zone warning when stock is not reachable via shipping", () => { + // Arrange + const product = createProduct({ isShippingRequired: true }); + const channelData = createChannelData({ + shippingZones: [ + { + id: "zone-1", + name: "Zone 1", + countries: [{ code: "US", country: "United States" }], + // Warehouse in zone is different from warehouse with stock + warehouses: [{ id: "warehouse-other", name: "Other Warehouse" }], + }, + ], + }); + const channelListing = product.channelListings[0]; + + // Act + const issues = runAvailabilityChecks(product, channelData, channelListing, mockIntl); + + // Assert + const zoneIssue = issues.find(i => i.id === "warehouse-not-in-zone"); + + expect(zoneIssue).toBeDefined(); + expect(zoneIssue?.severity).toBe("warning"); + }); + + it("should NOT return shipping warnings when properly configured", () => { + // Arrange - shippable product with correct shipping zone configuration + const product = createProduct({ isShippingRequired: true }); + const channelData = createChannelData({ + shippingZones: [ + { + id: "zone-1", + name: "Zone 1", + countries: [{ code: "US", country: "United States" }], + // Same warehouse as product stock - properly configured + warehouses: [{ id: "warehouse-1", name: "Main Warehouse" }], + }, + ], + }); + const channelListing = product.channelListings[0]; + + // Act + const issues = runAvailabilityChecks(product, channelData, channelListing, mockIntl); + + // Assert - no shipping-related warnings + const shippingIssues = issues.filter( + i => i.id === "no-shipping-zones" || i.id === "warehouse-not-in-zone", + ); + + expect(shippingIssues).toHaveLength(0); + }); + }); + + describe("shipping checks for non-shippable products", () => { + it("should NOT return no-shipping-zones warning for non-shippable product", () => { + // Arrange - digital product that doesn't require shipping + const product = createProduct({ isShippingRequired: false }); + const channelData = createChannelData({ shippingZones: [] }); + const channelListing = product.channelListings[0]; + + // Act + const issues = runAvailabilityChecks(product, channelData, channelListing, mockIntl); + + // Assert + const shippingIssue = issues.find(i => i.id === "no-shipping-zones"); + + expect(shippingIssue).toBeUndefined(); + }); + + it("should NOT return warehouse-not-in-zone warning for non-shippable product", () => { + // Arrange - digital product with stock but no shipping zone coverage + const product = createProduct({ isShippingRequired: false }); + const channelData = createChannelData({ + shippingZones: [ + { + id: "zone-1", + name: "Zone 1", + countries: [{ code: "US", country: "United States" }], + // Warehouse in zone is different from warehouse with stock + warehouses: [{ id: "warehouse-other", name: "Other Warehouse" }], + }, + ], + }); + const channelListing = product.channelListings[0]; + + // Act + const issues = runAvailabilityChecks(product, channelData, channelListing, mockIntl); + + // Assert + const zoneIssue = issues.find(i => i.id === "warehouse-not-in-zone"); + + expect(zoneIssue).toBeUndefined(); + }); + + it("should still run warehouse checks for non-shippable products", () => { + // Arrange - digital product that tracks inventory but has no warehouses + const product = createProduct({ isShippingRequired: false }); + const channelData = createChannelData({ warehouses: [] }); + const channelListing = product.channelListings[0]; + + // Act + const issues = runAvailabilityChecks(product, channelData, channelListing, mockIntl); + + // Assert - warehouse warning should still appear + const warehouseIssue = issues.find(i => i.id === "no-warehouses"); + + expect(warehouseIssue).toBeDefined(); + expect(warehouseIssue?.severity).toBe("warning"); + }); + + it("should still run core checks for non-shippable products", () => { + // Arrange - digital product with no variants + const product = createProduct({ + isShippingRequired: false, + variants: [], + }); + const channelData = createChannelData(); + const channelListing = product.channelListings[0]; + + // Act + const issues = runAvailabilityChecks(product, channelData, channelListing, mockIntl); + + // Assert - core checks should still run + const noVariantsIssue = issues.find(i => i.id === "no-variants"); + + expect(noVariantsIssue).toBeDefined(); + expect(noVariantsIssue?.severity).toBe("error"); + }); + }); + + describe("skip options", () => { + it("should skip warehouse checks when skipWarehouseChecks is true", () => { + // Arrange + const product = createProduct({ isShippingRequired: true }); + const channelData = createChannelData({ warehouses: [] }); + const channelListing = product.channelListings[0]; + + // Act + const issues = runAvailabilityChecks(product, channelData, channelListing, mockIntl, { + skipWarehouseChecks: true, + }); + + // Assert + const warehouseIssue = issues.find(i => i.id === "no-warehouses"); + + expect(warehouseIssue).toBeUndefined(); + }); + + it("should skip shipping checks when skipShippingChecks is true even for shippable products", () => { + // Arrange + const product = createProduct({ isShippingRequired: true }); + const channelData = createChannelData({ shippingZones: [] }); + const channelListing = product.channelListings[0]; + + // Act + const issues = runAvailabilityChecks(product, channelData, channelListing, mockIntl, { + skipShippingChecks: true, + }); + + // Assert + const shippingIssue = issues.find(i => i.id === "no-shipping-zones"); + + expect(shippingIssue).toBeUndefined(); + }); + }); +}); diff --git a/src/products/components/ProductDoctor/utils/availabilityChecks.ts b/src/products/components/ProductDoctor/utils/availabilityChecks.ts index b883422c336..0ed3d0236c4 100644 --- a/src/products/components/ProductDoctor/utils/availabilityChecks.ts +++ b/src/products/components/ProductDoctor/utils/availabilityChecks.ts @@ -227,11 +227,13 @@ const checkWarehouseNotInShippingZone: CheckFunction = ({ product, channelData, }); }); - // Check if any warehouse with stock is also in a shipping zone + // Pre-compute channel warehouse IDs for O(1) lookup instead of O(W) scan + const channelWarehouseIds = new Set(channelData.warehouses.map(w => w.id)); + + // Check if any warehouse with stock is also in a shipping zone AND assigned to this channel const hasReachableStock = Array.from(warehouseIdsWithStock).some( warehouseId => - warehouseIdsInShippingZones.has(warehouseId) && - channelData.warehouses.some(w => w.id === warehouseId), + warehouseIdsInShippingZones.has(warehouseId) && channelWarehouseIds.has(warehouseId), ); if (warehouseIdsWithStock.size > 0 && !hasReachableStock) { @@ -305,7 +307,10 @@ export function runAvailabilityChecks( } } - // Run warehouse checks only if we have permission + // Run warehouse checks only if we have permission. + // Note: Warehouse checks run for ALL products (including non-shippable) because: + // - Non-shippable products may still track inventory (e.g., activation codes, digital license keys) + // - If a product doesn't track inventory, variant.stocks will be empty and checks will pass if (!skipOptions?.skipWarehouseChecks) { for (const check of warehouseChecks) { const issue = check(context); @@ -316,8 +321,12 @@ export function runAvailabilityChecks( } } - // Run shipping checks only if we have permission - if (!skipOptions?.skipShippingChecks) { + // Run shipping checks only if: + // 1. We have permission (skipShippingChecks is not set) + // 2. Product requires shipping (non-shippable products don't need shipping configuration) + const shouldRunShippingChecks = !skipOptions?.skipShippingChecks && product.isShippingRequired; + + if (shouldRunShippingChecks) { for (const check of shippingChecks) { const issue = check(context); diff --git a/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.test.ts b/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.test.ts index fe9d04264ef..578f084a147 100644 --- a/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.test.ts +++ b/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.test.ts @@ -5,6 +5,58 @@ import { mapProductToDiagnosticData } from "./mapProductToDiagnosticData"; // Type alias for cleaner test code type ProductInput = ProductDetailsQuery["product"]; +/** + * Minimal type representing the fields actually used by mapProductToDiagnosticData. + * This allows tests to provide only the relevant data without fighting complex generated types. + */ +interface TestProductInput { + id?: string; + name?: string; + productType?: { + isShippingRequired?: boolean; + } | null; + channelListings?: Array<{ + channel: { id: string; name: string; slug: string }; + isPublished: boolean; + publishedAt?: string | null; + isAvailableForPurchase?: boolean | null; + availableForPurchaseAt?: string | null; + visibleInListings: boolean; + }>; + variants?: Array<{ + id: string; + name: string; + channelListings?: Array<{ + channel: { id: string }; + price?: { amount: number } | null; + }>; + stocks?: Array<{ + warehouse: { id: string }; + quantity: number; + }>; + }>; +} + +/** + * Factory function to create test product input data. + * + * Note: We use TestProductInput (a simplified type) because: + * 1. ProductInput is a complex generated GraphQL type with many required fields + * 2. mapProductToDiagnosticData only accesses specific fields we provide + * 3. Tests intentionally provide partial data to verify edge case handling + */ +const createTestProduct = (overrides: TestProductInput = {}): ProductInput => + ({ + id: "test-product", + name: "Test Product", + productType: { + isShippingRequired: true, + }, + channelListings: [], + variants: [], + ...overrides, + }) as unknown as ProductInput; + describe("mapProductToDiagnosticData", () => { it("should return null when product is null", () => { // Act @@ -22,11 +74,14 @@ describe("mapProductToDiagnosticData", () => { expect(result).toBeNull(); }); - it("should map product data to diagnostic structure", () => { + it("should map product data to diagnostic structure with isShippingRequired", () => { // Arrange - use type assertion for partial mock const product = { id: "product-1", name: "Test Product", + productType: { + isShippingRequired: true, + }, channelListings: [ { channel: { @@ -68,6 +123,7 @@ describe("mapProductToDiagnosticData", () => { expect(result).toEqual({ id: "product-1", name: "Test Product", + isShippingRequired: true, channelListings: [ { channel: { @@ -103,14 +159,27 @@ describe("mapProductToDiagnosticData", () => { }); }); + it("should map non-shippable product (isShippingRequired: false)", () => { + // Arrange - digital product that doesn't require shipping + const product = createTestProduct({ + id: "product-digital", + name: "Digital Product", + productType: { isShippingRequired: false }, + }); + + // Act + const result = mapProductToDiagnosticData(product); + + // Assert + expect(result?.isShippingRequired).toBe(false); + }); + it("should handle empty channelListings and variants", () => { // Arrange - const product = { + const product = createTestProduct({ id: "product-2", name: "Empty Product", - channelListings: [], - variants: [], - } as unknown as ProductInput; + }); // Act const result = mapProductToDiagnosticData(product); @@ -119,14 +188,47 @@ describe("mapProductToDiagnosticData", () => { expect(result).toEqual({ id: "product-2", name: "Empty Product", + isShippingRequired: true, channelListings: [], variants: [], }); }); + it("should default isShippingRequired to true when productType is null", () => { + // Arrange - edge case: product without productType (requires manual cast) + const product = { + ...createTestProduct(), + productType: null, + } as unknown as ProductInput; + + // Act + const result = mapProductToDiagnosticData(product); + + // Assert - should default to true (safer for diagnostics - shows more warnings) + expect(result?.isShippingRequired).toBe(true); + }); + + it("should default isShippingRequired to true when isShippingRequired is undefined", () => { + // Arrange - edge case: productType exists but isShippingRequired is undefined (requires manual cast) + const product = { + ...createTestProduct(), + productType: { + id: "pt-1", + name: "Some Type", + // isShippingRequired intentionally omitted (undefined) + }, + } as unknown as ProductInput; + + // Act + const result = mapProductToDiagnosticData(product); + + // Assert - should default to true (safer for diagnostics - shows more warnings) + expect(result?.isShippingRequired).toBe(true); + }); + it("should convert undefined dates to null", () => { // Arrange - const product = { + const product = createTestProduct({ id: "product-3", name: "Product with undefined dates", channelListings: [ @@ -143,8 +245,7 @@ describe("mapProductToDiagnosticData", () => { visibleInListings: false, }, ], - variants: [], - } as unknown as ProductInput; + }); // Act const result = mapProductToDiagnosticData(product); diff --git a/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.ts b/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.ts index 06a2f7cbd4f..fa3663afd07 100644 --- a/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.ts +++ b/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.ts @@ -15,6 +15,8 @@ export function mapProductToDiagnosticData( return { id: product.id, name: product.name, + // Default to true (shippable) if not specified - safer for diagnostics + isShippingRequired: product.productType?.isShippingRequired ?? true, channelListings: product.channelListings?.map(listing => ({ channel: { diff --git a/src/products/components/ProductDoctor/utils/types.ts b/src/products/components/ProductDoctor/utils/types.ts index 3cd28b0bfc0..d0d81d320c9 100644 --- a/src/products/components/ProductDoctor/utils/types.ts +++ b/src/products/components/ProductDoctor/utils/types.ts @@ -37,6 +37,20 @@ export interface ChannelDiagnosticData { export interface ProductDiagnosticData { id: string; name: string; + /** + * Whether the product type requires shipping (from productType.isShippingRequired). + * + * This flag affects which diagnostic checks run: + * - When true: All checks run (shipping zones, warehouses, stock, etc.) + * - When false: Shipping zone checks are skipped (non-shippable products don't need shipping) + * + * Note: Warehouse/stock checks still run for non-shippable products because they may + * track inventory (e.g., activation codes, digital license keys). If a product doesn't + * track inventory, variant.stocks will be empty and warehouse checks will pass naturally. + * + * @default true - Defaults to true when missing to show more warnings (safer for diagnostics) + */ + isShippingRequired: boolean; channelListings: Array<{ channel: { id: string; diff --git a/src/products/fixtures.ts b/src/products/fixtures.ts index 3985bab68bc..9cada4a8ff2 100644 --- a/src/products/fixtures.ts +++ b/src/products/fixtures.ts @@ -335,6 +335,7 @@ export const product: ( productType: { __typename: "ProductType", hasVariants: true, + isShippingRequired: true, id: "pt76406", name: "Versatile", nonSelectionVariantAttributes: [ @@ -445,11 +446,6 @@ export const product: ( }, }, ], - taxClass: { - __typename: "TaxClass", - name: "standard", - id: "standard", - }, variantAttributes: [ { __typename: "Attribute", From 30b94ec875456904b1f73f6e82fb29b4721b596e Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Thu, 5 Feb 2026 17:20:44 +0100 Subject: [PATCH 2/3] Add changeset --- .changeset/long-lions-ring.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/long-lions-ring.md diff --git a/.changeset/long-lions-ring.md b/.changeset/long-lions-ring.md new file mode 100644 index 00000000000..e1e4546af64 --- /dev/null +++ b/.changeset/long-lions-ring.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Product availability diagnostics now skip shipping zone warnings for non-shippable products (digital goods, activation codes, etc.). Products with isShippingRequired: false on their product type will no longer see false positive warnings about missing shipping zones or unreachable warehouses via shipping. From ce86ee61c430750373a6a69ac9726852e442ad03 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Sun, 8 Feb 2026 17:40:22 +0100 Subject: [PATCH 3/3] Address code review --- .../ProductDoctor/utils/availabilityChecks.ts | 8 ++ .../utils/mapProductToDiagnosticData.test.ts | 105 +++--------------- .../utils/mapProductToDiagnosticData.ts | 52 ++++++++- .../components/ProductDoctor/utils/types.ts | 2 - 4 files changed, 70 insertions(+), 97 deletions(-) diff --git a/src/products/components/ProductDoctor/utils/availabilityChecks.ts b/src/products/components/ProductDoctor/utils/availabilityChecks.ts index 0ed3d0236c4..36effc39f1b 100644 --- a/src/products/components/ProductDoctor/utils/availabilityChecks.ts +++ b/src/products/components/ProductDoctor/utils/availabilityChecks.ts @@ -274,8 +274,16 @@ const warehouseChecks: CheckFunction[] = [checkNoWarehouses, checkNoStock]; */ const shippingChecks: CheckFunction[] = [checkNoShippingZones, checkWarehouseNotInShippingZone]; +/** + * Options to skip certain check categories. + * These are typically set based on user permissions - if the user cannot view + * warehouse or shipping zone data, the corresponding checks should be skipped + * to avoid false positives from missing data. + */ interface CheckSkipOptions { + /** Skip warehouse/stock checks (set when user lacks warehouse view permissions) */ skipWarehouseChecks?: boolean; + /** Skip shipping zone checks (set when user lacks shipping zone view permissions) */ skipShippingChecks?: boolean; } diff --git a/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.test.ts b/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.test.ts index 578f084a147..83515da89a6 100644 --- a/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.test.ts +++ b/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.test.ts @@ -1,61 +1,20 @@ -import { ProductDetailsQuery } from "@dashboard/graphql"; - -import { mapProductToDiagnosticData } from "./mapProductToDiagnosticData"; - -// Type alias for cleaner test code -type ProductInput = ProductDetailsQuery["product"]; - -/** - * Minimal type representing the fields actually used by mapProductToDiagnosticData. - * This allows tests to provide only the relevant data without fighting complex generated types. - */ -interface TestProductInput { - id?: string; - name?: string; - productType?: { - isShippingRequired?: boolean; - } | null; - channelListings?: Array<{ - channel: { id: string; name: string; slug: string }; - isPublished: boolean; - publishedAt?: string | null; - isAvailableForPurchase?: boolean | null; - availableForPurchaseAt?: string | null; - visibleInListings: boolean; - }>; - variants?: Array<{ - id: string; - name: string; - channelListings?: Array<{ - channel: { id: string }; - price?: { amount: number } | null; - }>; - stocks?: Array<{ - warehouse: { id: string }; - quantity: number; - }>; - }>; -} +import { mapProductToDiagnosticData, ProductDiagnosticInput } from "./mapProductToDiagnosticData"; /** * Factory function to create test product input data. - * - * Note: We use TestProductInput (a simplified type) because: - * 1. ProductInput is a complex generated GraphQL type with many required fields - * 2. mapProductToDiagnosticData only accesses specific fields we provide - * 3. Tests intentionally provide partial data to verify edge case handling */ -const createTestProduct = (overrides: TestProductInput = {}): ProductInput => - ({ - id: "test-product", - name: "Test Product", - productType: { - isShippingRequired: true, - }, - channelListings: [], - variants: [], - ...overrides, - }) as unknown as ProductInput; +const createTestProduct = ( + overrides: Partial = {}, +): ProductDiagnosticInput => ({ + id: "test-product", + name: "Test Product", + productType: { + isShippingRequired: true, + }, + channelListings: [], + variants: [], + ...overrides, +}); describe("mapProductToDiagnosticData", () => { it("should return null when product is null", () => { @@ -75,8 +34,8 @@ describe("mapProductToDiagnosticData", () => { }); it("should map product data to diagnostic structure with isShippingRequired", () => { - // Arrange - use type assertion for partial mock - const product = { + // Arrange + const product: ProductDiagnosticInput = { id: "product-1", name: "Test Product", productType: { @@ -114,7 +73,7 @@ describe("mapProductToDiagnosticData", () => { ], }, ], - } as ProductInput; + }; // Act const result = mapProductToDiagnosticData(product); @@ -194,38 +153,6 @@ describe("mapProductToDiagnosticData", () => { }); }); - it("should default isShippingRequired to true when productType is null", () => { - // Arrange - edge case: product without productType (requires manual cast) - const product = { - ...createTestProduct(), - productType: null, - } as unknown as ProductInput; - - // Act - const result = mapProductToDiagnosticData(product); - - // Assert - should default to true (safer for diagnostics - shows more warnings) - expect(result?.isShippingRequired).toBe(true); - }); - - it("should default isShippingRequired to true when isShippingRequired is undefined", () => { - // Arrange - edge case: productType exists but isShippingRequired is undefined (requires manual cast) - const product = { - ...createTestProduct(), - productType: { - id: "pt-1", - name: "Some Type", - // isShippingRequired intentionally omitted (undefined) - }, - } as unknown as ProductInput; - - // Act - const result = mapProductToDiagnosticData(product); - - // Assert - should default to true (safer for diagnostics - shows more warnings) - expect(result?.isShippingRequired).toBe(true); - }); - it("should convert undefined dates to null", () => { // Arrange const product = createTestProduct({ diff --git a/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.ts b/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.ts index fa3663afd07..bd20c6f5c9b 100644 --- a/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.ts +++ b/src/products/components/ProductDoctor/utils/mapProductToDiagnosticData.ts @@ -1,12 +1,53 @@ -import { ProductDetailsQuery } from "@dashboard/graphql"; - import { ProductDiagnosticData } from "./types"; /** - * Maps the product data from ProductDetailsQuery to the diagnostic data structure + * Minimal input type declaring only the fields used by mapProductToDiagnosticData. + * This makes the function's contract explicit and simplifies testing. + */ +export interface ProductDiagnosticInput { + id: string; + name: string; + productType: { + isShippingRequired: boolean; + }; + channelListings?: Array<{ + channel: { + id: string; + name: string; + slug: string; + }; + isPublished: boolean; + publishedAt?: string | null; + isAvailableForPurchase: boolean | null; + availableForPurchaseAt?: string | null; + visibleInListings: boolean; + }> | null; + variants?: Array<{ + id: string; + name: string; + channelListings?: Array<{ + channel: { + id: string; + }; + price: { + amount: number; + } | null; + }> | null; + stocks?: Array<{ + warehouse: { + id: string; + }; + quantity: number; + }> | null; + }> | null; +} + +/** + * Maps the product data to the diagnostic data structure. + * Accepts ProductDiagnosticInput which is a subset of ProductDetailsQuery["product"]. */ export function mapProductToDiagnosticData( - product: ProductDetailsQuery["product"] | null | undefined, + product: ProductDiagnosticInput | null | undefined, ): ProductDiagnosticData | null { if (!product) { return null; @@ -15,8 +56,7 @@ export function mapProductToDiagnosticData( return { id: product.id, name: product.name, - // Default to true (shippable) if not specified - safer for diagnostics - isShippingRequired: product.productType?.isShippingRequired ?? true, + isShippingRequired: product.productType.isShippingRequired, channelListings: product.channelListings?.map(listing => ({ channel: { diff --git a/src/products/components/ProductDoctor/utils/types.ts b/src/products/components/ProductDoctor/utils/types.ts index d0d81d320c9..815e1bdfb90 100644 --- a/src/products/components/ProductDoctor/utils/types.ts +++ b/src/products/components/ProductDoctor/utils/types.ts @@ -47,8 +47,6 @@ export interface ProductDiagnosticData { * Note: Warehouse/stock checks still run for non-shippable products because they may * track inventory (e.g., activation codes, digital license keys). If a product doesn't * track inventory, variant.stocks will be empty and warehouse checks will pass naturally. - * - * @default true - Defaults to true when missing to show more warnings (safer for diagnostics) */ isShippingRequired: boolean; channelListings: Array<{