Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
103 changes: 88 additions & 15 deletions packages/core/e2e/stock-location.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ import {
createProductDocument,
createProductVariantsDocument,
} from './graphql/shared-definitions';
import { localUpdatedOrderFragment, testOrderFragment } from './graphql/shop-definitions';
import {
getProductWithStockLevelDocument,
localUpdatedOrderFragment,
testOrderFragment,
} from './graphql/shop-definitions';

describe('Stock location', () => {
const defaultStockLocationId = 'T_1';
Expand Down Expand Up @@ -390,15 +394,12 @@ describe('Stock location', () => {
}
// Create a StockLocation in Channel A
adminClient.setChannelToken(CHANNEL_A_TOKEN);
const { createStockLocation } = await adminClient.query(
testCreateStockLocationDocument,
{
input: {
name: 'Channel-A Location',
description: 'Belongs to Channel A only',
},
const { createStockLocation } = await adminClient.query(testCreateStockLocationDocument, {
input: {
name: 'Channel-A Location',
description: 'Belongs to Channel A only',
},
);
});
targetLocationId = createStockLocation.id;
});

Expand All @@ -421,15 +422,87 @@ describe('Stock location', () => {

// Verify the original entity in Channel A is completely unchanged.
adminClient.setChannelToken(CHANNEL_A_TOKEN);
const { stockLocations } = await adminClient.query(
testGetStockLocationsListDocument,
);
const target = stockLocations.items.find(
(sl: any) => sl.id === targetLocationId,
);
const { stockLocations } = await adminClient.query(testGetStockLocationsListDocument);
const target = stockLocations.items.find((sl: any) => sl.id === targetLocationId);
expect(target?.name).toBe('Channel-A Location');
expect(target?.description).toBe('Belongs to Channel A only');
});
});

// Repro for #3324: the MultiChannelStockLocationStrategy caches each StockLocation's
// channel ids for 7 days. Assigning a StockLocation to a Channel must invalidate that
// cache entry, otherwise the new channel sees no saleable stock until the entry expires
// or the server restarts.
describe('channelId cache invalidation (#3324)', () => {
const CACHE_TEST_CHANNEL_TOKEN = 'stock-loc-cache-test';
let cacheTestChannelId: string;
let cacheTestLocationId: string;

beforeAll(async () => {
adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
const { createStockLocation } = await adminClient.query(testCreateStockLocationDocument, {
input: {
name: 'Cache test location',
},
});
cacheTestLocationId = createStockLocation.id;
await adminClient.query(testSetStockLevelInLocationDocument, {
input: {
id: 'T_1',
stockLevels: [
{
stockLocationId: cacheTestLocationId,
stockOnHand: 10,
},
],
},
});
const { createChannel } = await adminClient.query(createChannelDocument, {
input: {
code: 'cache-test-channel',
token: CACHE_TEST_CHANNEL_TOKEN,
defaultLanguageCode: LanguageCode.en,
currencyCode: CurrencyCode.GBP,
pricesIncludeTax: true,
defaultShippingZoneId: 'T_1',
defaultTaxZoneId: 'T_1',
},
});
channelGuard.assertSuccess(createChannel);
cacheTestChannelId = createChannel.id;
await adminClient.query(assignProductToChannelDocument, {
input: {
channelId: cacheTestChannelId,
productIds: ['T_1'],
},
});
});

afterAll(() => {
adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
});

it('saleable stock becomes visible once the StockLocation is assigned to the channel', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This proves the assignment direction only. I think the removal direction deserves an equivalent e2e test, since stale positive membership can keep a removed location contributing stock in the channel it was removed from, which is the higher risk case. The unit spec constructs a removed event, but it does not drive the real removeStockLocationsFromChannel path through the Shop projection.

A sibling case could warm IN_STOCK in the channel, remove the location, assert the relation is gone while the source stock stays positive, then poll the Shop query with a deadline until it returns OUT_OF_STOCK.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in ad3e83c. The test warms IN_STOCK in a fresh channel, removes the location, checks the location's own stockOnHand is untouched, then polls until OUT_OF_STOCK through the real removeStockLocationsFromChannel path.

One ordering detail that turned out to matter: the StockLevel row is created only after the channel assignment. Any admin response that resolves variant stock in between (assignProductsToChannel does, via the ProductVariant.stockOnHand field) runs the strategy and seeds the cache with the pre-assignment list, and on master that makes the warm-positive state unreachable. With the ordering right, I verified the test against master's dist: it fails exactly at the stale-positive point (poll stuck at IN_STOCK after removal) and passes on this branch.

Thanks for running an independent reproduction and for pushing on this direction. You were right that it is the higher-risk case.

// Populate the strategy's channelId cache for the location while it is
// not yet assigned to the new channel
shopClient.setChannelToken(CACHE_TEST_CHANNEL_TOKEN);
const before = await shopClient.query(getProductWithStockLevelDocument, { id: 'T_1' });
expect(before.product?.variants[0].stockLevel).toBe('OUT_OF_STOCK');

adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
await adminClient.query(testAssignStockLocationToChannelDocument, {
input: {
stockLocationIds: [cacheTestLocationId],
channelId: cacheTestChannelId,
},
});
// Wait a bit for the ChangeChannelEvent subscriber to invalidate the cache
await new Promise(resolve => setTimeout(resolve, 100));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const after = await shopClient.query(getProductWithStockLevelDocument, { id: 'T_1' });
expect(after.product?.variants[0].stockLevel).toBe('IN_STOCK');
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { Subject } from 'rxjs';
import { filter } from 'rxjs/operators';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';

import { RequestContext } from '../../api/common/request-context';
import { Cache } from '../../cache/cache';
import { Channel } from '../../entity/channel/channel.entity';
import { Product } from '../../entity/product/product.entity';
import { StockLevel } from '../../entity/stock-level/stock-level.entity';
import { StockLocation } from '../../entity/stock-location/stock-location.entity';
import { ChangeChannelEvent } from '../../event-bus/events/change-channel-event';
import { StockLocationEvent } from '../../event-bus/events/stock-location-event';
import { ensureConfigLoaded } from '../config-helpers';

/**
* Unit tests for the MultiChannelStockLocationStrategy channelId cache invalidation.
*
* The real `Cache` wrapper is used (backed by an in-memory Map) so that the
* key-prefixing behaviour of `Cache.get()`/`Cache.delete()` matches production —
* the invalidation bugs under test live exactly in that interaction.
*/

const cacheStore = new Map<string, any>();
const mockCacheService = {
createCache: (config: any) =>
new Cache(config, {
get: (key: string) => Promise.resolve(cacheStore.get(key)),
set: (key: string, value: any) => {
cacheStore.set(key, value);
return Promise.resolve();
},
delete: (key: string) => {
cacheStore.delete(key);
return Promise.resolve();
},
} as any),
} as any;

const eventStream = new Subject<any>();
const mockEventBus = {
ofType: (type: any) => eventStream.asObservable().pipe(filter((e: any) => e.constructor === type)),
} as any;

let channelsInDb: Channel[];
const getEntityOrThrow = vi.fn(() => Promise.resolve(new StockLocation({ id: 1, channels: channelsInDb })));

const mockInjector = {
get: (token: unknown) => {
const name = (token as { name?: string })?.name;
if (name === 'EventBus') return mockEventBus;
if (name === 'CacheService') return mockCacheService;
if (name === 'TransactionalConnection') return { getEntityOrThrow };
return {};
},
} as any;

const channel1 = new Channel({ id: 1 });
const channel2 = new Channel({ id: 2 });

function contextForChannel(channel: Channel): RequestContext {
return new RequestContext({
apiType: 'shop',
channel,
authorizedAsOwnerOnly: false,
isAuthorized: true,
session: {} as any,
} as any);
}

const ctxChannel1 = contextForChannel(channel1);
const ctxChannel2 = contextForChannel(channel2);

const stockLevel = new StockLevel({
stockLocationId: 1,
stockOnHand: 100,
stockAllocated: 0,
productVariantId: 1,
});

const stockLocation = new StockLocation({ id: 1 });

/** Lets the async `subscribe` handlers (cache deletes) settle. */
function flushEventHandlers() {
return new Promise(resolve => setTimeout(resolve, 0));
}

describe('MultiChannelStockLocationStrategy', () => {
let strategy: import('./multi-channel-stock-location-strategy').MultiChannelStockLocationStrategy;

beforeAll(async () => {
await ensureConfigLoaded();
});

beforeEach(async () => {
cacheStore.clear();
channelsInDb = [channel1];
getEntityOrThrow.mockClear();
// Dynamic import to avoid vitest circular dependency issue
const { MultiChannelStockLocationStrategy } =
await import('./multi-channel-stock-location-strategy.js');
strategy = new MultiChannelStockLocationStrategy();
await strategy.init(mockInjector);
});

it('caches the channel ids of a StockLocation after the first lookup', async () => {
const first = await strategy.getAvailableStock(ctxChannel1, 1, [stockLevel]);
const second = await strategy.getAvailableStock(ctxChannel1, 1, [stockLevel]);

expect(first.stockOnHand).toBe(100);
expect(second.stockOnHand).toBe(100);
expect(getEntityOrThrow).toHaveBeenCalledTimes(1);
});

it('invalidates the cache when a StockLocation is updated', async () => {
// Cache the channel ids while the location belongs to channel1 only
const stale = await strategy.getAvailableStock(ctxChannel2, 1, [stockLevel]);
expect(stale.stockOnHand).toBe(0);

channelsInDb = [channel1, channel2];
eventStream.next(new StockLocationEvent(ctxChannel1, stockLocation, 'updated'));
await flushEventHandlers();

const fresh = await strategy.getAvailableStock(ctxChannel2, 1, [stockLevel]);
expect(fresh.stockOnHand).toBe(100);
});

it('does not invalidate the cache when a StockLocation is created', async () => {
await strategy.getAvailableStock(ctxChannel1, 1, [stockLevel]);

eventStream.next(new StockLocationEvent(ctxChannel1, stockLocation, 'created'));
await flushEventHandlers();

await strategy.getAvailableStock(ctxChannel1, 1, [stockLevel]);
expect(getEntityOrThrow).toHaveBeenCalledTimes(1);
});

it('invalidates the cache when a StockLocation is assigned to a Channel', async () => {
// Cache the channel ids before the location is assigned to channel2
const stale = await strategy.getAvailableStock(ctxChannel2, 1, [stockLevel]);
expect(stale.stockOnHand).toBe(0);

channelsInDb = [channel1, channel2];
eventStream.next(new ChangeChannelEvent(ctxChannel1, stockLocation, [2], 'assigned', StockLocation));
await flushEventHandlers();

const fresh = await strategy.getAvailableStock(ctxChannel2, 1, [stockLevel]);
expect(fresh.stockOnHand).toBe(100);
});

it('invalidates the cache when a StockLocation is removed from a Channel', async () => {
channelsInDb = [channel1, channel2];
const beforeRemoval = await strategy.getAvailableStock(ctxChannel2, 1, [stockLevel]);
expect(beforeRemoval.stockOnHand).toBe(100);

channelsInDb = [channel1];
eventStream.next(new ChangeChannelEvent(ctxChannel1, stockLocation, [2], 'removed', StockLocation));
await flushEventHandlers();

const fresh = await strategy.getAvailableStock(ctxChannel2, 1, [stockLevel]);
expect(fresh.stockOnHand).toBe(0);
});

it('does not invalidate the cache on ChangeChannelEvents for other entity types', async () => {
await strategy.getAvailableStock(ctxChannel1, 1, [stockLevel]);

eventStream.next(
new ChangeChannelEvent(ctxChannel1, new Product({ id: 1 }) as any, [2], 'assigned', Product),
);
await flushEventHandlers();

await strategy.getAvailableStock(ctxChannel1, 1, [stockLevel]);
expect(getEntityOrThrow).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { GlobalSettingsService } from '../../service/index';
import { GlobalFlag } from '@vendure/common/lib/generated-types';
import { ID } from '@vendure/common/lib/shared-types';
import ms from 'ms';
import { filter } from 'rxjs/operators';
import type { GlobalSettingsService } from '../../service/index';

import { RequestContext } from '../../api/common/request-context';
import { Cache, CacheService, RequestContextCacheService } from '../../cache/index';
Expand All @@ -11,7 +11,7 @@ import { ProductVariant } from '../../entity/index';
import { OrderLine } from '../../entity/order-line/order-line.entity';
import { StockLevel } from '../../entity/stock-level/stock-level.entity';
import { StockLocation } from '../../entity/stock-location/stock-location.entity';
import { EventBus, StockLocationEvent } from '../../event-bus/index';
import { ChangeChannelEvent, EventBus, StockLocationEvent } from '../../event-bus/index';

import { BaseStockLocationStrategy } from './default-stock-location-strategy';
import { AvailableStock, LocationWithQuantity, StockLocationStrategy } from './stock-location-strategy';
Expand Down Expand Up @@ -59,11 +59,21 @@ export class MultiChannelStockLocationStrategy extends BaseStockLocationStrategy
getKey: id => this.getCacheKey(id),
});

// When a StockLocation is updated, we need to invalidate the cache
// When a StockLocation is updated, we need to invalidate the cache.
// Note: `Cache.delete()` applies the configured `getKey` function itself,
// so it must be passed the raw id, not the result of `getCacheKey()`.
this.eventBus
.ofType(StockLocationEvent)
.pipe(filter(event => event.type !== 'created'))
.subscribe(({ entity }) => this.channelIdCache.delete(this.getCacheKey(entity.id)));
.subscribe(({ entity }) => this.channelIdCache.delete(entity.id));

// Assigning a StockLocation to a Channel (or removing it) does not emit a
// StockLocationEvent, so we also need to invalidate the cache on ChangeChannelEvents
// which relate to StockLocations.
this.eventBus
.ofType(ChangeChannelEvent)
.pipe(filter(event => event.entityType === StockLocation))
.subscribe(({ entity }) => this.channelIdCache.delete(entity.id));
}

/**
Expand Down
Loading