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
279 changes: 279 additions & 0 deletions packages/core/e2e/product-channel.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ import { initialData } from '../../../e2e-common/e2e-initial-data';
import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';

import { channelFragment, productVariantFragment } from './graphql/fragments-admin';
import { graphql } from './graphql/graphql-admin';
import {
addOptionGroupToProductDocument,
assignProductToChannelDocument,
assignProductVariantToChannelDocument,
createAdministratorDocument,
createAssetsDocument,
createChannelDocument,
createProductDocument,
createProductOptionGroupDocument,
createProductVariantsDocument,
createRoleDocument,
getChannelsDocument,
Expand Down Expand Up @@ -556,6 +560,270 @@ describe('ChannelAware Products and ProductVariants', () => {
});
});

// https://github.com/vendure-ecommerce/vendure/issues/4532
describe('creating a new variant for a product already assigned to another channel', () => {
let testProduct: ResultOf<typeof createProductDocument>['createProduct'];
let colorGroupId: string;
let redOptionId: string;
let assetId: string;

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

// Create an asset in the default channel to attach to the new variant
const { createAssets } = await adminClient.fileUploadMutation({
mutation: createAssetsDocument,
filePaths: [path.join(__dirname, 'fixtures/assets/pps2.jpg')],
mapVariables: filePaths => ({
input: filePaths.map(() => ({ file: null })),
}),
});
assetId = createAssets[0].id;

// Create a product in the default channel
const { createProduct } = await adminClient.query(createProductDocument, {
input: {
translations: [
{
languageCode: LanguageCode.en,
name: 'Channel Variant Test Product',
slug: 'channel-variant-test-product',
description: 'Testing variant channel inheritance',
},
],
},
});
testProduct = createProduct;

// Create an option group with one option
const { createProductOptionGroup } = await adminClient.query(
createProductOptionGroupDocument,
{
input: {
code: 'test-color',
translations: [{ languageCode: LanguageCode.en, name: 'Color' }],
options: [
{
code: 'red',
translations: [{ languageCode: LanguageCode.en, name: 'Red' }],
},
],
},
},
);
colorGroupId = createProductOptionGroup.id;
redOptionId = createProductOptionGroup.options[0].id;

// Attach option group to product
await adminClient.query(addOptionGroupToProductDocument, {
productId: testProduct.id,
optionGroupId: colorGroupId,
});

// Create first variant with the red option
const { createProductVariants } = await adminClient.query(createProductVariantsDocument, {
input: [
{
productId: testProduct.id,
sku: 'CHAN-VAR-RED',
price: 1000,
optionIds: [redOptionId],
translations: [{ languageCode: LanguageCode.en, name: 'Red Variant' }],
},
],
});
productVariantGuard.assertSuccess(createProductVariants[0]);

// Assign the product to the third channel (pricesIncludeTax: true, EUR)
await adminClient.query(assignProductToChannelDocument, {
input: {
channelId: 'T_3',
productIds: [testProduct.id],
priceFactor: 1,
},
});
});

it('new variant is automatically assigned to the same channels as the product', async () => {
adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);

// Create a new option after the product was assigned to the channel
const { createProductOption } = await adminClient.query(createProductOptionDocument, {
input: {
productOptionGroupId: colorGroupId,
code: 'blue',
translations: [{ languageCode: LanguageCode.en, name: 'Blue' }],
},
});

// Create a new variant with the new option
const { createProductVariants } = await adminClient.query(createProductVariantsDocument, {
input: [
{
productId: testProduct.id,
sku: 'CHAN-VAR-BLUE',
price: 2000,
optionIds: [createProductOption.id],
assetIds: [assetId],
featuredAssetId: assetId,
translations: [{ languageCode: LanguageCode.en, name: 'Blue Variant' }],
},
],
});
const newVariant = createProductVariants[0];
productVariantGuard.assertSuccess(newVariant);

// From the default channel, the new variant should be in both channels
expect(newVariant.channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']);
});

it('new variant is visible in the assigned channel', async () => {
adminClient.setChannelToken(THIRD_CHANNEL_TOKEN);

const { product } = await adminClient.query(getProductWithVariantsDocument, {
id: testProduct.id,
});
productGuard.assertSuccess(product);

// Both variants should be visible in the third channel
expect(product.variants).toHaveLength(2);
expect(product.variants.map(v => v.sku).sort()).toEqual([
'CHAN-VAR-BLUE',
'CHAN-VAR-RED',
]);
});

it('new variant has correct price in the tax-inclusive assigned channel', async () => {
adminClient.setChannelToken(THIRD_CHANNEL_TOKEN);

const { product } = await adminClient.query(getProductWithVariantsDocument, {
id: testProduct.id,
});
productGuard.assertSuccess(product);

const blueVariant = product.variants.find(v => v.sku === 'CHAN-VAR-BLUE');
expect(blueVariant).toBeDefined();
// Third channel has pricesIncludeTax: true and uses EUR.
// assignProductVariantsToChannel writes the variant's priceWithTax (2000)
// as the channel price. With 20% tax applied, priceWithTax = 2000 * 1.2 = 2400.
expect(blueVariant!.priceWithTax).toBe(2400);
expect(blueVariant!.currencyCode).toBe(CurrencyCode.EUR);
});

it('new variant has stock initialized in the assigned channel', async () => {
adminClient.setChannelToken(THIRD_CHANNEL_TOKEN);

const { product } = await adminClient.query(getProductWithVariantsDocument, {
id: testProduct.id,
});
productGuard.assertSuccess(product);

const blueVariant = product.variants.find(v => v.sku === 'CHAN-VAR-BLUE');
expect(blueVariant).toBeDefined();
// Stock should be initialized (default 0) via ensureStockLevelsForChannel
expect(blueVariant!.stockOnHand).toBe(0);
});

it('new variant assets are assigned to the assigned channel', async () => {
adminClient.setChannelToken(THIRD_CHANNEL_TOKEN);

const { product } = await adminClient.query(getProductWithVariantsDocument, {
id: testProduct.id,
});
productGuard.assertSuccess(product);

const blueVariant = product.variants.find(v => v.sku === 'CHAN-VAR-BLUE');
expect(blueVariant).toBeDefined();
// The asset attached at creation time should be assigned to the third channel
// (via assetService.assignToChannel inside assignProductVariantsToChannel)
// and therefore visible/resolvable when querying from that channel.
expect(blueVariant!.assets).toHaveLength(1);
expect(blueVariant!.assets[0].id).toBe(assetId);
expect(blueVariant!.featuredAsset).toBeDefined();
expect(blueVariant!.featuredAsset!.id).toBe(assetId);
});

it('new variant options and option groups are accessible in the assigned channel', async () => {
adminClient.setChannelToken(THIRD_CHANNEL_TOKEN);

const { product } = await adminClient.query(getProductWithVariantsDocument, {
id: testProduct.id,
});
productGuard.assertSuccess(product);

// The option group should be visible in the third channel
expect(product.optionGroups).toHaveLength(1);
expect(product.optionGroups[0].code).toBe('test-color');

// The new variant's options should have valid group references
const blueVariant = product.variants.find(v => v.sku === 'CHAN-VAR-BLUE');
expect(blueVariant).toBeDefined();
expect(blueVariant!.options).toHaveLength(1);
expect(blueVariant!.options[0].code).toBe('blue');
expect(blueVariant!.options[0].groupId).toBe(product.optionGroups[0].id);
});

it(
'respects permissions: throws if user lacks UpdateCatalog on a target channel',
assertThrowsWithMessage(async () => {
await adminClient.asSuperAdmin();
adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);

// Create a new option so we have a valid optionId for the variant
const { createProductOption } = await adminClient.query(createProductOptionDocument, {
input: {
productOptionGroupId: colorGroupId,
code: 'green',
translations: [{ languageCode: LanguageCode.en, name: 'Green' }],
},
});

// Create a role with UpdateCatalog only on the default channel
const { createRole } = await adminClient.query(createRoleDocument, {
input: {
description: 'default-channel-catalog-admin',
code: 'default-channel-catalog-admin',
channelIds: ['T_1'],
permissions: [Permission.UpdateCatalog, Permission.ReadCatalog],
},
});
await adminClient.query(createAdministratorDocument, {
input: {
firstName: 'Limited',
lastName: 'Admin',
emailAddress: 'limited-admin@test.com',
password: 'test',
roleIds: [createRole.id],
},
});

// This user can create variants on the default channel,
// but lacks UpdateCatalog on T_3 where the product is also assigned.
// assignProductVariantsToChannel should throw ForbiddenError.
await adminClient.asUserWithCredentials('limited-admin@test.com', 'test');
adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
await adminClient.query(createProductVariantsDocument, {
input: [
{
productId: testProduct.id,
sku: 'CHAN-VAR-GREEN',
price: 3000,
optionIds: [createProductOption.id],
translations: [{ languageCode: LanguageCode.en, name: 'Green Variant' }],
},
],
});
}, 'You are not currently authorized to perform this action'),
);

afterAll(async () => {
// Reset to super admin for subsequent test suites
await adminClient.asSuperAdmin();
adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
});
});

describe('updating Product in sub-channel', () => {
it(
'throws if attempting to update a Product which is not assigned to that Channel',
Expand Down Expand Up @@ -727,3 +995,14 @@ describe('ChannelAware Products and ProductVariants', () => {
});
});
});

const createProductOptionDocument = graphql(`
mutation CreateProductOption($input: CreateProductOptionInput!) {
createProductOption(input: $input) {
id
code
name
groupId
}
}
`);
66 changes: 66 additions & 0 deletions packages/core/src/service/services/product-variant.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,72 @@ export class ProductVariantService {
// variant created directly within a non-default channel the channel-filtered `stockLevels`
// field resolves to a real entry rather than an empty array until stock is first adjusted.
await this.ensureStockLevelsForChannel(ctx, [createdVariant.id], ctx.channelId);

// Assign the new variant to any other channels the parent product is already assigned to,
// so that the variant is visible in all channels the product belongs to.
// We reuse assignProductVariantsToChannel which handles permissions, pricing
// (pricesIncludeTax + defaultCurrencyCode), stock-level seeding, asset assignment,
// and ProductVariantChannelEvent — matching the flow in
// ProductService.assignProductsToChannel().
const product = await this.connection.getRepository(ctx, Product).findOne({
where: { id: input.productId },
relations: ['channels'],
relationLoadStrategy: 'query',
loadEagerRelations: false,
});
if (product) {
const additionalChannelIds = product.channels
.map(c => c.id)
.filter(id => !idsAreEqual(id, ctx.channelId) && !idsAreEqual(id, defaultChannel.id));

if (additionalChannelIds.length) {
// Load the variant's options with their groups so we can assign them
// to the additional channels, matching ProductService.assignProductsToChannel()
const optionIds = input.optionIds || [];
let optionGroupIds: ID[] = [];
if (optionIds.length) {
const variantOptions = await this.connection
.getRepository(ctx, ProductOption)
.find({
where: { id: In(optionIds) },
relations: ['group'],
loadEagerRelations: false,
});
optionGroupIds = unique(variantOptions.map(o => o.group.id));
}

for (const additionalChannelId of additionalChannelIds) {
await this.assignProductVariantsToChannel(ctx, {
productVariantIds: [createdVariant.id],
channelId: additionalChannelId,
});

// Also assign option groups and options to the target channel,
// matching ProductService.assignProductsToChannel()
if (optionIds.length) {
await Promise.all([
...optionGroupIds.map(id =>
this.channelService.assignToChannels(
ctx,
ProductOptionGroup,
id,
[additionalChannelId],
),
),
...optionIds.map(id =>
this.channelService.assignToChannels(
ctx,
ProductOption,
id,
[additionalChannelId],
),
),
]);
}
}
}
}

return createdVariant.id;
}

Expand Down
Loading