Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/long-lions-ring.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/fragments/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ export const productFragmentDetails = gql`
id
name
hasVariants
isShippingRequired
}
weight {
...Weight
Expand Down
1 change: 1 addition & 0 deletions src/graphql/hooks.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3014,6 +3014,7 @@ export const ProductFragmentDoc = gql`
id
name
hasVariants
isShippingRequired
}
weight {
...Weight
Expand Down
4 changes: 2 additions & 2 deletions src/graphql/types.generated.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const wrapper = ({ children }: { children: React.ReactNode }) => (
const createMockProduct = (overrides?: Partial<ProductDiagnosticData>): ProductDiagnosticData => ({
id: "product-123",
name: "Test Product",
isShippingRequired: true,
channelListings: [
{
channel: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
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();
Comment on lines +6 to +37
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: if we need to have checks in tests for proper formatting of placeholders, maybe we should just use the actual react-intl with a mocked provider?

i'm not sure if we need this though, since we are not making any checks for messages in the actual tests below if I understand correctly. In that case we can make this much simpler:

Suggested change
// Mock intl for testing - validates that message placeholders are provided
const createMockIntl = (): IntlShape =>
({
formatMessage: (descriptor: MessageDescriptor, values?: Record<string, unknown>) => {
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();
const mockIntl = {
formatMessage: (descriptor: MessageDescriptor) => return descriptor?.defaultMessage ?? "";
} as unknown as IntlShape;


// Helper to create a minimal product diagnostic data
const createProduct = (overrides: Partial<ProductDiagnosticData> = {}): 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> = {},
): 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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -272,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;
}

Expand Down Expand Up @@ -305,7 +315,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);
Expand All @@ -316,8 +329,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);

Expand Down
Loading
Loading