diff --git a/packages/core/core/src/services/document-service/transform/__tests__/map-relation.test.ts b/packages/core/core/src/services/document-service/transform/__tests__/map-relation.test.ts index 2dfd967c60..e1dac5e9a0 100644 --- a/packages/core/core/src/services/document-service/transform/__tests__/map-relation.test.ts +++ b/packages/core/core/src/services/document-service/transform/__tests__/map-relation.test.ts @@ -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() + ); + }); +}); diff --git a/packages/core/core/src/services/document-service/utils/__tests__/populate.test.ts b/packages/core/core/src/services/document-service/utils/__tests__/populate.test.ts new file mode 100644 index 0000000000..9e6539db4e --- /dev/null +++ b/packages/core/core/src/services/document-service/utils/__tests__/populate.test.ts @@ -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; + + 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'); + }); +}); diff --git a/packages/core/database/src/query/helpers/populate/__tests__/apply-morph-to-one.test.ts b/packages/core/database/src/query/helpers/populate/__tests__/apply-morph-to-one.test.ts new file mode 100644 index 0000000000..7d56de5b30 --- /dev/null +++ b/packages/core/database/src/query/helpers/populate/__tests__/apply-morph-to-one.test.ts @@ -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[], 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[] = [ + { 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[] = [ + { 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[] = [{ 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[] = [ + { 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(); + }); +});