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
136 changes: 136 additions & 0 deletions packages/core/e2e/default-search-plugin.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
DeleteProductMutationVariables,
DeleteProductVariantMutation,
DeleteProductVariantMutationVariables,
GlobalFlag,
LanguageCode,
ReindexMutation,
RemoveProductsFromChannelMutation,
Expand Down Expand Up @@ -1941,6 +1942,141 @@ describe('Default search plugin', () => {
});
});
});

// https://github.com/vendurehq/community-plugins/issues/1
describe('multi-channel productInStock cache', () => {
const STOCK_CHANNEL_TOKEN = 'stock-test-channel-token';
let stockTestChannelId: string;
let testProductId: string;

beforeAll(async () => {
adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
await adminClient.asSuperAdmin();

// Create a second channel for testing stock isolation
const { createChannel } = await adminClient.query<
CreateChannelMutation,
CreateChannelMutationVariables
>(CREATE_CHANNEL, {
input: {
code: 'stock-test-channel',
token: STOCK_CHANNEL_TOKEN,
defaultLanguageCode: LanguageCode.en,
currencyCode: CurrencyCode.GBP,
pricesIncludeTax: true,
defaultTaxZoneId: 'T_2',
defaultShippingZoneId: 'T_1',
},
});
stockTestChannelId = (createChannel as ChannelFragment).id;

// Create a product with a variant that has stock in the default channel
const { createProduct } = await adminClient.query<
CreateProductMutation,
CreateProductMutationVariables
>(CREATE_PRODUCT, {
input: {
translations: [
{
languageCode: LanguageCode.en,
name: 'Stock Test Product',
slug: 'stock-test-product',
description: 'A product for testing multi-channel stock',
},
],
},
});
testProductId = createProduct.id;

await adminClient.query<CreateProductVariantsMutation, CreateProductVariantsMutationVariables>(
CREATE_PRODUCT_VARIANTS,
{
input: [
{
productId: testProductId,
sku: 'STOCK-TEST-1',
price: 1000,
stockOnHand: 100,
trackInventory: GlobalFlag.TRUE,
translations: [{ languageCode: LanguageCode.en, name: 'Stock Test Variant' }],
},
],
},
);
await awaitRunningJobs(adminClient);

// Assign the product to the second channel (no stock location there)
await adminClient.query<
AssignProductsToChannelMutation,
AssignProductsToChannelMutationVariables
>(ASSIGN_PRODUCT_TO_CHANNEL, {
input: { channelId: stockTestChannelId, productIds: [testProductId] },
});
await awaitRunningJobs(adminClient);

// Reindex default channel first
adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
await adminClient.query<ReindexMutation>(REINDEX);
await awaitRunningJobs(adminClient);

// Reindex the second channel
adminClient.setChannelToken(STOCK_CHANNEL_TOKEN);
await adminClient.query<ReindexMutation>(REINDEX);
await awaitRunningJobs(adminClient);

// Reset admin token after reindexing second channel
adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
});

it('product is inStock in default channel', async () => {
shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
const result = await shopClient.query<
SearchProductsShopQuery,
SearchProductsShopQueryVariablesExt
>(SEARCH_PRODUCTS_SHOP, {
input: {
term: 'Stock Test Product',
groupByProduct: true,
inStock: true,
},
});
expect(result.search.items.map(i => i.productName)).toContain('Stock Test Product');
});

it('product is NOT inStock in second channel (no stock location)', async () => {
shopClient.setChannelToken(STOCK_CHANNEL_TOKEN);
const result = await shopClient.query<
SearchProductsShopQuery,
SearchProductsShopQueryVariablesExt
>(SEARCH_PRODUCTS_SHOP, {
input: {
term: 'Stock Test Product',
groupByProduct: true,
inStock: true,
},
});
expect(result.search.items.map(i => i.productName)).not.toContain('Stock Test Product');
});

it('product appears when filtering inStock: false in second channel', async () => {
shopClient.setChannelToken(STOCK_CHANNEL_TOKEN);
const result = await shopClient.query<
SearchProductsShopQuery,
SearchProductsShopQueryVariablesExt
>(SEARCH_PRODUCTS_SHOP, {
input: {
term: 'Stock Test Product',
groupByProduct: true,
inStock: false,
},
});
expect(result.search.items.map(i => i.productName)).toContain('Stock Test Product');
});

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

export const REINDEX = gql`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ export class IndexerController {
0 < (await this.productVariantService.getSaleableStockLevel(ctx, variant));
const productInStock = await this.requestContextCache.get(
ctx,
`productVariantsStock-${variant.productId}`,
`productVariantsStock-${ctx.channelId}-${variant.productId}`,
() =>
this.connection
.getRepository(ctx, ProductVariant)
Expand Down
137 changes: 136 additions & 1 deletion packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion, no-console */
import { CurrencyCode, SortOrder } from '@vendure/common/lib/generated-types';
import { CurrencyCode, GlobalFlag, SortOrder } from '@vendure/common/lib/generated-types';
import { pick } from '@vendure/common/lib/pick';
import {
DefaultJobQueuePlugin,
Expand Down Expand Up @@ -1560,6 +1560,141 @@ describe('Elasticsearch plugin', () => {
});
});
});

// https://github.com/vendure-ecommerce/vendure/issues/3972
describe('multi-channel productInStock cache', () => {
const STOCK_CHANNEL_TOKEN = 'stock-test-channel-token';
let stockTestChannelId: string;
let testProductId: string;

beforeAll(async () => {
adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
await adminClient.asSuperAdmin();

// Create a second channel for testing stock isolation
const { createChannel } = await adminClient.query<
Codegen.CreateChannelMutation,
Codegen.CreateChannelMutationVariables
>(CREATE_CHANNEL, {
input: {
code: 'stock-test-channel',
token: STOCK_CHANNEL_TOKEN,
defaultLanguageCode: LanguageCode.en,
currencyCode: CurrencyCode.GBP,
pricesIncludeTax: true,
defaultTaxZoneId: 'T_2',
defaultShippingZoneId: 'T_1',
},
});
stockTestChannelId = (createChannel as Codegen.ChannelFragment).id;

// Create a product with a variant that has stock in the default channel
const { createProduct } = await adminClient.query<
Codegen.CreateProductMutation,
Codegen.CreateProductMutationVariables
>(CREATE_PRODUCT, {
input: {
translations: [
{
languageCode: LanguageCode.en,
name: 'Stock Test Product',
slug: 'stock-test-product',
description: 'A product for testing multi-channel stock',
},
],
},
});
testProductId = createProduct.id;

await adminClient.query<
Codegen.CreateProductVariantsMutation,
Codegen.CreateProductVariantsMutationVariables
>(CREATE_PRODUCT_VARIANTS, {
input: [
{
productId: testProductId,
sku: 'STOCK-TEST-1',
price: 1000,
stockOnHand: 100,
trackInventory: GlobalFlag.TRUE,
translations: [{ languageCode: LanguageCode.en, name: 'Stock Test Variant' }],
},
],
});
await awaitRunningJobs(adminClient);

// Assign the product to the second channel (no stock location there)
await adminClient.query<
Codegen.AssignProductsToChannelMutation,
Codegen.AssignProductsToChannelMutationVariables
>(ASSIGN_PRODUCT_TO_CHANNEL, {
input: { channelId: stockTestChannelId, productIds: [testProductId] },
});
await awaitRunningJobs(adminClient);

// Reindex default channel first
adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
await adminClient.query<Codegen.ReindexMutation>(REINDEX);
await awaitRunningJobs(adminClient);

// Reindex the second channel
adminClient.setChannelToken(STOCK_CHANNEL_TOKEN);
await adminClient.query<Codegen.ReindexMutation>(REINDEX);
await awaitRunningJobs(adminClient);

// Reset admin token after reindexing second channel
adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
});

it('product is inStock in default channel', async () => {
shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
const result = await shopClient.query<SearchProductsShopQuery, SearchProductShopVariables>(
SEARCH_PRODUCTS_SHOP,
{
input: {
term: 'Stock Test Product',
groupByProduct: true,
inStock: true,
},
},
);
expect(result.search.items.map(i => i.productName)).toContain('Stock Test Product');
});

it('product is NOT inStock in second channel (no stock location)', async () => {
shopClient.setChannelToken(STOCK_CHANNEL_TOKEN);
const result = await shopClient.query<SearchProductsShopQuery, SearchProductShopVariables>(
SEARCH_PRODUCTS_SHOP,
{
input: {
term: 'Stock Test Product',
groupByProduct: true,
inStock: true,
},
},
);
expect(result.search.items.map(i => i.productName)).not.toContain('Stock Test Product');
});

it('product appears when filtering inStock: false in second channel', async () => {
shopClient.setChannelToken(STOCK_CHANNEL_TOKEN);
const result = await shopClient.query<SearchProductsShopQuery, SearchProductShopVariables>(
SEARCH_PRODUCTS_SHOP,
{
input: {
term: 'Stock Test Product',
groupByProduct: true,
inStock: false,
},
},
);
expect(result.search.items.map(i => i.productName)).toContain('Stock Test Product');
});

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

export const SEARCH_PRODUCTS = gql`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
import { Observable } from 'rxjs';
import { In, IsNull } from 'typeorm';

import { ELASTIC_SEARCH_OPTIONS, VARIANT_INDEX_NAME, loggerCtx } from '../constants';
import { ELASTIC_SEARCH_OPTIONS, loggerCtx, VARIANT_INDEX_NAME } from '../constants';
import { ElasticsearchOptions } from '../options';
import {
BulkOperation,
Expand Down Expand Up @@ -963,7 +963,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
private async getProductInStockValue(ctx: RequestContext, variants: ProductVariant[]): Promise<boolean> {
return this.requestContextCache.get(
ctx,
`elastic-index-product-in-stock-${variants.map(v => v.id).join(',')}`,
`elastic-index-product-in-stock-${ctx.channelId}-${variants.map(v => v.id).join(',')}`,
async () => {
const stockLevels = await Promise.all(
variants.map(variant => this.productVariantService.getSaleableStockLevel(ctx, variant)),
Expand Down