diff --git a/packages/core/data-transfer/lib/providers/local-strapi-source-provider/__tests__/entities.test.ts b/packages/core/data-transfer/lib/providers/local-strapi-source-provider/__tests__/entities.test.ts new file mode 100644 index 0000000000..2000ee2e7b --- /dev/null +++ b/packages/core/data-transfer/lib/providers/local-strapi-source-provider/__tests__/entities.test.ts @@ -0,0 +1,152 @@ +import type { IEntity } from '../../../../types'; + +import { Readable, PassThrough } from 'stream'; + +import { + collect, + getStrapiFactory, + createMockedReadableFactory, + getContentTypes, +} from './test-utils'; +import { createEntitiesStream, createEntitiesTransformStream } from '../entities'; + +describe('Local Strapi Source Provider - Entities Streaming', () => { + describe('Create Entities Stream', () => { + test('Should return an empty stream if there is no content type', async () => { + const customContentTypes = {}; + const stream = createMockedReadableFactory({}); + + const strapi = getStrapiFactory({ + contentTypes: customContentTypes, + entityService: { stream }, + })(); + + const entitiesStream = createEntitiesStream(strapi); + + // The returned value should be a Readable stream instance + expect(entitiesStream).toBeInstanceOf(Readable); + + const entities = await collect>(entitiesStream); + + // The stream should not have been called since there is no content types + // Note: This check must happen AFTER we've collected the results + expect(stream).not.toHaveBeenCalled(); + + // We have 0 * 0 entities + expect(entities).toHaveLength(0); + }); + + test('Should return a stream with 4 entities from 2 content types', async () => { + const stream = createMockedReadableFactory({ + foo: [ + { id: 1, title: 'First title' }, + { id: 2, title: 'Second title' }, + ], + bar: [ + { id: 1, age: 42 }, + { id: 2, age: 84 }, + ], + }); + + const strapi = getStrapiFactory({ + contentTypes: getContentTypes(), + entityService: { stream }, + })(); + + const entitiesStream = createEntitiesStream(strapi); + + // The returned value should be a Readable stream instance + expect(entitiesStream).toBeInstanceOf(Readable); + + const results = await collect(entitiesStream); + + // Should have been called with 'foo', then 'bar' + // Note: This check must happen AFTER we've collected the results + expect(stream).toHaveBeenCalledTimes(2); + + // We have 2 * 2 entities + expect(results).toHaveLength(4); + + const matchContentTypeUIDs = new RegExp(`(${Object.keys(getContentTypes()).join('|')})`); + + // Each result should contain the entity and its parent content type + results.forEach((result) => { + expect(result).toMatchObject( + expect.objectContaining({ + entity: expect.objectContaining({ + id: expect.any(Number), + }), + contentType: expect.objectContaining({ + uid: expect.stringMatching(matchContentTypeUIDs), + attributes: expect.any(Object), + }), + }) + ); + }); + }); + }); + + describe('Create Entities Transform Stream', () => { + test('Should transform entities to the Strapi Data Transfer Format', async () => { + const entities = [ + { + entity: { id: 1, title: 'My first foo' }, + contentType: { uid: 'foo' }, + }, + { + entity: { id: 4, title: 'Another Foo' }, + contentType: { uid: 'foo' }, + }, + { + entity: { id: 12, title: 'Last foo' }, + contentType: { uid: 'foo' }, + }, + { + entity: { id: 1, age: 21 }, + contentType: { uid: 'bar' }, + }, + { + entity: { id: 2, age: 42 }, + contentType: { uid: 'bar' }, + }, + { + entity: { id: 7, age: 84 }, + contentType: { uid: 'bar' }, + }, + { + entity: { id: 9, age: 0 }, + contentType: { uid: 'bar' }, + }, + ]; + const matchContentTypeUIDs = new RegExp( + `(${Object.values(entities) + .map((entity) => entity.contentType.uid) + .join('|')})` + ); + + const entitiesStream = Readable.from(entities); + const transformStream = createEntitiesTransformStream(); + + expect(transformStream).toBeInstanceOf(PassThrough); + + // Connect the data source to the transformation stream + const pipeline = entitiesStream.pipe(transformStream); + + const transformedEntities = await collect(pipeline); + + // Check the amount of transformed entities matches the initial amount + expect(transformedEntities).toHaveLength(entities.length); + + // Each result should contain a type (uid), and id and some data + transformedEntities.forEach((entity) => { + expect(entity).toMatchObject( + expect.objectContaining({ + type: expect.stringMatching(matchContentTypeUIDs), + id: expect.any(Number), + data: expect.any(Object), + }) + ); + }); + }); + }); +}); diff --git a/packages/core/data-transfer/lib/providers/local-strapi-source-provider/__tests__/index.test.ts b/packages/core/data-transfer/lib/providers/local-strapi-source-provider/__tests__/index.test.ts new file mode 100644 index 0000000000..9b0e7cf7af --- /dev/null +++ b/packages/core/data-transfer/lib/providers/local-strapi-source-provider/__tests__/index.test.ts @@ -0,0 +1,112 @@ +import type { IEntity } from '../../../../types'; + +import { Readable } from 'stream'; + +import { collect, getStrapiFactory } from './test-utils'; +import { createLocalStrapiSourceProvider } from '../'; + +describe('Local Strapi Source Provider', () => { + describe('Boostrap', () => { + test('Should not have a defined Strapi instance if bootstrap has not been called', () => { + const provider = createLocalStrapiSourceProvider({ getStrapi: getStrapiFactory() }); + + expect(provider.strapi).not.toBeDefined(); + }); + + test('Should have a defined Strapi instance if bootstrap has been called', async () => { + const provider = createLocalStrapiSourceProvider({ getStrapi: getStrapiFactory() }); + await provider.bootstrap(); + + expect(provider.strapi).toBeDefined(); + }); + }); + + describe('Close', () => { + test('Should destroy the strapi instance if autoDestroy is undefined ', async () => { + const destroy = jest.fn(); + + const provider = createLocalStrapiSourceProvider({ + getStrapi: getStrapiFactory({ destroy }), + }); + + await provider.bootstrap(); + await provider.close(); + + expect(destroy).toHaveBeenCalled(); + }); + + test('Should destroy the strapi instance if autoDestroy is true ', async () => { + const destroy = jest.fn(); + + const provider = createLocalStrapiSourceProvider({ + getStrapi: getStrapiFactory({ destroy }), + autoDestroy: true, + }); + + await provider.bootstrap(); + await provider.close(); + + expect(destroy).toHaveBeenCalled(); + }); + }); + + describe('Streaming Entities', () => { + test('Should throw an error if strapi is not defined', async () => { + const provider = createLocalStrapiSourceProvider({ getStrapi: getStrapiFactory() }); + + await expect(() => provider.streamEntities()).rejects.toThrowError( + 'Not able to stream entities. Strapi instance not found' + ); + }); + + test('Should successfully create a readable stream with all available entities', async () => { + const contentTypes = { + foo: { uid: 'foo', attributes: { title: { type: 'string' } } }, + bar: { uid: 'bar', attributes: { age: { type: 'number' } } }, + }; + + const stream = jest.fn((uid: string, _query: unknown) => { + if (uid === 'foo') { + return Readable.from([ + { id: 1, title: 'First title' }, + { id: 2, title: 'Second title' }, + ]); + } + + if (uid === 'bar') { + return Readable.from([ + { id: 1, age: 42 }, + { id: 2, age: 84 }, + ]); + } + }); + + const provider = createLocalStrapiSourceProvider({ + getStrapi: getStrapiFactory({ + contentTypes, + entityService: { stream }, + }), + }); + + await provider.bootstrap(); + + const entitiesStream = (await provider.streamEntities()) as Readable; + const entities = await collect>(entitiesStream); + + // Should have been called with 'foo', then 'bar' + expect(stream).toHaveBeenCalledTimes(2); + // The returned value should be a Readable stream instance + expect(entitiesStream).toBeInstanceOf(Readable); + // We have 2 * 2 entities + expect(entities).toHaveLength(4); + // Each entity should follow the transfer format + entities.forEach((entity) => { + expect(entity).toMatchObject({ + type: expect.any(String), + id: expect.any(Number), + data: expect.any(Object), + }); + }); + }); + }); +}); diff --git a/packages/core/data-transfer/lib/providers/local-strapi-source-provider/__tests__/test-utils.ts b/packages/core/data-transfer/lib/providers/local-strapi-source-provider/__tests__/test-utils.ts new file mode 100644 index 0000000000..7ef78d52e1 --- /dev/null +++ b/packages/core/data-transfer/lib/providers/local-strapi-source-provider/__tests__/test-utils.ts @@ -0,0 +1,57 @@ +import { Readable } from 'stream'; + +/** + * Collect every entity in a Readable stream + */ +export const collect = (stream: Readable): Promise => { + const chunks: T[] = []; + + return new Promise((resolve) => { + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('end', () => { + stream.destroy(); + resolve(chunks); + }); + }); +}; + +/** + * Create a "Strapi" like object factory based on the + * given params and cast it to the correct type + */ +export const getStrapiFactory = + < + T extends { + [key in keyof Partial]: unknown; + } + >( + properties?: T + ) => + () => { + return { ...properties } as Strapi.Strapi; + }; + +/** + * Union type used to represent the default content types available + */ +export type ContentType = 'foo' | 'bar'; + +/** + * Factory to get default content types test values + */ +export const getContentTypes = (): { + [key in ContentType]: { uid: key; attributes: { [attribute: string]: unknown } }; +} => ({ + foo: { uid: 'foo', attributes: { title: { type: 'string' } } }, + bar: { uid: 'bar', attributes: { age: { type: 'number' } } }, +}); + +/** + * Create a factory of readable streams (wrapped with a jest mock function) + */ +export const createMockedReadableFactory = (source: { + [ct in T]: Array<{ id: number; [key: string]: unknown }>; +}) => + jest.fn((uid: T) => { + return Readable.from(source[uid] || []); + });