chore: add tests

This commit is contained in:
Boaz Poolman
2026-04-23 14:00:42 +02:00
parent bc95443972
commit 41290fe1bc
3 changed files with 244 additions and 1 deletions
@@ -1,4 +1,4 @@
import { mapRelation } from '../relations/utils/map-relation';
import { mapRelation, traverseEntityRelations } from '../relations/utils/map-relation';
const mapper = mapRelation(async (relation) => {
if (!relation) return 'default';
@@ -114,3 +114,55 @@ describe('map relation', () => {
expect(await mapper(relation)).toBe(expectedRelation);
});
});
describe('traverseEntityRelations', () => {
const CATEGORY_UID = 'api::category.category';
const schema = {
uid: 'api::article.article',
modelType: 'contentType' as const,
attributes: {
// morphToOne — visitor must be skipped (inline columns, not a join table)
related: { type: 'relation' as const, relation: 'morphToOne' as const },
// regular relation — visitor must be called
category: {
type: 'relation' as const,
relation: 'manyToOne' as const,
target: CATEGORY_UID,
},
},
};
const options = {
schema,
getModel: jest.fn().mockReturnValue(null),
};
it('does not call visitor for morphToOne relations', async () => {
const visitor = jest.fn();
// null value prevents traverseEntity from recursing into the attribute
await traverseEntityRelations(visitor, options, { related: null });
expect(visitor).not.toHaveBeenCalled();
});
it('calls visitor for regular relation attributes', async () => {
const visitor = jest.fn();
await traverseEntityRelations(visitor, options, { category: null });
expect(visitor).toHaveBeenCalledTimes(1);
expect(visitor).toHaveBeenCalledWith(
expect.objectContaining({ key: 'category' }),
expect.anything()
);
});
it('skips morphToOne but processes other relations in the same entity', async () => {
const visitor = jest.fn();
await traverseEntityRelations(visitor, options, { related: null, category: null });
// only 'category' should trigger the visitor — 'related' (morphToOne) is skipped
expect(visitor).toHaveBeenCalledTimes(1);
expect(visitor).toHaveBeenCalledWith(
expect.objectContaining({ key: 'category' }),
expect.anything()
);
});
});
@@ -0,0 +1,62 @@
import type { Internal } from '@strapi/types';
import { getDeepPopulate } from '../populate';
const ARTICLE_UID = 'api::article.article' as Internal.UID.ContentType;
const CATEGORY_UID = 'api::category.category' as Internal.UID.ContentType;
const articleModel = {
uid: ARTICLE_UID,
modelType: 'contentType' as const,
kind: 'collectionType' as const,
info: { displayName: 'Article', singularName: 'article', pluralName: 'articles' },
options: { draftAndPublish: true },
attributes: {
title: { type: 'string' as const },
// Polymorphic relations that can point to any content type
related: { type: 'relation' as const, relation: 'morphToOne' as const },
relatedMany: { type: 'relation' as const, relation: 'morphToMany' as const },
// Regular relation with a fixed target
category: {
type: 'relation' as const,
relation: 'manyToOne' as const,
target: CATEGORY_UID,
},
// Virtual relation not managed by the DB layer — must be excluded
virtualRel: {
type: 'relation' as const,
relation: 'oneToMany' as const,
target: CATEGORY_UID,
unstable_virtual: true,
},
},
};
describe('getDeepPopulate', () => {
let result: Record<string, unknown>;
beforeAll(() => {
global.strapi = { getModel: () => articleModel } as any;
result = getDeepPopulate(ARTICLE_UID);
});
it('includes morphToOne relations', () => {
expect(result).toHaveProperty('related');
});
it('includes morphToMany relations', () => {
expect(result).toHaveProperty('relatedMany');
});
it('includes regular relations', () => {
expect(result).toHaveProperty('category');
});
it('excludes unstable_virtual relations', () => {
expect(result).not.toHaveProperty('virtualRel');
});
it('does not include scalar attributes', () => {
expect(result).not.toHaveProperty('title');
});
});
@@ -0,0 +1,129 @@
import applyPopulate from '../apply';
/**
* Tests for the morphToOne populate helper in apply.ts.
*
* Key behaviour under test:
* - The populated result row includes a `__type` field (or a custom typeField from morphColumn)
* that carries the target content-type UID.
* - When a result row has no morph target (null id / type columns) the attribute is set to null.
*/
// fromRow is the only piece of transform we need to isolate — mock it so tests
// don't depend on field/scalar serialisation internals.
jest.mock('../../transform', () => ({
fromRow: jest.fn((_meta: unknown, row: unknown) => {
if (row == null) return null;
return row; // pass-through for test purposes
}),
}));
const TARGET_TYPE = 'api::category.category';
const SOURCE_UID = 'api::article.article';
const ATTRIBUTE_NAME = 'related';
const buildMorphAttribute = (typeField?: string) => ({
type: 'relation' as const,
relation: 'morphToOne' as const,
morphColumn: {
idColumn: { name: 'related_id', referencedColumn: 'id' },
typeColumn: { name: 'related_type' },
...(typeField ? { typeField } : {}),
},
});
const buildCtx = (targetRows: Record<string, unknown>[], typeField?: string) => {
const mockQb = {
alias: 't',
init: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue(targetRows),
};
const db = {
metadata: {
get: jest.fn((type: string) => {
if (type === SOURCE_UID) {
return {
attributes: {
[ATTRIBUTE_NAME]: buildMorphAttribute(typeField),
},
};
}
// target type meta — only needs to exist (fromRow is mocked)
return { columnToAttribute: {}, attributes: {} };
}),
},
entityManager: {
createQueryBuilder: jest.fn().mockReturnValue(mockQb),
},
};
const ctx = {
db,
uid: SOURCE_UID,
qb: { state: { filters: {} } },
};
return { ctx, mockQb };
};
describe('morphToOne populate', () => {
it('includes __type in the populated result', async () => {
const targetRow = { id: 10, name: 'Category A' };
const { ctx } = buildCtx([targetRow]);
const results: Record<string, unknown>[] = [
{ id: 1, related_id: 10, related_type: TARGET_TYPE },
];
await applyPopulate(results, { [ATTRIBUTE_NAME]: {} }, ctx as any);
expect(results[0][ATTRIBUTE_NAME]).toEqual({
__type: TARGET_TYPE,
...targetRow,
});
});
it('uses a custom typeField from morphColumn when provided', async () => {
const targetRow = { id: 10, name: 'Category A' };
const { ctx } = buildCtx([targetRow], 'contentType');
const results: Record<string, unknown>[] = [
{ id: 1, related_id: 10, related_type: TARGET_TYPE },
];
await applyPopulate(results, { [ATTRIBUTE_NAME]: {} }, ctx as any);
expect(results[0][ATTRIBUTE_NAME]).toEqual({
contentType: TARGET_TYPE,
...targetRow,
});
});
it('sets the attribute to null when the morph columns are empty', async () => {
const { ctx } = buildCtx([]);
const results: Record<string, unknown>[] = [{ id: 2, related_id: null, related_type: null }];
await applyPopulate(results, { [ATTRIBUTE_NAME]: {} }, ctx as any);
expect(results[0][ATTRIBUTE_NAME]).toBeNull();
});
it('handles mixed results — some with targets, some without', async () => {
const targetRow = { id: 10, name: 'Category A' };
const { ctx } = buildCtx([targetRow]);
const results: Record<string, unknown>[] = [
{ id: 1, related_id: 10, related_type: TARGET_TYPE },
{ id: 2, related_id: null, related_type: null },
];
await applyPopulate(results, { [ATTRIBUTE_NAME]: {} }, ctx as any);
expect(results[0][ATTRIBUTE_NAME]).toEqual({ __type: TARGET_TYPE, ...targetRow });
expect(results[1][ATTRIBUTE_NAME]).toBeNull();
});
});