Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';

import { Asset } from '../../../entity/asset/asset.entity';
import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
import { ProductPriceApplicator } from '../product-price-applicator/product-price-applicator';

import { EntityHydrator } from './entity-hydrator.service';

describe('EntityHydrator', () => {
Expand Down Expand Up @@ -216,9 +220,13 @@ describe('EntityHydrator', () => {
});

describe('getRelationEntityAtPath()', () => {
function getRelationEntityAtPath(target: any, path: string[]): any {
const hydrator = new EntityHydrator(undefined as any, undefined as any, undefined as any);
return (hydrator as any).getRelationEntityAtPath(target, path);
}

// https://github.com/vendurehq/vendure/issues/4661
it('treats undefined intermediate relations as terminal values', () => {
const hydrator = new EntityHydrator(undefined as any, undefined as any, undefined as any);
const translation = { languageCode: 'en', name: 'Laptop' };
const order = {
lines: [
Expand All @@ -233,13 +241,190 @@ describe('EntityHydrator', () => {
],
};

const result = (hydrator as any).getRelationEntityAtPath(order, [
'lines',
'productVariant',
'translations',
]);
const result = getRelationEntityAtPath(order, ['lines', 'productVariant', 'translations']);

expect(result).toEqual([translation, undefined]);
});

it('does not crash when a terminal relation array is very large', () => {
// A relation at the end of the path is collected element by element. `push(...target)`
// expands the array into call arguments, which exceeds V8's stack budget and throws a
// RangeError — the same failure fixed in getMissingRelations() in #4986. Reproduces
// e.g. `collection.productVariants` on a big catalog.
//
// The limit is a stack budget rather than a fixed count, so it moves with the Node
// version and the vitest pool (measured on Node 22: ~110k in a process, ~495k in a
// worker thread). The fixture is sized well above both. The hole is deliberate:
// `target.forEach(...)` would also avoid the RangeError but silently skips holes, and
// the walk must preserve them.
const variant = { id: 1 };
const productVariants = new Array(1_000_000).fill(variant);
delete productVariants[5];

const result = getRelationEntityAtPath({ productVariants }, ['productVariants']);

expect(result).toHaveLength(1_000_000);
expect(result[5]).toBeUndefined();
});
});

describe('getProductVariantsToPrice()', () => {
function getProductVariantsToPrice(entity: any): ProductVariant[] {
const hydrator = new EntityHydrator(undefined as any, undefined as any, undefined as any);
return (hydrator as any).getProductVariantsToPrice(entity);
}

// These pin the semantics of the helper rather than guard a regression: they are also
// satisfied by the pre-helper code, which skipped all three shapes by other means.
it('returns an empty array for an empty array', () => {
expect(getProductVariantsToPrice([])).toEqual([]);
});

it('returns an empty array when the array contains only holes', () => {
expect(getProductVariantsToPrice([null, undefined])).toEqual([]);
});

it('returns an empty array for non-ProductVariant entities', () => {
const assets = [new Asset({ id: 1 }), new Asset({ id: 2 })];
expect(getProductVariantsToPrice(assets)).toEqual([]);
});

it('wraps a bare ProductVariant in an array', () => {
const variant = new ProductVariant({ id: 1 });
expect(getProductVariantsToPrice(variant)).toEqual([variant]);
});

it('returns an empty array for undefined', () => {
expect(getProductVariantsToPrice(undefined)).toEqual([]);
});
});

describe('hydrate() with applyProductVariantPrices', () => {
class TestOrder {
id = 1;
children?: any[];
}
class TestChild {}

/**
* Drives the real hydrate() against a stubbed query builder that returns a fixed
* hydrated result. The ProductPriceApplicator is the real implementation with stubbed
* strategies, so a crash in applyChannelPriceAndTax() is a real crash, not a mock
* artefact. A relation array can contain `null`/`undefined` elements —
* getRelationEntityAtPath() pushes them deliberately — and the price application at the
* hydrate() call site must neither skip the whole array because of one (the `[0]` sample
* did) nor pass one to applyChannelPriceAndTax(), which dereferences its argument.
*/
function createHydrator(children: any[]) {
const variantMetadata = {
target: ProductVariant,
findRelationWithPropertyPath: () => undefined,
};
const childMetadata = {
target: TestChild,
findRelationWithPropertyPath: (path: string) =>
path === 'variant' ? { inverseEntityMetadata: variantMetadata } : undefined,
};
const orderMetadata = {
target: TestOrder,
treeType: undefined,
relations: [],
findRelationWithPropertyPath: (path: string) =>
path === 'children' ? { inverseEntityMetadata: childMetadata } : undefined,
};
const queryBuilder = {
alias: 'TestOrder',
connection: { getMetadata: () => orderMetadata },
expressionMap: { joinAttributes: [] },
setFindOptions: () => queryBuilder,
getOne: () => Promise.resolve({ children }),
};
const connection = {
rawConnection: { entityMetadatas: [orderMetadata] },
getRepository: () => ({ createQueryBuilder: () => queryBuilder }),
};
const configService = {
catalogOptions: {
productVariantPriceSelectionStrategy: {
selectPrice: (_ctx: any, prices: any[]) => Promise.resolve(prices[0]),
},
productVariantPriceCalculationStrategy: {
calculate: ({ inputPrice }: any) =>
Promise.resolve({ price: inputPrice, priceIncludesTax: false }),
},
},
taxOptions: {
taxZoneStrategy: { determineTaxZone: () => ({ id: 1 }) },
},
};
const priceApplicator = new ProductPriceApplicator(
configService as any,
{ getApplicableTaxRate: () => Promise.resolve({ id: 1 }) } as any,
{ getAllWithMembers: () => Promise.resolve([]) } as any,
{ get: (_ctx: any, _key: any, getValue: () => any) => getValue() } as any,
);
const translator = { translate: (entity: any) => entity };
const hydrator = new EntityHydrator(connection as any, priceApplicator, translator as any);
const ctx = { channelId: 1, currencyCode: 'USD' } as any;
return { hydrator, ctx, target: new TestOrder() };
}

function createVariant(id: number): ProductVariant {
return new ProductVariant({
id,
productVariantPrices: [{ price: 4200, currencyCode: 'USD' }] as any,
taxCategory: { id: 1 } as any,
});
}

it('prices a variant that sits behind a null array element', async () => {
const variant = createVariant(1);
const { hydrator, ctx, target } = createHydrator([{ variant: null }, { variant }]);

await hydrator.hydrate(
ctx,
target as any,
{
relations: ['children.variant'],
applyProductVariantPrices: true,
} as any,
);

expect(variant.listPrice).toBe(4200);
});

it('does not pass a null array element to the price applicator', async () => {
const variant = createVariant(1);
const { hydrator, ctx, target } = createHydrator([{ variant }, { variant: null }]);

await hydrator.hydrate(
ctx,
target as any,
{
relations: ['children.variant'],
applyProductVariantPrices: true,
} as any,
);

expect(variant.listPrice).toBe(4200);
});

it('prices every element when the array is fully populated', async () => {
const variant1 = createVariant(1);
const variant2 = createVariant(2);
const { hydrator, ctx, target } = createHydrator([{ variant: variant1 }, { variant: variant2 }]);

await hydrator.hydrate(
ctx,
target as any,
{
relations: ['children.variant'],
applyProductVariantPrices: true,
} as any,
);

expect(variant1.listPrice).toBe(4200);
expect(variant2.listPrice).toBe(4200);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -151,21 +151,11 @@ export class EntityHydrator {

if (options.applyProductVariantPrices === true) {
for (const relationWithEntities of relationsWithEntities) {
const entity = relationWithEntities.entity;
if (entity) {
if (Array.isArray(entity)) {
if (entity[0] instanceof ProductVariant) {
await Promise.all(
entity.map((e: any) =>
this.productPriceApplicator.applyChannelPriceAndTax(e, ctx),
),
);
}
} else {
if (entity instanceof ProductVariant) {
await this.productPriceApplicator.applyChannelPriceAndTax(entity, ctx);
}
}
// Applied sequentially rather than with Promise.all: relation arrays are
// unbounded in size, and applyChannelPriceAndTax() is applied per variant
// the same way in ProductVariantService.assignProductVariantsToChannel()
for (const variant of this.getProductVariantsToPrice(relationWithEntities.entity)) {
await this.productPriceApplicator.applyChannelPriceAndTax(variant, ctx);
}
}
}
Expand Down Expand Up @@ -314,7 +304,13 @@ export class EntityHydrator {
if (Array.isArray(target)) {
isArrayResult = true;
if (parts.length === 0) {
result.push(...target);
// Use a plain loop rather than push(...target): spreading a very large array
// (e.g. `collection.productVariants` on a big catalog) expands it into call
// arguments, which exceeds V8's stack budget and throws a RangeError. Same
// fix as in getMissingRelations() above.
for (const item of target) {
result.push(item);
}
} else {
for (const item of target) {
visit(item, parts.slice());
Expand All @@ -334,6 +330,20 @@ export class EntityHydrator {
return isArrayResult ? result : result[0];
}

/**
* Returns the ProductVariants found at a relation path, to which Channel prices should be
* applied. A relation array can contain `null`/`undefined` entries — getRelationEntityAtPath()
* pushes them deliberately — and only some of its elements may be ProductVariants, so the type
* is tested per element rather than sampled from element [0]. Sampling [0] was wrong in both
* directions: a hole at [0] suppressed pricing for every real ProductVariant in the array, and
* a ProductVariant at [0] passed the holes behind it straight into applyChannelPriceAndTax(),
* which dereferences `variant.productVariantPrices` and throws.
*/
private getProductVariantsToPrice(entity: VendureEntity | VendureEntity[] | undefined): ProductVariant[] {
const candidates = Array.isArray(entity) ? entity : [entity];
return candidates.filter((e): e is ProductVariant => e instanceof ProductVariant);
}

private getRelationEntityTypeAtPath(entity: VendureEntity, path: string): Type<VendureEntity> {
const { entityMetadatas } = this.connection.rawConnection;
const targetMetadata = entityMetadatas.find(m => m.target === entity.constructor);
Expand Down
Loading