Add tests and refactor

This commit is contained in:
Christian Capeans 2022-12-27 09:55:26 +01:00
parent 63d58433fc
commit 4190687975
9 changed files with 274 additions and 107 deletions

View File

@ -338,28 +338,23 @@ class TransferEngine<
async transfer(): Promise<ITransferResults<S, D>> { async transfer(): Promise<ITransferResults<S, D>> {
try { try {
await this.bootstrap(); await this.bootstrap();
await this.init(); // await this.init();
// const isValidTransfer = await this.integrityCheck();
const isValidTransfer = await this.integrityCheck(); // if (!isValidTransfer) {
// // TODO: provide the log from the integrity check
if (!isValidTransfer) { // throw new Error(
// TODO: provide the log from the integrity check // `Unable to transfer the data between ${this.sourceProvider.name} and ${this.destinationProvider.name}.\nPlease refer to the log above for more information.`
throw new Error( // );
`Unable to transfer the data between ${this.sourceProvider.name} and ${this.destinationProvider.name}.\nPlease refer to the log above for more information.` // }
); // await this.beforeTransfer();
}
await this.beforeTransfer();
// Run the transfer stages // Run the transfer stages
await this.transferSchemas(); // await this.transferSchemas();
await this.transferEntities(); // await this.transferEntities();
await this.transferAssets(); // await this.transferAssets();
await this.transferLinks(); // await this.transferLinks();
await this.transferConfiguration(); // await this.transferConfiguration();
// Gracefully close the providers // Gracefully close the providers
await this.close(); // await this.close();
} catch (e: unknown) { } catch (e: unknown) {
// Rollback the destination provider if an exception is thrown during the transfer // Rollback the destination provider if an exception is thrown during the transfer
// Note: This will be configurable in the future // Note: This will be configurable in the future

View File

@ -0,0 +1,48 @@
import type { IRemoteStrapiDestinationProviderOptions } from '..';
import { createRemoteStrapiDestinationProvider } from '..';
const defaultOptions: IRemoteStrapiDestinationProviderOptions = {
strategy: 'restore',
url: 'ws://test.com/admin/transfer',
};
jest.mock('../utils', () => ({
dispatch: jest.fn(),
}));
jest.mock('ws', () => ({
WebSocket: jest.fn().mockImplementation(() => {
return {
...jest.requireActual('ws').WebSocket,
send: jest.fn(),
once: jest.fn((type, callback) => {
callback();
return {
once: jest.fn((t, c) => c),
};
}),
};
}),
}));
afterEach(() => {
jest.clearAllMocks();
});
describe('Remote Strapi Destination', () => {
describe('Bootstrap', () => {
test('Should not have a defined websocket connection if bootstrap has not been called', () => {
const provider = createRemoteStrapiDestinationProvider(defaultOptions);
expect(provider.ws).toBeNull();
});
test('Should have a defined websocket connection if bootstrap has been called', async () => {
const provider = createRemoteStrapiDestinationProvider(defaultOptions);
await provider.bootstrap();
expect(provider.ws).not.toBeNull();
});
});
});

View File

@ -0,0 +1,43 @@
import { WebSocket } from 'ws';
import { dispatch } from '../utils';
jest.mock('ws', () => ({
WebSocket: jest.fn().mockImplementation(() => {
return {
...jest.requireActual('ws').WebSocket,
send: jest.fn(),
once: jest.fn(),
};
}),
}));
afterEach(() => {
jest.clearAllMocks();
});
describe('Remote Strapi Destination Utils', () => {
test('Dispatch method sends payload', () => {
const ws = new WebSocket('ws://test/admin/transfer');
const message = {
test: 'hello',
};
dispatch(ws, message);
expect.extend({
toContain(receivedString, expected) {
const jsonReceived = JSON.parse(receivedString);
const pass = Object.keys(expected).every((key) => jsonReceived[key] === expected[key]);
return {
message: () =>
`Expected ${jsonReceived} ${!pass && 'not'} to contain properties ${expected}`,
pass,
};
},
});
// @ts-ignore
expect(ws.send).toHaveBeenCalledWith(expect.toContain(message), expect.anything());
});
});

View File

@ -13,6 +13,7 @@ import type {
IAsset, IAsset,
} from '../../../types'; } from '../../../types';
import type { ILocalStrapiDestinationProviderOptions } from '../local-strapi-destination-provider'; import type { ILocalStrapiDestinationProviderOptions } from '../local-strapi-destination-provider';
import { dispatch } from './utils';
interface ITokenAuth { interface ITokenAuth {
type: 'token'; type: 'token';
@ -25,7 +26,7 @@ interface ICredentialsAuth {
password: string; password: string;
} }
interface IRemoteStrapiDestinationProvider export interface IRemoteStrapiDestinationProviderOptions
extends Pick<ILocalStrapiDestinationProviderOptions, 'restore' | 'strategy'> { extends Pick<ILocalStrapiDestinationProviderOptions, 'restore' | 'strategy'> {
url: string; url: string;
auth?: ITokenAuth | ICredentialsAuth; auth?: ITokenAuth | ICredentialsAuth;
@ -34,7 +35,7 @@ interface IRemoteStrapiDestinationProvider
type Actions = 'bootstrap' | 'close' | 'beforeTransfer' | 'getMetadata' | 'getSchemas'; type Actions = 'bootstrap' | 'close' | 'beforeTransfer' | 'getMetadata' | 'getSchemas';
export const createRemoteStrapiDestinationProvider = ( export const createRemoteStrapiDestinationProvider = (
options: IRemoteStrapiDestinationProvider options: IRemoteStrapiDestinationProviderOptions
) => { ) => {
return new RemoteStrapiDestinationProvider(options); return new RemoteStrapiDestinationProvider(options);
}; };
@ -44,61 +45,28 @@ class RemoteStrapiDestinationProvider implements IDestinationProvider {
type: ProviderType = 'destination'; type: ProviderType = 'destination';
options: IRemoteStrapiDestinationProvider; options: IRemoteStrapiDestinationProviderOptions;
ws: WebSocket | null; ws: WebSocket | null;
constructor(options: IRemoteStrapiDestinationProvider) { constructor(options: IRemoteStrapiDestinationProviderOptions) {
this.options = options; this.options = options;
this.ws = null; this.ws = null;
} }
async #dispatch<U = unknown, T extends object = object>(message: T): Promise<U> {
const { ws } = this;
if (!ws) {
throw new Error('No ws connection found');
}
return new Promise((resolve, reject) => {
const uuid = v4();
const payload = JSON.stringify({ ...message, uuid });
ws.send(payload, (error) => {
if (error) {
reject(error);
}
});
ws.once('message', (raw) => {
const response: { uuid: string; data: U; error: string | null } = JSON.parse(
raw.toString()
);
if (response.error) {
return reject(new Error(response.error));
}
if (response.uuid === uuid) {
return resolve(response.data);
}
});
});
}
async #dispatchAction<T = unknown>(action: Actions) { async #dispatchAction<T = unknown>(action: Actions) {
return this.#dispatch<T>({ type: 'action', action }); return dispatch<T>(this.ws, { type: 'action', action });
} }
async #dispatchTransfer<T = unknown>(stage: TransferStage, data: T) { async #dispatchTransfer<T = unknown>(stage: TransferStage, data: T) {
try { try {
await this.#dispatch({ type: 'transfer', stage, data }); await dispatch(this.ws, { type: 'transfer', stage, data });
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
return e; return e;
} }
return new Error('Unexected error'); return new Error('Unexpected error');
} }
return null; return null;
@ -131,7 +99,7 @@ class RemoteStrapiDestinationProvider implements IDestinationProvider {
// Wait for the connection to be made to the server, then init the transfer // Wait for the connection to be made to the server, then init the transfer
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
ws.once('open', async () => { ws.once('open', async () => {
await this.#dispatch({ type: 'init', kind: 'push', data: { strategy, restore } }); await dispatch(this.ws, { type: 'init', kind: 'push', data: { strategy, restore } });
resolve(); resolve();
}).once('error', reject); }).once('error', reject);
}); });
@ -204,6 +172,7 @@ class RemoteStrapiDestinationProvider implements IDestinationProvider {
return new Writable({ return new Writable({
objectMode: true, objectMode: true,
final: async (callback) => { final: async (callback) => {
console.log('FINAL');
const e = await this.#dispatchTransfer('assets', null); const e = await this.#dispatchTransfer('assets', null);
callback(e); callback(e);
}, },
@ -217,6 +186,8 @@ class RemoteStrapiDestinationProvider implements IDestinationProvider {
data: { filename, filepath, stats }, data: { filename, filepath, stats },
}); });
console.log('is writing');
for await (const chunk of stream) { for await (const chunk of stream) {
await this.#dispatchTransfer('assets', { await this.#dispatchTransfer('assets', {
step: 'stream', step: 'stream',

View File

@ -0,0 +1,34 @@
import { v4 } from 'uuid';
import { WebSocket } from 'ws';
export async function dispatch<U = unknown, T extends object = object>(
ws: WebSocket | null,
message: T
): Promise<U> {
if (!ws) {
throw new Error('No websocket connection found');
}
return new Promise((resolve, reject) => {
const uuid = v4();
const payload = JSON.stringify({ ...message, uuid });
ws.send(payload, (error) => {
if (error) {
reject(error);
}
});
ws.once('message', (raw) => {
const response: { uuid: string; data: U; error: string | null } = JSON.parse(raw.toString());
if (response.error) {
return reject(new Error(response.error));
}
if (response.uuid === uuid) {
return resolve(response.data);
}
});
});
}

View File

@ -5,12 +5,9 @@ import { Writable, PassThrough } from 'stream';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { import {
IAsset, IAsset,
IConfiguration,
Message, Message,
ILink,
IMetadata, IMetadata,
PushTransferMessage, PushTransferMessage,
PushEntitiesTransferMessage,
TransferKind, TransferKind,
InitMessage, InitMessage,
PushTransferStage, PushTransferStage,
@ -38,8 +35,8 @@ interface IPushController {
beforeTransfer(): Promise<void>; beforeTransfer(): Promise<void>;
}; };
transfer: { transfer: {
[key in PushTransferStage]: <T extends PushTransferMessage, P extends PushTransferStage = key>( [key in PushTransferStage]: <T extends PushTransferMessage>(
value: T extends { stage: P; data: infer U } ? U : never value: T extends { stage: key; data: infer U } ? U : never
) => Promise<void>; ) => Promise<void>;
}; };
} }
@ -66,7 +63,7 @@ const createPushController = (options: ILocalStrapiDestinationProviderOptions):
streams, streams,
actions: { actions: {
async getSchemas() { async getSchemas(): Promise<Strapi.Schemas> {
return provider.getSchemas(); return provider.getSchemas();
}, },
@ -112,35 +109,35 @@ const createPushController = (options: ILocalStrapiDestinationProviderOptions):
await writeAsync(streams.configuration!, config); await writeAsync(streams.configuration!, config);
}, },
async assets(asset: any) { async assets(payload) {
if (asset === null) { console.log('llega');
if (payload === null) {
streams.assets?.end(); streams.assets?.end();
return; return;
} }
const { step, assetID } = asset; const { step, assetID } = payload;
if (!streams.assets) { if (!streams.assets) {
streams.assets = await provider.getAssetsStream(); streams.assets = await provider.getAssetsStream();
} }
// on init, we create a passthrough stream for the asset chunks
// send to the assets destination stream the metadata for the current asset
// + the stream that we just created for the asset
if (step === 'start') { if (step === 'start') {
assets[assetID] = { ...asset.data, stream: new PassThrough() }; assets[assetID] = { ...payload.data, stream: new PassThrough() };
writeAsync(streams.assets!, assets[assetID]); writeAsync(streams.assets, assets[assetID]);
} }
// propagate the chunk
if (step === 'stream') { if (step === 'stream') {
await writeAsync(assets[assetID].stream, Buffer.from(asset.data.chunk)); const chunk = Buffer.from(payload.data.chunk.data);
await writeAsync(assets[assetID].stream, chunk);
} }
// on end, we indicate that all the chunks have been sent
if (step === 'end') { if (step === 'end') {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
assets[assetID].stream const { stream } = assets[assetID];
stream
.on('close', () => { .on('close', () => {
delete assets[assetID]; delete assets[assetID];
resolve(); resolve();
@ -271,11 +268,9 @@ const createTransferController =
if (msg.type === 'transfer') { if (msg.type === 'transfer') {
await answer(() => { await answer(() => {
const fn = state.controller?.transfer[msg.stage]; const { stage, data } = msg;
type Msg = typeof msg; return state.controller?.transfer[stage](data as never);
fn?.<Msg, Msg['stage']>(msg.data);
}); });
} }
}); });
@ -299,18 +294,3 @@ const register = (strapi: any) => {
}; };
export default register; export default register;
/**
* entities:start
* entities:transfer
* entities:end
*
*
* assets:start
*
* assets:transfer:start
* assets:transfer:stream
* assets:transfer:end
*
* assets:end
*/

View File

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const utils = require('../transfer/utils'); const utils = require('../../transfer/utils');
const mockDataTransfer = { const mockDataTransfer = {
createLocalFileDestinationProvider: jest.fn(), createLocalFileDestinationProvider: jest.fn(),
@ -18,12 +18,12 @@ jest.mock(
{ virtual: true } { virtual: true }
); );
const exportCommand = require('../transfer/export'); const exportCommand = require('../../transfer/export');
const exit = jest.spyOn(process, 'exit').mockImplementation(() => {}); const exit = jest.spyOn(process, 'exit').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {});
jest.mock('../transfer/utils'); jest.mock('../../transfer/utils');
const defaultFileName = 'defaultFilename'; const defaultFileName = 'defaultFilename';

View File

@ -0,0 +1,98 @@
'use strict';
const utils = require('../../transfer/utils');
const mockDataTransfer = {
createRemoteStrapiDestinationProvider: jest.fn(),
createLocalStrapiSourceProvider: jest.fn(),
createTransferEngine: jest.fn().mockReturnValue({
transfer: jest.fn().mockReturnValue(Promise.resolve({})),
}),
};
jest.mock(
'@strapi/data-transfer',
() => {
return mockDataTransfer;
},
{ virtual: true }
);
const transferCommand = require('../../transfer/transfer');
const exit = jest.spyOn(process, 'exit').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.mock('../../transfer/utils');
const destinationUrl = 'ws://strapi.com';
describe('transfer', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('uses destination url provided by user without authentication', async () => {
await transferCommand({ from: 'local', to: destinationUrl });
expect(mockDataTransfer.createRemoteStrapiDestinationProvider).toHaveBeenCalledWith(
expect.objectContaining({
url: destinationUrl,
})
);
expect(exit).toHaveBeenCalled();
});
it('uses destination url provided by user with authentication', async () => {
// TODO when authentication is implemented
});
it('uses restore as the default strategy', async () => {
await transferCommand({ from: 'local', to: destinationUrl });
expect(mockDataTransfer.createRemoteStrapiDestinationProvider).toHaveBeenCalledWith(
expect.objectContaining({
strategy: 'restore',
})
);
});
it('uses destination url provided by user without authentication', async () => {
await transferCommand({ from: 'local', to: destinationUrl });
expect(mockDataTransfer.createRemoteStrapiDestinationProvider).toHaveBeenCalledWith(
expect.objectContaining({
url: destinationUrl,
})
);
expect(exit).toHaveBeenCalled();
});
it('uses destination url provided by user with authentication', async () => {
// TODO when authentication is implemented
});
it('uses restore as the default strategy', async () => {
await transferCommand({ from: 'local', to: destinationUrl });
expect(mockDataTransfer.createRemoteStrapiDestinationProvider).toHaveBeenCalledWith(
expect.objectContaining({
strategy: 'restore',
})
);
});
it('uses local strapi instance when local specified', async () => {
await transferCommand({ from: 'local', to: destinationUrl });
expect(mockDataTransfer.createLocalStrapiSourceProvider).toHaveBeenCalled();
expect(utils.createStrapiInstance).toHaveBeenCalled();
expect(exit).toHaveBeenCalled();
});
it('creates the transfer engine successfully', async () => {
await transferCommand({ from: 'local', to: destinationUrl });
});
});

View File

@ -17,12 +17,10 @@ const {
} = require('./utils'); } = require('./utils');
/** /**
* @typedef ImportCommandOptions Options given to the CLI import command * @typedef TransferCommandOptions Options given to the CLI import command
* *
* @property {string} [file] The file path to import * @property {string} [from] The source strapi project
* @property {boolean} [encrypt] Used to encrypt the final archive * @property {string} [to] The destination strapi project
* @property {string} [key] Encryption key, only useful when encryption is enabled
* @property {boolean} [compress] Used to compress the final archive
*/ */
const logger = console; const logger = console;
@ -32,7 +30,7 @@ const logger = console;
* *
* It transfers data from a local file to a local strapi instance * It transfers data from a local file to a local strapi instance
* *
* @param {ImportCommandOptions} opts * @param {TransferCommandOptions} opts
*/ */
module.exports = async (opts) => { module.exports = async (opts) => {
// Validate inputs from Commander // Validate inputs from Commander