From cce45f9a8b898d13db65bc067056a5c975da528a Mon Sep 17 00:00:00 2001 From: Rhys Date: Fri, 5 Mar 2021 09:21:09 +0000 Subject: [PATCH 01/15] feat(hub): clone hub command --- src/commands/content-item/__mocks__/copy.ts | 5 +- src/commands/content-item/archive.ts | 9 +- src/commands/content-item/export.ts | 3 +- src/commands/content-item/import-revert.ts | 20 +- src/commands/content-item/import.spec.ts | 5 +- src/commands/content-item/import.ts | 21 +- src/commands/content-item/unarchive.ts | 4 +- .../__snapshots__/export.spec.ts.snap | 219 ++++--- src/commands/content-type-schema/archive.ts | 7 +- .../content-type-schema/export.spec.ts | 86 ++- src/commands/content-type-schema/export.ts | 51 +- .../content-type-schema/import.spec.ts | 101 ++- src/commands/content-type-schema/import.ts | 62 +- src/commands/content-type-schema/unarchive.ts | 3 +- src/commands/content-type/archive.ts | 7 +- src/commands/content-type/export.spec.ts | 165 ++--- src/commands/content-type/export.ts | 54 +- src/commands/content-type/import.spec.ts | 115 ++-- src/commands/content-type/import.ts | 90 ++- src/commands/content-type/unarchive.ts | 3 +- src/commands/event/archive.ts | 2 +- src/commands/hub/clone.spec.ts | 596 ++++++++++++++++++ src/commands/hub/clone.ts | 257 ++++++++ src/commands/hub/model/clone-hub-state.ts | 14 + src/commands/hub/model/clone-hub-step.ts | 7 + src/commands/hub/steps/content-clone-step.ts | 33 + src/commands/hub/steps/schema-clone-step.ts | 70 ++ src/commands/hub/steps/settings-clone-step.ts | 87 +++ src/commands/hub/steps/type-clone-step.ts | 84 +++ src/commands/settings/export.spec.ts | 7 + src/commands/settings/export.ts | 36 +- src/commands/settings/import.ts | 9 +- src/common/archive/archive-helpers.ts | 31 +- src/common/archive/archive-options.ts | 2 +- .../dc-management-sdk-js/mock-content.ts | 95 ++- src/common/dc-management-sdk-js/paginator.ts | 9 +- .../dc-management-sdk-js/resource-status.ts | 9 + src/common/file-log.ts | 6 +- src/common/log-helpers.ts | 23 + src/interfaces/clone-hub-builder-options.ts | 27 + .../copy-item-builder-options.interface.ts | 2 +- .../export-builder-options.interface.ts | 4 + .../import-builder-options.interface.ts | 3 + .../import-item-builder-options.interface.ts | 2 +- ...port-settings-builder-options.interface.ts | 4 +- src/services/export.service.spec.ts | 9 +- src/services/export.service.ts | 52 +- src/view/data-presenter.ts | 11 +- 48 files changed, 2077 insertions(+), 444 deletions(-) create mode 100644 src/commands/hub/clone.spec.ts create mode 100644 src/commands/hub/clone.ts create mode 100644 src/commands/hub/model/clone-hub-state.ts create mode 100644 src/commands/hub/model/clone-hub-step.ts create mode 100644 src/commands/hub/steps/content-clone-step.ts create mode 100644 src/commands/hub/steps/schema-clone-step.ts create mode 100644 src/commands/hub/steps/settings-clone-step.ts create mode 100644 src/commands/hub/steps/type-clone-step.ts create mode 100644 src/common/dc-management-sdk-js/resource-status.ts create mode 100644 src/interfaces/clone-hub-builder-options.ts diff --git a/src/commands/content-item/__mocks__/copy.ts b/src/commands/content-item/__mocks__/copy.ts index 281e7870..5f38dea6 100644 --- a/src/commands/content-item/__mocks__/copy.ts +++ b/src/commands/content-item/__mocks__/copy.ts @@ -17,7 +17,10 @@ export const setForceFail = (fail: boolean): void => { export const handler = async (argv: Arguments): Promise => { calls.push(argv); const idOut = argv.exportedIds as string[]; - idOut.push(...outputIds); + + if (idOut) { + idOut.push(...outputIds); + } return !forceFail; }; diff --git a/src/commands/content-item/archive.ts b/src/commands/content-item/archive.ts index 20432e8c..3aa9fc95 100644 --- a/src/commands/content-item/archive.ts +++ b/src/commands/content-item/archive.ts @@ -9,6 +9,7 @@ import { ContentItem, DynamicContent } from 'dc-management-sdk-js'; import { equalsOrRegex } from '../../common/filter/filter'; import { getDefaultLogPath, createLog } from '../../common/log-helpers'; import { FileLog } from '../../common/file-log'; +import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'archive [id]'; @@ -153,7 +154,7 @@ export const getContentItems = async ({ contentType }: { client: DynamicContent; - id?: string; + id?: string | string[]; hubId: string; repoId?: string | string[]; folderId?: string | string[]; @@ -165,7 +166,9 @@ export const getContentItems = async ({ const contentItems: ContentItem[] = []; if (id != null) { - contentItems.push(await client.contentItems.get(id)); + const itemIds = Array.isArray(id) ? id : [id]; + const items = await Promise.all(itemIds.map(id => client.contentItems.get(id))); + contentItems.push(...items); return { contentItems, @@ -192,7 +195,7 @@ export const getContentItems = async ({ ) : await Promise.all( contentRepositories.map(async source => { - const items = await paginator(source.related.contentItems.list, { status: 'ACTIVE' }); + const items = await paginator(source.related.contentItems.list, { status: Status.ACTIVE }); contentItems.push(...items); }) ); diff --git a/src/commands/content-item/export.ts b/src/commands/content-item/export.ts index 1b9b81b3..80c3205e 100644 --- a/src/commands/content-item/export.ts +++ b/src/commands/content-item/export.ts @@ -16,6 +16,7 @@ import { ContentDependancyTree, RepositoryContentItem } from '../../common/conte import { ContentMapping } from '../../common/content-item/content-mapping'; import { getDefaultLogPath } from '../../common/log-helpers'; import { AmplienceSchemaValidator, defaultSchemaLookup } from '../../common/content-item/amplience-schema-validator'; +import { Status } from '../../common/dc-management-sdk-js/resource-status'; interface PublishedContentItem { lastPublishedVersion?: number; @@ -136,7 +137,7 @@ const getContentItems = async ( // Add content items in repo base folder. Cache the other items so we don't have to request them again. let newItems: ContentItem[]; try { - const allItems = await paginator(repository.related.contentItems.list, { status: 'ACTIVE' }); + const allItems = await paginator(repository.related.contentItems.list, { status: Status.ACTIVE }); Array.prototype.push.apply(repoItems, allItems); newItems = allItems.filter(item => item.folderId == null); diff --git a/src/commands/content-item/import-revert.ts b/src/commands/content-item/import-revert.ts index 81143e9d..b93b3a2f 100644 --- a/src/commands/content-item/import-revert.ts +++ b/src/commands/content-item/import-revert.ts @@ -4,15 +4,21 @@ import { Arguments } from 'yargs'; import { FileLog } from '../../common/file-log'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ContentItem } from 'dc-management-sdk-js'; -import { asyncQuestion } from '../../common/archive/archive-helpers'; +import { asyncQuestion } from '../../common/log-helpers'; export const revert = async (argv: Arguments): Promise => { - const log = new FileLog(); - try { - await log.loadFromFile(argv.revertLog as string); - } catch (e) { - console.log('Could not open the import log! Aborting.'); - return false; + let log: FileLog; + + if (typeof argv.revertLog === 'string') { + log = new FileLog(); + try { + await log.loadFromFile(argv.revertLog as string); + } catch (e) { + console.log('Could not open the import log! Aborting.'); + return false; + } + } else { + log = argv.revertLog as FileLog; } // We just need to access the destination repo to undo a import. diff --git a/src/commands/content-item/import.spec.ts b/src/commands/content-item/import.spec.ts index 56b3b4f4..f22f6841 100644 --- a/src/commands/content-item/import.spec.ts +++ b/src/commands/content-item/import.spec.ts @@ -22,7 +22,10 @@ jest.mock('./import-revert'); jest.mock('../../services/dynamic-content-client-factory'); jest.mock('../../common/import/publish-queue'); jest.mock('../../common/media/media-rewriter'); -jest.mock('../../common/log-helpers'); +jest.mock('../../common/log-helpers', () => ({ + ...jest.requireActual('../../common/log-helpers'), + getDefaultLogPath: jest.fn() +})); function rimraf(dir: string): Promise { return new Promise((resolve): void => { diff --git a/src/commands/content-item/import.ts b/src/commands/content-item/import.ts index 3a9f5799..f1042553 100644 --- a/src/commands/content-item/import.ts +++ b/src/commands/content-item/import.ts @@ -27,9 +27,8 @@ import { ContentDependancyInfo } from '../../common/content-item/content-dependancy-tree'; -import { asyncQuestion } from '../../common/archive/archive-helpers'; import { AmplienceSchemaValidator, defaultSchemaLookup } from '../../common/content-item/amplience-schema-validator'; -import { getDefaultLogPath } from '../../common/log-helpers'; +import { getDefaultLogPath, asyncQuestion } from '../../common/log-helpers'; import { PublishQueue } from '../../common/import/publish-queue'; import { MediaRewriter } from '../../common/media/media-rewriter'; @@ -393,7 +392,8 @@ const prepareContentForImport = async ( const updateExisting = force || (await asyncQuestion( - `${alreadyExists.length} of the items being imported already exist in the mapping. Would you like to update these content items instead of skipping them? (y/n) ` + `${alreadyExists.length} of the items being imported already exist in the mapping. Would you like to update these content items instead of skipping them? (y/n) `, + log )); if (!updateExisting) { @@ -435,7 +435,8 @@ const prepareContentForImport = async ( const create = force || (await asyncQuestion( - 'Content types can be automatically created for these schemas, but it is not recommended as they will have a default name and lack any configuration. Are you sure you wish to continue? (y/n) ' + 'Content types can be automatically created for these schemas, but it is not recommended as they will have a default name and lack any configuration. Are you sure you wish to continue? (y/n) ', + log )); if (!create) { return null; @@ -499,7 +500,8 @@ const prepareContentForImport = async ( const createAssignments = force || (await asyncQuestion( - 'These assignments will be created automatically. Are you sure you still wish to continue? (y/n) ' + 'These assignments will be created automatically. Are you sure you still wish to continue? (y/n) ', + log )); if (!createAssignments) { return null; @@ -576,7 +578,8 @@ const prepareContentForImport = async ( const ignore = force || (await asyncQuestion( - `${affectedContentItems.length} out of ${beforeRemove} content items will be skipped. Are you sure you still wish to continue? (y/n) ` + `${affectedContentItems.length} out of ${beforeRemove} content items will be skipped. Are you sure you still wish to continue? (y/n) `, + log )); if (!ignore) { return null; @@ -652,7 +655,8 @@ const prepareContentForImport = async ( const ignore = force || (await asyncQuestion( - `${invalidContentItems.length} out of ${contentItems.length} content items will be affected. Are you sure you still wish to continue? (y/n) ` + `${invalidContentItems.length} out of ${contentItems.length} content items will be affected. Are you sure you still wish to continue? (y/n) `, + log )); if (!ignore) { return null; @@ -959,7 +963,8 @@ export const handler = async ( const ignore = force || (await asyncQuestion( - 'These repositories will be skipped during the import, as they need to be added to the hub manually. Do you want to continue? (y/n) ' + 'These repositories will be skipped during the import, as they need to be added to the hub manually. Do you want to continue? (y/n) ', + log )); if (!ignore) { closeLog(); diff --git a/src/commands/content-item/unarchive.ts b/src/commands/content-item/unarchive.ts index aacf23ce..174997e8 100644 --- a/src/commands/content-item/unarchive.ts +++ b/src/commands/content-item/unarchive.ts @@ -8,6 +8,7 @@ import UnarchiveOptions from '../../common/archive/unarchive-options'; import { ContentItem, DynamicContent } from 'dc-management-sdk-js'; import { equalsOrRegex } from '../../common/filter/filter'; import { getDefaultLogPath } from '../../common/log-helpers'; +import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'unarchive [id]'; @@ -206,8 +207,7 @@ export const getContentItems = async ({ ) : await Promise.all( contentRepositories.map(async source => { - const items = await paginator(source.related.contentItems.list, { status: 'ARCHIVED' }); - + const items = await paginator(source.related.contentItems.list, { status: Status.ACTIVE }); contentItems.push(...items); }) ); diff --git a/src/commands/content-type-schema/__snapshots__/export.spec.ts.snap b/src/commands/content-type-schema/__snapshots__/export.spec.ts.snap index 1e554ad6..f55265ae 100644 --- a/src/commands/content-type-schema/__snapshots__/export.spec.ts.snap +++ b/src/commands/content-type-schema/__snapshots__/export.spec.ts.snap @@ -581,35 +581,52 @@ exports[`content-type-schema export command processContentTypeSchemas should not Array [ Array [ Array [ - "File", - "Schema file", - "Schema ID", - "Result", - ], - ], - Array [ - Array [ - "export-dir/export-filename-1.json", - "", - "content-type-schema-id-1", - "UP-TO-DATE", - ], - ], - Array [ - Array [ - "export-dir/export-filename-2.json", - "", - "content-type-schema-id-2", - "UP-TO-DATE", - ], - ], - Array [ - Array [ - "export-dir/export-filename-3.json", - "", - "content-type-schema-id-3", - "UP-TO-DATE", + Array [ + "File", + "Schema file", + "Schema ID", + "Result", + ], + Array [ + "export-dir/export-filename-1.json", + "", + "content-type-schema-id-1", + "UP-TO-DATE", + ], + Array [ + "export-dir/export-filename-2.json", + "", + "content-type-schema-id-2", + "UP-TO-DATE", + ], + Array [ + "export-dir/export-filename-3.json", + "", + "content-type-schema-id-3", + "UP-TO-DATE", + ], ], + Object { + "border": undefined, + "columnCount": 4, + "columnDefault": Object { + "width": 50, + }, + "columns": Object { + "0": Object { + "width": 30, + }, + "1": Object { + "width": 30, + }, + "2": Object { + "width": 100, + }, + "3": Object { + "width": 10, + }, + }, + }, ], ] `; @@ -727,35 +744,52 @@ exports[`content-type-schema export command processContentTypeSchemas should out Array [ Array [ Array [ - "File", - "Schema file", - "Schema ID", - "Result", - ], - ], - Array [ - Array [ - "export-dir/export-filename-1.json", - "export-dir/schemas/export-filename-1-schema.json", - "content-type-schema-id-1", - "CREATED", - ], - ], - Array [ - Array [ - "export-dir/export-filename-2.json", - "export-dir/schemas/export-filename-2-schema.json", - "content-type-schema-id-2", - "CREATED", - ], - ], - Array [ - Array [ - "export-dir/export-filename-3.json", - "export-dir/schemas/export-filename-3-schema.json", - "content-type-schema-id-3", - "CREATED", + Array [ + "File", + "Schema file", + "Schema ID", + "Result", + ], + Array [ + "export-dir/export-filename-1.json", + "export-dir/schemas/export-filename-1-schema.json", + "content-type-schema-id-1", + "CREATED", + ], + Array [ + "export-dir/export-filename-2.json", + "export-dir/schemas/export-filename-2-schema.json", + "content-type-schema-id-2", + "CREATED", + ], + Array [ + "export-dir/export-filename-3.json", + "export-dir/schemas/export-filename-3-schema.json", + "content-type-schema-id-3", + "CREATED", + ], ], + Object { + "border": undefined, + "columnCount": 4, + "columnDefault": Object { + "width": 50, + }, + "columns": Object { + "0": Object { + "width": 30, + }, + "1": Object { + "width": 30, + }, + "2": Object { + "width": 100, + }, + "3": Object { + "width": 10, + }, + }, + }, ], ] `; @@ -804,35 +838,52 @@ exports[`content-type-schema export command processContentTypeSchemas should upd Array [ Array [ Array [ - "File", - "Schema file", - "Schema ID", - "Result", - ], - ], - Array [ - Array [ - "export-dir/export-filename-1.json", - "", - "content-type-schema-id-1", - "UP-TO-DATE", - ], - ], - Array [ - Array [ - "export-dir/export-filename-2.json", - "", - "content-type-schema-id-2", - "UP-TO-DATE", - ], - ], - Array [ - Array [ - "export-dir/export-filename-3.json", - "export-dir/schemas/export-filename-3-schema.json", - "content-type-schema-id-3", - "UPDATED", + Array [ + "File", + "Schema file", + "Schema ID", + "Result", + ], + Array [ + "export-dir/export-filename-1.json", + "", + "content-type-schema-id-1", + "UP-TO-DATE", + ], + Array [ + "export-dir/export-filename-2.json", + "", + "content-type-schema-id-2", + "UP-TO-DATE", + ], + Array [ + "export-dir/export-filename-3.json", + "export-dir/schemas/export-filename-3-schema.json", + "content-type-schema-id-3", + "UPDATED", + ], ], + Object { + "border": undefined, + "columnCount": 4, + "columnDefault": Object { + "width": 50, + }, + "columns": Object { + "0": Object { + "width": 30, + }, + "1": Object { + "width": 30, + }, + "2": Object { + "width": 100, + }, + "3": Object { + "width": 10, + }, + }, + }, ], ] `; diff --git a/src/commands/content-type-schema/archive.ts b/src/commands/content-type-schema/archive.ts index 0583b2ab..43a23977 100644 --- a/src/commands/content-type-schema/archive.ts +++ b/src/commands/content-type-schema/archive.ts @@ -9,6 +9,7 @@ import { confirmArchive } from '../../common/archive/archive-helpers'; import ArchiveOptions from '../../common/archive/archive-options'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { FileLog } from '../../common/file-log'; +import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'archive [id]'; @@ -78,8 +79,8 @@ export const handler = async (argv: Arguments client.contentTypeSchemas.get(id))); } catch (e) { console.log(`Fatal error: could not find schema with ID ${id}. Error: \n${e.toString()}`); return; @@ -87,7 +88,7 @@ export const handler = async (argv: Arguments { 'The Schema ID of a Content Type Schema to be exported.\nIf no --schemaId option is given, all content type schemas for the hub are exported.\nA single --schemaId option may be given to export a single content type schema.\nMultiple --schemaId options may be given to export multiple content type schemas at the same time.', requiresArg: true }); + expect(spyOption).toHaveBeenCalledWith('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite content type schema without asking.' + }); expect(spyOption).toHaveBeenCalledWith('archived', { type: 'boolean', describe: 'If present, archived content type schemas will also be considered.', boolean: true }); + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.' + }); }); }); @@ -75,7 +87,8 @@ describe('content-type-schema export command', (): void => { let mockGetContentTypeSchemaExports: jest.SpyInstance; let mockWriteJsonToFile: jest.SpyInstance; let mockWriteSchemaBody: jest.SpyInstance; - const mockStreamWrite = jest.fn(); + let mockTable: jest.Mock; + const exportedContentTypeSchemas = [ { schemaId: 'content-type-schema-id-1', @@ -106,9 +119,8 @@ describe('content-type-schema export command', (): void => { mockGetContentTypeSchemaExports = jest.spyOn(exportModule, 'getContentTypeSchemaExports'); mockWriteSchemaBody = jest.spyOn(exportModule, 'writeSchemaBody'); mockWriteJsonToFile = jest.spyOn(exportServiceModule, 'writeJsonToFile'); - (createStream as jest.Mock).mockReturnValue({ - write: mockStreamWrite - }); + mockTable = table as jest.Mock; + mockTable.mockImplementation(jest.requireActual('table').table); mockWriteJsonToFile.mockImplementation(); mockWriteSchemaBody.mockImplementation(); }); @@ -142,7 +154,7 @@ describe('content-type-schema export command', (): void => { [] ]); - await processContentTypeSchemas('export-dir', {}, contentTypeSchemasToProcess); + await processContentTypeSchemas('export-dir', {}, contentTypeSchemasToProcess, new FileLog(), false); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledTimes(1); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledWith('export-dir', {}, contentTypeSchemasToProcess); @@ -155,8 +167,8 @@ describe('content-type-schema export command', (): void => { expect(mockWriteSchemaBody).toHaveBeenCalledTimes(3); expect(mockWriteSchemaBody.mock.calls).toMatchSnapshot(); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite.mock.calls).toMatchSnapshot(); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable.mock.calls).toMatchSnapshot(); }); it('should not output any export files if a previous export exists and the content type is unchanged', async () => { @@ -184,7 +196,13 @@ describe('content-type-schema export command', (): void => { const previouslyExportedContentTypeSchemas = { 'export-dir/export-filename-2.json': contentTypeSchemasToProcess[1] }; - await processContentTypeSchemas('export-dir', previouslyExportedContentTypeSchemas, contentTypeSchemasToProcess); + await processContentTypeSchemas( + 'export-dir', + previouslyExportedContentTypeSchemas, + contentTypeSchemasToProcess, + new FileLog(), + false + ); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledTimes(1); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledWith( @@ -197,8 +215,8 @@ describe('content-type-schema export command', (): void => { expect(mockWriteJsonToFile).toHaveBeenCalledTimes(0); expect(mockWriteSchemaBody).toHaveBeenCalledTimes(0); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite.mock.calls).toMatchSnapshot(); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable.mock.calls).toMatchSnapshot(); }); it('should update the existing export file for a changed content type', async () => { @@ -241,7 +259,13 @@ describe('content-type-schema export command', (): void => { 'export-dir/export-filename-3.json': contentTypeSchemasToProcess[2] }; - await processContentTypeSchemas('export-dir', previouslyExportedContentTypeSchemas, mutatedContentTypeSchemas); + await processContentTypeSchemas( + 'export-dir', + previouslyExportedContentTypeSchemas, + mutatedContentTypeSchemas, + new FileLog(), + false + ); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledTimes(1); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledWith( @@ -258,8 +282,8 @@ describe('content-type-schema export command', (): void => { expect(mockWriteSchemaBody).toHaveBeenCalledTimes(1); expect(mockWriteSchemaBody.mock.calls).toMatchSnapshot(); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite.mock.calls).toMatchSnapshot(); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable.mock.calls).toMatchSnapshot(); }); it('should not update anything if the user says "n" to the overwrite prompt', async () => { @@ -310,7 +334,13 @@ describe('content-type-schema export command', (): void => { }; await expect( - processContentTypeSchemas('export-dir', previouslyExportedContentTypeSchemas, mutatedContentTypeSchemas) + processContentTypeSchemas( + 'export-dir', + previouslyExportedContentTypeSchemas, + mutatedContentTypeSchemas, + new FileLog(), + false + ) ).rejects.toThrowError(exitError); expect(stdoutSpy.mock.calls).toMatchSnapshot(); @@ -324,7 +354,7 @@ describe('content-type-schema export command', (): void => { expect(mockEnsureDirectory).toHaveBeenCalledTimes(0); expect(mockWriteJsonToFile).toHaveBeenCalledTimes(0); expect(mockWriteSchemaBody).toHaveBeenCalledTimes(0); - expect(mockStreamWrite).toHaveBeenCalledTimes(0); + expect(mockTable).toHaveBeenCalledTimes(0); expect(process.exit).toHaveBeenCalled(); }); @@ -336,7 +366,9 @@ describe('content-type-schema export command', (): void => { const stdoutSpy = jest.spyOn(process.stdout, 'write'); stdoutSpy.mockImplementation(); - await expect(processContentTypeSchemas('export-dir', {}, [])).rejects.toThrowError(exitError); + await expect(processContentTypeSchemas('export-dir', {}, [], new FileLog(), false)).rejects.toThrowError( + exitError + ); expect(stdoutSpy.mock.calls).toMatchSnapshot(); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledTimes(0); @@ -344,7 +376,7 @@ describe('content-type-schema export command', (): void => { expect(mockEnsureDirectory).toHaveBeenCalledTimes(0); expect(mockWriteJsonToFile).toHaveBeenCalledTimes(0); expect(mockWriteSchemaBody).toHaveBeenCalledTimes(0); - expect(mockStreamWrite).toHaveBeenCalledTimes(0); + expect(mockTable).toHaveBeenCalledTimes(0); expect(process.exit).toHaveBeenCalled(); }); }); @@ -644,6 +676,10 @@ describe('content-type-schema export command', (): void => { }); }); + function expectProcessArguments(dir: string, schemas: ContentTypeSchema[]): void { + expect(processContentTypeSchemasSpy.mock.calls[0].slice(0, 3)).toEqual([dir, {}, schemas]); + } + it('should export all content type schemas for the current hub', async (): Promise => { filterContentTypeSchemasBySchemaIdSpy.mockReturnValue(contentTypeSchemasToExport); @@ -655,7 +691,7 @@ describe('content-type-schema export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentTypeSchema); expect(resolveSchemaBodyMock).toHaveBeenCalledWith({}, 'my-dir'); expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, []); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, contentTypeSchemasToExport); + expectProcessArguments(argv.dir, contentTypeSchemasToExport); }); it('should ignore any resolve schema errors', async (): Promise => { @@ -670,7 +706,7 @@ describe('content-type-schema export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentTypeSchema); expect(resolveSchemaBodyMock).toHaveBeenCalledWith({}, 'my-dir'); expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, []); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, contentTypeSchemasToExport); + expectProcessArguments(argv.dir, contentTypeSchemasToExport); }); it('should export all content type schemas for the current hub if schemaId is not supplied', async (): Promise< @@ -686,7 +722,7 @@ describe('content-type-schema export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentTypeSchema); expect(resolveSchemaBodyMock).toHaveBeenCalledWith({}, 'my-dir'); expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, []); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, contentTypeSchemasToExport); + expectProcessArguments(argv.dir, contentTypeSchemasToExport); }); it('should export only the specified content type schema when schemaId is provided', async (): Promise => { @@ -703,7 +739,7 @@ describe('content-type-schema export command', (): void => { expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, [ 'content-type-schema-id-1' ]); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, filteredContentTypeSchemas); + expectProcessArguments(argv.dir, filteredContentTypeSchemas); }); it('should export all content type schemas when schemaId is undefined', async (): Promise => { @@ -715,7 +751,7 @@ describe('content-type-schema export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentTypeSchema); expect(resolveSchemaBodyMock).toHaveBeenCalledWith({}, 'my-dir'); expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, []); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, contentTypeSchemasToExport); + expectProcessArguments(argv.dir, contentTypeSchemasToExport); }); it('should export all content type schemas when schemaId is an empty array', async (): Promise => { @@ -727,7 +763,7 @@ describe('content-type-schema export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentTypeSchema); expect(resolveSchemaBodyMock).toHaveBeenCalledWith({}, 'my-dir'); expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, []); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, contentTypeSchemasToExport); + expectProcessArguments(argv.dir, contentTypeSchemasToExport); }); it('should export even archived content type schemas when --archived is provided', async (): Promise => { @@ -739,7 +775,7 @@ describe('content-type-schema export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentTypeSchema); expect(resolveSchemaBodyMock).toHaveBeenCalledWith({}, 'my-dir'); expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, []); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, contentTypeSchemasToExport); + expectProcessArguments(argv.dir, contentTypeSchemasToExport); }); }); diff --git a/src/commands/content-type-schema/export.ts b/src/commands/content-type-schema/export.ts index 83583ea7..c11d2268 100644 --- a/src/commands/content-type-schema/export.ts +++ b/src/commands/content-type-schema/export.ts @@ -3,9 +3,8 @@ import { ConfigurationParameters } from '../configure'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; import { ContentTypeSchema } from 'dc-management-sdk-js'; -import { createStream } from 'table'; +import { table } from 'table'; import { baseTableConfig } from '../../common/table/table.consts'; -import { TableStream } from '../../interfaces/table.interface'; import chalk from 'chalk'; import { ExportResult, @@ -20,6 +19,9 @@ import * as path from 'path'; import * as fs from 'fs'; import { resolveSchemaBody } from '../../services/resolve-schema-body'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; +import { FileLog } from '../../common/file-log'; +import { getDefaultLogPath } from '../../common/log-helpers'; +import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const streamTableOptions = { ...baseTableConfig, @@ -47,6 +49,9 @@ export const command = 'export '; export const desc = 'Export Content Type Schemas'; +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('schema', 'export', platform); + export const builder = (yargs: Argv): void => { yargs .positional('dir', { @@ -59,10 +64,21 @@ export const builder = (yargs: Argv): void => { 'The Schema ID of a Content Type Schema to be exported.\nIf no --schemaId option is given, all content type schemas for the hub are exported.\nA single --schemaId option may be given to export a single content type schema.\nMultiple --schemaId options may be given to export multiple content type schemas at the same time.', requiresArg: true }) + .alias('f', 'force') + .option('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite content type schema without asking.' + }) .option('archived', { type: 'boolean', describe: 'If present, archived content type schemas will also be considered.', boolean: true + }) + .option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.' }); }; @@ -199,10 +215,12 @@ export const getContentTypeSchemaExports = ( export const processContentTypeSchemas = async ( outputDir: string, previouslyExportedContentTypeSchemas: { [filename: string]: ContentTypeSchema }, - storedContentTypeSchemas: ContentTypeSchema[] + storedContentTypeSchemas: ContentTypeSchema[], + log: FileLog, + force: boolean ): Promise => { if (storedContentTypeSchemas.length === 0) { - nothingExportedExit('No content type schemas to export from this hub, exiting.\n'); + nothingExportedExit(log, 'No content type schemas to export from this hub, exiting.'); } const [allExports, updatedExportsMap] = getContentTypeSchemaExports( @@ -212,15 +230,15 @@ export const processContentTypeSchemas = async ( ); if ( allExports.length === 0 || - (Object.keys(updatedExportsMap).length > 0 && !(await promptToOverwriteExports(updatedExportsMap))) + (Object.keys(updatedExportsMap).length > 0 && !(force || (await promptToOverwriteExports(updatedExportsMap, log)))) ) { - nothingExportedExit(); + nothingExportedExit(log); } await ensureDirectoryExists(outputDir); - const tableStream = (createStream(streamTableOptions) as unknown) as TableStream; - tableStream.write([chalk.bold('File'), chalk.bold('Schema file'), chalk.bold('Schema ID'), chalk.bold('Result')]); + const data: string[][] = []; + data.push([chalk.bold('File'), chalk.bold('Schema file'), chalk.bold('Schema ID'), chalk.bold('Result')]); for (const { filename, status, contentTypeSchema } of allExports) { let schemaFilename = ''; if (status !== 'UP-TO-DATE') { @@ -239,24 +257,31 @@ export const processContentTypeSchemas = async ( }) ); } - tableStream.write([filename, schemaFilename, contentTypeSchema.schemaId || '', status]); + data.push([filename, schemaFilename, contentTypeSchema.schemaId || '', status]); } - process.stdout.write('\n'); + + log.appendLine(table(data, streamTableOptions)); }; export const handler = async (argv: Arguments): Promise => { - const { dir, schemaId } = argv; + const { dir, schemaId, logFile, force } = argv; const [contentTypeSchemas] = await resolveSchemaBody( loadJsonFromDirectory(dir, ContentTypeSchema), dir ); const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); + const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; const storedContentTypeSchemas = await paginator( hub.related.contentTypeSchema.list, - argv.archived ? undefined : { status: 'ACTIVE' } + argv.archived ? undefined : { status: Status.ACTIVE } ); const schemaIdArray: string[] = schemaId ? (Array.isArray(schemaId) ? schemaId : [schemaId]) : []; const filteredContentTypeSchemas = filterContentTypeSchemasBySchemaId(storedContentTypeSchemas, schemaIdArray); - await processContentTypeSchemas(dir, contentTypeSchemas, filteredContentTypeSchemas); + await processContentTypeSchemas(dir, contentTypeSchemas, filteredContentTypeSchemas, log, force || false); + + if (typeof logFile !== 'object') { + // Only close the log if it was opened by this handler. + await log.close(); + } }; diff --git a/src/commands/content-type-schema/import.spec.ts b/src/commands/content-type-schema/import.spec.ts index 3a507f17..e9052a36 100644 --- a/src/commands/content-type-schema/import.spec.ts +++ b/src/commands/content-type-schema/import.spec.ts @@ -1,15 +1,27 @@ import Yargs = require('yargs/yargs'); import * as importModule from './import'; -import { command, builder, handler, storedSchemaMapper, processSchemas, doCreate, doUpdate } from './import'; +import { + command, + builder, + handler, + storedSchemaMapper, + processSchemas, + doCreate, + doUpdate, + LOG_FILENAME +} from './import'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ContentTypeSchema, ValidationLevel, Hub } from 'dc-management-sdk-js'; -import { createStream } from 'table'; +import { table } from 'table'; import { createContentTypeSchema } from './create.service'; import { updateContentTypeSchema } from './update.service'; import paginator from '../../common/dc-management-sdk-js/paginator'; import { loadJsonFromDirectory, UpdateStatus } from '../../services/import.service'; import { resolveSchemaBody } from '../../services/resolve-schema-body'; +import { FileLog } from '../../common/file-log'; +import { streamTableOptions } from '../../common/table/table.consts'; +import chalk from 'chalk'; jest.mock('fs'); jest.mock('table'); @@ -37,12 +49,19 @@ describe('content-type-schema import command', (): void => { it('should configure yargs', () => { const argv = Yargs(process.argv.slice(2)); const spyPositional = jest.spyOn(argv, 'positional').mockReturnThis(); + const spyOption = jest.spyOn(argv, 'option').mockReturnThis(); builder(argv); expect(spyPositional).toHaveBeenCalledWith('dir', { describe: 'Directory containing Content Type Schema definitions', type: 'string' }); + + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.' + }); }); }); @@ -88,13 +107,14 @@ describe('content-type-schema import command', (): void => { describe('doCreate', () => { it('should create a content type schema and report the results', async () => { const hub = new Hub(); + const log = new FileLog(); const contentTypeSchema = { body: schemaBodyJson, validationLevel: ValidationLevel.CONTENT_TYPE } as ContentTypeSchema; (createContentTypeSchema as jest.Mock).mockResolvedValueOnce({ ...contentTypeSchema, id: 'create-id', schemaId }); - const result = await doCreate(hub, contentTypeSchema); + const result = await doCreate(hub, contentTypeSchema, log); expect(createContentTypeSchema).toHaveBeenCalledWith( contentTypeSchema.body, @@ -102,10 +122,16 @@ describe('content-type-schema import command', (): void => { hub ); expect(result).toEqual({ ...contentTypeSchema, id: 'create-id', schemaId }); + expect(log.getData('CREATE')).toMatchInlineSnapshot(` + Array [ + "create-id", + ] + `); }); it('should throw an error when content type schema fails to create', async () => { const hub = new Hub(); + const log = new FileLog(); const contentTypeSchema = { body: schemaBodyJson, @@ -115,7 +141,8 @@ describe('content-type-schema import command', (): void => { throw new Error('Error creating content type schema'); }); - await expect(doCreate(hub, contentTypeSchema)).rejects.toThrowErrorMatchingSnapshot(); + await expect(doCreate(hub, contentTypeSchema, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('CREATE')).toEqual([]); }); }); @@ -131,16 +158,19 @@ describe('content-type-schema import command', (): void => { }); it('should update a content type schema and report the results', async () => { const client = (dynamicContentClientFactory as jest.Mock)(); + const log = new FileLog(); const storedContentTypeSchema = { id: 'stored-id', schemaId, body: schemaBodyJson, - validationLevel: ValidationLevel.CONTENT_TYPE + validationLevel: ValidationLevel.CONTENT_TYPE, + version: 1 } as ContentTypeSchema; const mutatedContentTypeSchema = { ...storedContentTypeSchema, - body: `{\n\t"$schema": "http://json-schema.org/draft-07/schema#",\n\t"$id": "${schemaId}",\n\n\t"title": "Test Schema 1 - updated",\n\t"description": "Test Schema 1- updated",\n\n\t"allOf": [\n\t\t{\n\t\t\t"$ref": "http://bigcontent.io/cms/schema/v1/core#/definitions/content"\n\t\t}\n\t],\n\t\n\t"type": "object",\n\t"properties": {\n\t\t\n\t},\n\t"propertyOrder": []\n}` + body: `{\n\t"$schema": "http://json-schema.org/draft-07/schema#",\n\t"$id": "${schemaId}",\n\n\t"title": "Test Schema 1 - updated",\n\t"description": "Test Schema 1- updated",\n\n\t"allOf": [\n\t\t{\n\t\t\t"$ref": "http://bigcontent.io/cms/schema/v1/core#/definitions/content"\n\t\t}\n\t],\n\t\n\t"type": "object",\n\t"properties": {\n\t\t\n\t},\n\t"propertyOrder": []\n}`, + version: 2 } as ContentTypeSchema; mockGetContentTypeSchema.mockResolvedValueOnce(new ContentTypeSchema(storedContentTypeSchema)); (updateContentTypeSchema as jest.Mock).mockResolvedValueOnce({ @@ -148,7 +178,13 @@ describe('content-type-schema import command', (): void => { id: 'stored-id', schemaId }); - const result = await doUpdate(client, mutatedContentTypeSchema); + const result = await doUpdate(client, mutatedContentTypeSchema, log); + + expect(log.getData('UPDATE')).toMatchInlineSnapshot(` + Array [ + "stored-id 1 2", + ] + `); expect(updateContentTypeSchema).toHaveBeenCalledWith( expect.objectContaining(storedContentTypeSchema), @@ -160,15 +196,18 @@ describe('content-type-schema import command', (): void => { it('should update a content type when only the validationLevel has been updated', async () => { const client = (dynamicContentClientFactory as jest.Mock)(); + const log = new FileLog(); const storedContentTypeSchema = { id: 'stored-id', schemaId, body: schemaBodyJson, - validationLevel: ValidationLevel.CONTENT_TYPE + validationLevel: ValidationLevel.CONTENT_TYPE, + version: 1 } as ContentTypeSchema; const mutatedContentTypeSchema = { ...storedContentTypeSchema, - validationLevel: ValidationLevel.SLOT + validationLevel: ValidationLevel.SLOT, + version: 2 } as ContentTypeSchema; mockGetContentTypeSchema.mockResolvedValueOnce(new ContentTypeSchema(storedContentTypeSchema)); (updateContentTypeSchema as jest.Mock).mockResolvedValueOnce({ @@ -176,8 +215,13 @@ describe('content-type-schema import command', (): void => { id: 'stored-id', schemaId }); - const result = await doUpdate(client, mutatedContentTypeSchema); + const result = await doUpdate(client, mutatedContentTypeSchema, log); + expect(log.getData('UPDATE')).toMatchInlineSnapshot(` + Array [ + "stored-id 1 2", + ] + `); expect(updateContentTypeSchema).toHaveBeenCalledWith( expect.objectContaining(storedContentTypeSchema), mutatedContentTypeSchema.body, @@ -188,6 +232,7 @@ describe('content-type-schema import command', (): void => { it('should skip updating a content type schema when no changes detected and report the results', async () => { const client = (dynamicContentClientFactory as jest.Mock)(); + const log = new FileLog(); const storedContentTypeSchema = { id: 'stored-id', @@ -201,14 +246,16 @@ describe('content-type-schema import command', (): void => { } as ContentTypeSchema; mockGetContentTypeSchema.mockResolvedValueOnce(new ContentTypeSchema(storedContentTypeSchema)); - const result = await doUpdate(client, mutatedContentTypeSchema); + const result = await doUpdate(client, mutatedContentTypeSchema, log); + expect(log.getData('UPDATE')).toEqual([]); expect(updateContentTypeSchema).toHaveBeenCalledTimes(0); expect(result).toEqual(expect.objectContaining({ updateStatus: UpdateStatus.SKIPPED })); }); it('should throw an error when content type schema fails to create', async () => { const client = (dynamicContentClientFactory as jest.Mock)(); + const log = new FileLog(); const contentTypeSchema = { id: 'stored-id', @@ -219,17 +266,17 @@ describe('content-type-schema import command', (): void => { mockGetContentTypeSchema.mockImplementationOnce(() => { throw new Error('Error getting content type schema'); }); - await expect(doUpdate(client, contentTypeSchema)).rejects.toThrowErrorMatchingSnapshot(); + await expect(doUpdate(client, contentTypeSchema, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('UPDATE')).toEqual([]); }); }); describe('processSchemas', () => { - const mockStreamWrite = jest.fn(); + let mockTable: jest.Mock; beforeEach(() => { - (createStream as jest.Mock).mockReturnValue({ - write: mockStreamWrite - }); + mockTable = table as jest.Mock; + mockTable.mockImplementation(jest.requireActual('table').table); }); it('should successfully create and update a schema', async () => { @@ -251,13 +298,20 @@ describe('content-type-schema import command', (): void => { .spyOn(importModule, 'doUpdate') .mockResolvedValueOnce({ contentTypeSchema: contentTypeSchemaToUpdate, updateStatus: UpdateStatus.UPDATED }); - await processSchemas(schemasToProcess, client, hub); - - expect(importModule.doCreate).toHaveBeenCalledWith(hub, contentTypeSchemaToCreate); - expect(importModule.doUpdate).toHaveBeenCalledWith(client, contentTypeSchemaToUpdate); - expect(mockStreamWrite).toHaveBeenCalledTimes(3); - expect(mockStreamWrite).toHaveBeenNthCalledWith(2, ['new-id', schemaId, 'CREATED']); - expect(mockStreamWrite).toHaveBeenNthCalledWith(3, ['stored-id', schemaId, 'UPDATED']); + await processSchemas(schemasToProcess, client, hub, new FileLog()); + + expect(importModule.doCreate).toHaveBeenCalledWith(hub, contentTypeSchemaToCreate, expect.any(FileLog)); + expect(importModule.doUpdate).toHaveBeenCalledWith(client, contentTypeSchemaToUpdate, expect.any(FileLog)); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable).toHaveBeenNthCalledWith( + 1, + [ + [chalk.bold('ID'), chalk.bold('Schema ID'), chalk.bold('Result')], + ['new-id', schemaId, 'CREATED'], + ['stored-id', schemaId, 'UPDATED'] + ], + streamTableOptions + ); }); }); @@ -317,6 +371,7 @@ describe('content-type-schema import command', (): void => { expect(processSchemasSpy).toHaveBeenCalledWith( [expect.objectContaining(schemaToCreate), expect.objectContaining({ ...schemaToUpdate, id: 'stored-id' })], expect.any(Object), + expect.any(Object), expect.any(Object) ); }); diff --git a/src/commands/content-type-schema/import.ts b/src/commands/content-type-schema/import.ts index 6f508225..ef9f82cd 100644 --- a/src/commands/content-type-schema/import.ts +++ b/src/commands/content-type-schema/import.ts @@ -3,15 +3,17 @@ import { ConfigurationParameters } from '../configure'; import { ContentTypeSchema, DynamicContent, Hub, ValidationLevel } from 'dc-management-sdk-js'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; -import { createStream } from 'table'; +import { table } from 'table'; import { streamTableOptions } from '../../common/table/table.consts'; -import { TableStream } from '../../interfaces/table.interface'; import { ImportBuilderOptions } from '../../interfaces/import-builder-options.interface'; import chalk from 'chalk'; import { createContentTypeSchema } from './create.service'; import { updateContentTypeSchema } from './update.service'; import { ImportResult, loadJsonFromDirectory, UpdateStatus } from '../../services/import.service'; import { resolveSchemaBody } from '../../services/resolve-schema-body'; +import { FileLog } from '../../common/file-log'; +import { getDefaultLogPath } from '../../common/log-helpers'; +import { ResourceStatus, Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'import '; @@ -21,11 +23,20 @@ export interface SchemaOptions { validation: ValidationLevel; } +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('schema', 'import', platform); + export const builder = (yargs: Argv): void => { yargs.positional('dir', { describe: 'Directory containing Content Type Schema definitions', type: 'string' }); + + yargs.option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.' + }); }; export const storedSchemaMapper = ( @@ -38,7 +49,7 @@ export const storedSchemaMapper = ( return new ContentTypeSchema(mutatedSchema); }; -export const doCreate = async (hub: Hub, schema: ContentTypeSchema): Promise => { +export const doCreate = async (hub: Hub, schema: ContentTypeSchema, log: FileLog): Promise => { try { const createdSchemaType = await createContentTypeSchema( schema.body || '', @@ -46,6 +57,8 @@ export const doCreate = async (hub: Hub, schema: ContentTypeSchema): Promise export const doUpdate = async ( client: DynamicContent, - schema: ContentTypeSchema + schema: ContentTypeSchema, + log: FileLog ): Promise<{ contentTypeSchema: ContentTypeSchema; updateStatus: UpdateStatus }> => { try { - const retrievedSchema = await client.contentTypeSchemas.get(schema.id || ''); + let retrievedSchema: ContentTypeSchema = await client.contentTypeSchemas.get(schema.id || ''); if (equals(retrievedSchema, schema)) { return { contentTypeSchema: retrievedSchema, updateStatus: UpdateStatus.SKIPPED }; } + + if ((retrievedSchema as ResourceStatus).status === Status.ARCHIVED) { + try { + // Resurrect this schema before updating it. + retrievedSchema = await retrievedSchema.related.unarchive(); + } catch (err) { + throw new Error(`Error unable unarchive content type ${schema.id}: ${err.message}`); + } + } + const updatedSchema = await updateContentTypeSchema( retrievedSchema, schema.body || '', schema.validationLevel || ValidationLevel.CONTENT_TYPE ); + log.addAction('UPDATE', `${retrievedSchema.id} ${retrievedSchema.version} ${updatedSchema.version}`); + return { contentTypeSchema: updatedSchema, updateStatus: UpdateStatus.UPDATED }; } catch (err) { throw new Error(`Error updating content type schema ${schema.schemaId || ''}: ${err.message}`); @@ -79,31 +105,34 @@ export const doUpdate = async ( export const processSchemas = async ( schemasToProcess: ContentTypeSchema[], client: DynamicContent, - hub: Hub + hub: Hub, + log: FileLog ): Promise => { - const tableStream = (createStream(streamTableOptions) as unknown) as TableStream; + const data: string[][] = []; - tableStream.write([chalk.bold('ID'), chalk.bold('Schema ID'), chalk.bold('Result')]); + data.push([chalk.bold('ID'), chalk.bold('Schema ID'), chalk.bold('Result')]); for (const schema of schemasToProcess) { let status: ImportResult; let contentTypeSchema: ContentTypeSchema; if (schema.id) { - const result = await doUpdate(client, schema); + const result = await doUpdate(client, schema, log); contentTypeSchema = result.contentTypeSchema; status = result.updateStatus === UpdateStatus.SKIPPED ? 'UP-TO-DATE' : 'UPDATED'; } else { - contentTypeSchema = await doCreate(hub, schema); + contentTypeSchema = await doCreate(hub, schema, log); status = 'CREATED'; } - tableStream.write([contentTypeSchema.id || '', contentTypeSchema.schemaId || '', status]); + data.push([contentTypeSchema.id || '', contentTypeSchema.schemaId || '', status]); } - process.stdout.write('\n'); + + log.appendLine(table(data, streamTableOptions)); }; export const handler = async (argv: Arguments): Promise => { - const { dir } = argv; + const { dir, logFile } = argv; const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); + const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; const schemas = loadJsonFromDirectory(dir, ContentTypeSchema); const [resolvedSchemas, resolveSchemaErrors] = await resolveSchemaBody(schemas, dir); if (Object.keys(resolveSchemaErrors).length > 0) { @@ -120,5 +149,10 @@ export const handler = async (argv: Arguments getDefaultLogPath('schema', 'unarchive', platform); @@ -83,7 +84,7 @@ export const handler = async (argv: Arguments client.contentTypes.get(id))); } catch (e) { console.log(`Fatal error: could not find content type with ID ${id}. Error: \n${e.toString()}`); return; @@ -88,7 +89,7 @@ export const handler = async (argv: Arguments { 'The Schema ID of a Content Type to be exported.\nIf no --schemaId option is given, all content types for the hub are exported.\nA single --schemaId option may be given to export a single content type.\nMultiple --schemaId options may be given to export multiple content types at the same time.', requiresArg: true }); + expect(spyOption).toHaveBeenCalledWith('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite content types without asking.' + }); expect(spyOption).toHaveBeenCalledWith('archived', { type: 'boolean', describe: 'If present, archived content types will also be considered.', boolean: true }); + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.' + }); }); }); @@ -314,7 +327,7 @@ describe('content-type export command', (): void => { describe('processContentTypes', () => { let mockEnsureDirectory: jest.Mock; - let mockStreamWrite: jest.Mock; + let mockTable: jest.Mock; let stdoutSpy: jest.SpyInstance; const contentTypesToProcess = [ @@ -352,10 +365,8 @@ describe('content-type export command', (): void => { beforeEach(() => { mockEnsureDirectory = directoryUtils.ensureDirectoryExists as jest.Mock; - mockStreamWrite = jest.fn(); - (createStream as jest.Mock).mockReturnValue({ - write: mockStreamWrite - }); + mockTable = table as jest.Mock; + mockTable.mockImplementation(jest.requireActual('table').table); jest.spyOn(exportServiceModule, 'writeJsonToFile').mockImplementation(); stdoutSpy = jest.spyOn(process.stdout, 'write'); stdoutSpy.mockImplementation(); @@ -388,7 +399,13 @@ describe('content-type export command', (): void => { ]); const previouslyExportedContentTypes = {}; - await processContentTypes('export-dir', previouslyExportedContentTypes, contentTypesToProcess); + await processContentTypes( + 'export-dir', + previouslyExportedContentTypes, + contentTypesToProcess, + new FileLog(), + false + ); expect(exportModule.getContentTypeExports).toHaveBeenCalledTimes(1); expect(exportModule.getContentTypeExports).toHaveBeenCalledWith( @@ -416,27 +433,17 @@ describe('content-type export command', (): void => { expect.objectContaining(exportedContentTypes[2]) ); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite).toHaveBeenNthCalledWith(1, [ - chalk.bold('File'), - chalk.bold('Schema ID'), - chalk.bold('Result') - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(2, [ - 'export-dir/export-filename-1.json', - exportedContentTypes[0].contentTypeUri, - 'CREATED' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(3, [ - 'export-dir/export-filename-2.json', - exportedContentTypes[1].contentTypeUri, - 'CREATED' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(4, [ - 'export-dir/export-filename-3.json', - exportedContentTypes[2].contentTypeUri, - 'CREATED' - ]); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable).toHaveBeenNthCalledWith( + 1, + [ + [chalk.bold('File'), chalk.bold('Schema ID'), chalk.bold('Result')], + ['export-dir/export-filename-1.json', exportedContentTypes[0].contentTypeUri, 'CREATED'], + ['export-dir/export-filename-2.json', exportedContentTypes[1].contentTypeUri, 'CREATED'], + ['export-dir/export-filename-3.json', exportedContentTypes[2].contentTypeUri, 'CREATED'] + ], + streamTableOptions + ); }); it('should output a message if no content types to export from hub', async () => { @@ -449,15 +456,15 @@ describe('content-type export command', (): void => { const previouslyExportedContentTypes = {}; - await expect(processContentTypes('export-dir', previouslyExportedContentTypes, [])).rejects.toThrowError( - exitError - ); + await expect( + processContentTypes('export-dir', previouslyExportedContentTypes, [], new FileLog(), false) + ).rejects.toThrowError(exitError); expect(mockEnsureDirectory).toHaveBeenCalledTimes(0); expect(exportModule.getContentTypeExports).toHaveBeenCalledTimes(0); expect(stdoutSpy.mock.calls).toMatchSnapshot(); expect(exportServiceModule.writeJsonToFile).toHaveBeenCalledTimes(0); - expect(mockStreamWrite).toHaveBeenCalledTimes(0); + expect(mockTable).toHaveBeenCalledTimes(0); }); it('should not output any export files if a previous export exists and the content type is unchanged', async () => { @@ -485,7 +492,13 @@ describe('content-type export command', (): void => { const previouslyExportedContentTypes = { 'export-dir/export-filename-2.json': new ContentType(exportedContentTypes[1]) }; - await processContentTypes('export-dir', previouslyExportedContentTypes, contentTypesToProcess); + await processContentTypes( + 'export-dir', + previouslyExportedContentTypes, + contentTypesToProcess, + new FileLog(), + false + ); expect(exportModule.getContentTypeExports).toHaveBeenCalledTimes(1); expect(exportModule.getContentTypeExports).toHaveBeenCalledWith( @@ -497,27 +510,17 @@ describe('content-type export command', (): void => { expect(mockEnsureDirectory).toHaveBeenCalledTimes(1); expect(exportServiceModule.writeJsonToFile).toHaveBeenCalledTimes(0); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite).toHaveBeenNthCalledWith(1, [ - chalk.bold('File'), - chalk.bold('Schema ID'), - chalk.bold('Result') - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(2, [ - 'export-dir/export-filename-1.json', - exportedContentTypes[0].contentTypeUri, - 'UP-TO-DATE' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(3, [ - 'export-dir/export-filename-2.json', - exportedContentTypes[1].contentTypeUri, - 'UP-TO-DATE' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(4, [ - 'export-dir/export-filename-3.json', - exportedContentTypes[2].contentTypeUri, - 'UP-TO-DATE' - ]); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable).toHaveBeenNthCalledWith( + 1, + [ + [chalk.bold('File'), chalk.bold('Schema ID'), chalk.bold('Result')], + ['export-dir/export-filename-1.json', exportedContentTypes[0].contentTypeUri, 'UP-TO-DATE'], + ['export-dir/export-filename-2.json', exportedContentTypes[1].contentTypeUri, 'UP-TO-DATE'], + ['export-dir/export-filename-3.json', exportedContentTypes[2].contentTypeUri, 'UP-TO-DATE'] + ], + streamTableOptions + ); }); it('should update the existing export file for a changed content type', async () => { @@ -560,7 +563,13 @@ describe('content-type export command', (): void => { 'export-dir/export-filename-2.json': new ContentType(exportedContentTypes[1]) }; - await processContentTypes('export-dir', previouslyExportedContentTypes, mutatedContentTypes); + await processContentTypes( + 'export-dir', + previouslyExportedContentTypes, + mutatedContentTypes, + new FileLog(), + false + ); expect(exportModule.getContentTypeExports).toHaveBeenCalledTimes(1); expect(exportModule.getContentTypeExports).toHaveBeenCalledWith( @@ -572,27 +581,17 @@ describe('content-type export command', (): void => { expect(mockEnsureDirectory).toHaveBeenCalledTimes(1); expect(exportServiceModule.writeJsonToFile).toHaveBeenCalledTimes(1); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite).toHaveBeenNthCalledWith(1, [ - chalk.bold('File'), - chalk.bold('Schema ID'), - chalk.bold('Result') - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(2, [ - 'export-dir/export-filename-1.json', - exportedContentTypes[0].contentTypeUri, - 'UP-TO-DATE' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(3, [ - 'export-dir/export-filename-2.json', - exportedContentTypes[1].contentTypeUri, - 'UPDATED' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(4, [ - 'export-dir/export-filename-3.json', - exportedContentTypes[2].contentTypeUri, - 'UP-TO-DATE' - ]); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable).toHaveBeenNthCalledWith( + 1, + [ + [chalk.bold('File'), chalk.bold('Schema ID'), chalk.bold('Result')], + ['export-dir/export-filename-1.json', exportedContentTypes[0].contentTypeUri, 'UP-TO-DATE'], + ['export-dir/export-filename-2.json', exportedContentTypes[1].contentTypeUri, 'UPDATED'], + ['export-dir/export-filename-3.json', exportedContentTypes[2].contentTypeUri, 'UP-TO-DATE'] + ], + streamTableOptions + ); }); it('should not update anything if the user says "n" to the overwrite prompt', async () => { @@ -639,7 +638,7 @@ describe('content-type export command', (): void => { }; await expect( - processContentTypes('export-dir', previouslyExportedContentTypes, mutatedContentTypes) + processContentTypes('export-dir', previouslyExportedContentTypes, mutatedContentTypes, new FileLog(), false) ).rejects.toThrowError(exitError); expect(exportModule.getContentTypeExports).toHaveBeenCalledTimes(1); @@ -651,7 +650,7 @@ describe('content-type export command', (): void => { expect(mockEnsureDirectory).toHaveBeenCalledTimes(0); expect(exportServiceModule.writeJsonToFile).toHaveBeenCalledTimes(0); - expect(mockStreamWrite).toHaveBeenCalledTimes(0); + expect(mockTable).toHaveBeenCalledTimes(0); expect(process.exit).toHaveBeenCalled(); }); }); @@ -708,6 +707,10 @@ describe('content-type export command', (): void => { jest.spyOn(exportModule, 'processContentTypes').mockResolvedValue(); }); + function expectProcessArguments(dir: string, types: ContentType[]): void { + expect((exportModule.processContentTypes as jest.Mock).mock.calls[0].slice(0, 3)).toEqual([dir, [], types]); + } + it('should export all content types for the current hub if no schemaIds specified', async (): Promise => { const schemaIdsToExport: string[] | undefined = undefined; const argv = { ...yargArgs, ...config, dir: 'my-dir', schemaId: schemaIdsToExport }; @@ -723,7 +726,7 @@ describe('content-type export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentType); expect(validateNoDuplicateContentTypeUris).toHaveBeenCalled(); expect(exportModule.filterContentTypesByUri).toHaveBeenCalledWith(contentTypesToExport, []); - expect(exportModule.processContentTypes).toHaveBeenCalledWith(argv.dir, [], filteredContentTypesToExport); + expectProcessArguments(argv.dir, filteredContentTypesToExport); }); it('should export even archived content types for the current hub if --archived is provided', async (): Promise< @@ -744,7 +747,7 @@ describe('content-type export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentType); expect(validateNoDuplicateContentTypeUris).toHaveBeenCalled(); expect(exportModule.filterContentTypesByUri).toHaveBeenCalledWith(contentTypesToExport, []); - expect(exportModule.processContentTypes).toHaveBeenCalledWith(argv.dir, [], filteredContentTypesToExport); + expectProcessArguments(argv.dir, filteredContentTypesToExport); }); it('should export only selected content types if schemaIds specified', async (): Promise => { @@ -761,7 +764,7 @@ describe('content-type export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentType); expect(validateNoDuplicateContentTypeUris).toHaveBeenCalled(); expect(exportModule.filterContentTypesByUri).toHaveBeenCalledWith(contentTypesToExport, schemaIdsToExport); - expect(exportModule.processContentTypes).toHaveBeenCalledWith(argv.dir, [], filteredContentTypesToExport); + expectProcessArguments(argv.dir, filteredContentTypesToExport); }); }); }); diff --git a/src/commands/content-type/export.ts b/src/commands/content-type/export.ts index 9c5c9510..71719081 100644 --- a/src/commands/content-type/export.ts +++ b/src/commands/content-type/export.ts @@ -3,9 +3,8 @@ import { ConfigurationParameters } from '../configure'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; import { ContentType } from 'dc-management-sdk-js'; -import { createStream } from 'table'; +import { table } from 'table'; import { streamTableOptions } from '../../common/table/table.consts'; -import { TableStream } from '../../interfaces/table.interface'; import chalk from 'chalk'; import { ExportResult, @@ -19,11 +18,17 @@ import { validateNoDuplicateContentTypeUris } from './import'; import { isEqual } from 'lodash'; import { ExportBuilderOptions } from '../../interfaces/export-builder-options.interface'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; +import { FileLog } from '../../common/file-log'; +import { getDefaultLogPath } from '../../common/log-helpers'; +import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'export '; export const desc = 'Export Content Types'; +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('type', 'export', platform); + export const builder = (yargs: Argv): void => { yargs .positional('dir', { @@ -36,10 +41,21 @@ export const builder = (yargs: Argv): void => { 'The Schema ID of a Content Type to be exported.\nIf no --schemaId option is given, all content types for the hub are exported.\nA single --schemaId option may be given to export a single content type.\nMultiple --schemaId options may be given to export multiple content types at the same time.', requiresArg: true }) + .alias('f', 'force') + .option('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite content types without asking.' + }) .option('archived', { type: 'boolean', describe: 'If present, archived content types will also be considered.', boolean: true + }) + .option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.' }); }; @@ -137,10 +153,12 @@ export const getContentTypeExports = ( export const processContentTypes = async ( outputDir: string, previouslyExportedContentTypes: { [filename: string]: ContentType }, - contentTypesBeingExported: ContentType[] + contentTypesBeingExported: ContentType[], + log: FileLog, + force: boolean ): Promise => { if (contentTypesBeingExported.length === 0) { - nothingExportedExit('No content types to export from this hub, exiting.\n'); + nothingExportedExit(log, 'No content types to export from this hub, exiting.'); } const [allExports, updatedExportsMap] = getContentTypeExports( @@ -150,39 +168,47 @@ export const processContentTypes = async ( ); if ( allExports.length === 0 || - (Object.keys(updatedExportsMap).length > 0 && !(await promptToOverwriteExports(updatedExportsMap))) + (Object.keys(updatedExportsMap).length > 0 && !(force || (await promptToOverwriteExports(updatedExportsMap, log)))) ) { - nothingExportedExit(); + nothingExportedExit(log); } await ensureDirectoryExists(outputDir); - const tableStream = (createStream(streamTableOptions) as unknown) as TableStream; - tableStream.write([chalk.bold('File'), chalk.bold('Schema ID'), chalk.bold('Result')]); + const data: string[][] = []; + + data.push([chalk.bold('File'), chalk.bold('Schema ID'), chalk.bold('Result')]); for (const { filename, status, contentType } of allExports) { if (status !== 'UP-TO-DATE') { delete contentType.id; // do not export id writeJsonToFile(filename, contentType); } - tableStream.write([filename, contentType.contentTypeUri || '', status]); + data.push([filename, contentType.contentTypeUri || '', status]); } - process.stdout.write('\n'); + + log.appendLine(table(data, streamTableOptions)); }; export const handler = async (argv: Arguments): Promise => { - const { dir, schemaId } = argv; + const { dir, schemaId, logFile, force } = argv; const previouslyExportedContentTypes = loadJsonFromDirectory(dir, ContentType); validateNoDuplicateContentTypeUris(previouslyExportedContentTypes); const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); - const storedContentTypes = await paginator(hub.related.contentTypes.list, { status: 'ACTIVE' }); + const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; + const storedContentTypes = await paginator(hub.related.contentTypes.list, { status: Status.ACTIVE }); if (argv.archived) { - const archivedContentTypes = await paginator(hub.related.contentTypes.list, { status: 'ARCHIVED' }); + const archivedContentTypes = await paginator(hub.related.contentTypes.list, { status: Status.ARCHIVED }); Array.prototype.push.apply(storedContentTypes, archivedContentTypes); } const schemaIdArray: string[] = schemaId ? (Array.isArray(schemaId) ? schemaId : [schemaId]) : []; const filteredContentTypes = filterContentTypesByUri(storedContentTypes, schemaIdArray); - await processContentTypes(dir, previouslyExportedContentTypes, filteredContentTypes); + await processContentTypes(dir, previouslyExportedContentTypes, filteredContentTypes, log, force || false); + + if (typeof logFile !== 'object') { + // Only close the log if it was opened by this handler. + await log.close(); + } }; diff --git a/src/commands/content-type/import.spec.ts b/src/commands/content-type/import.spec.ts index e58b7645..8aface53 100644 --- a/src/commands/content-type/import.spec.ts +++ b/src/commands/content-type/import.spec.ts @@ -13,13 +13,16 @@ import { processContentTypes, storedContentTypeMapper, synchronizeContentTypeRepositories, - validateNoDuplicateContentTypeUris + validateNoDuplicateContentTypeUris, + LOG_FILENAME } from './import'; import Yargs from 'yargs/yargs'; -import { createStream } from 'table'; +import { table } from 'table'; +import { streamTableOptions } from '../../common/table/table.consts'; import { loadJsonFromDirectory, UpdateStatus } from '../../services/import.service'; import paginator from '../../common/dc-management-sdk-js/paginator'; import chalk from 'chalk'; +import { FileLog } from '../../common/file-log'; jest.mock('../../services/dynamic-content-client-factory'); jest.mock('../../view/data-presenter'); @@ -50,6 +53,7 @@ describe('content-type import command', (): void => { it('should configure yargs', () => { const argv = Yargs(process.argv.slice(2)); const spyPositional = jest.spyOn(argv, 'positional').mockReturnThis(); + const spyOption = jest.spyOn(argv, 'option').mockReturnThis(); builder(argv); @@ -57,6 +61,12 @@ describe('content-type import command', (): void => { describe: 'Path to Content Type definitions', type: 'string' }); + + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.' + }); }); }); @@ -99,29 +109,38 @@ describe('content-type import command', (): void => { describe('doCreate', () => { it('should create a content type and return report', async () => { const mockHub = new Hub(); + const log = new FileLog(); const newContentType = new ContentType({ id: 'created-id' }); const mockRegister = jest.fn().mockResolvedValue(newContentType); mockHub.related.contentTypes.register = mockRegister; const contentType = { contentTypeUri: 'content-type-uri', settings: { label: 'test-label' } }; - const result = await doCreate(mockHub, contentType as ContentType); + const result = await doCreate(mockHub, contentType as ContentType, log); + expect(log.getData('CREATE')).toMatchInlineSnapshot(` + Array [ + "undefined", + ] + `); expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining(contentType)); expect(result).toEqual(newContentType); }); it('should throw an error when content type create fails', async () => { const mockHub = new Hub(); + const log = new FileLog(); const mockRegister = jest.fn().mockImplementation(() => { throw new Error('Error creating content type'); }); mockHub.related.contentTypes.register = mockRegister; const contentType = { contentTypeUri: 'content-type-uri', settings: { label: 'test-label' } }; - await expect(doCreate(mockHub, contentType as ContentType)).rejects.toThrowErrorMatchingSnapshot(); + await expect(doCreate(mockHub, contentType as ContentType, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('UPDATE')).toEqual([]); }); it('should throw an error when content type create fails if a string error is returned by the sdk', async () => { const mockHub = new Hub(); + const log = new FileLog(); const mockRegister = jest .fn() .mockRejectedValue( @@ -130,7 +149,8 @@ describe('content-type import command', (): void => { mockHub.related.contentTypes.register = mockRegister; const contentType = { contentTypeUri: 'content-type-uri', settings: { label: 'test-label' } }; - await expect(doCreate(mockHub, contentType as ContentType)).rejects.toThrowErrorMatchingSnapshot(); + await expect(doCreate(mockHub, contentType as ContentType, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('UPDATE')).toEqual([]); }); }); @@ -257,11 +277,21 @@ describe('content-type import command', (): void => { const mockContentTypeSchemaUpdate = jest.fn().mockResolvedValue(new ContentTypeCachedSchema()); updatedContentType.related.contentTypeSchema.update = mockContentTypeSchemaUpdate; const client = mockDynamicContentClientFactory(); - const result = await doUpdate(client, { - ...mutatedContentType, - repositories: ['Slots'] - } as ContentTypeWithRepositoryAssignments); + const log = new FileLog(); + const result = await doUpdate( + client, + { + ...mutatedContentType, + repositories: ['Slots'] + } as ContentTypeWithRepositoryAssignments, + log + ); + expect(log.getData('UPDATE')).toMatchInlineSnapshot(` + Array [ + "stored-id", + ] + `); expect(result).toEqual({ contentType: updatedContentType, updateStatus: UpdateStatus.UPDATED }); expect(mockUpdate).toHaveBeenCalledWith({ ...expectedContentType.toJSON(), @@ -283,9 +313,11 @@ describe('content-type import command', (): void => { }); mockGet.mockResolvedValue(storedContentType); const client = mockDynamicContentClientFactory(); - const result = await doUpdate(client, mutatedContentType); + const log = new FileLog(); + const result = await doUpdate(client, mutatedContentType, log); expect(result).toEqual({ contentType: storedContentType, updateStatus: UpdateStatus.SKIPPED }); + expect(log.getData('UPDATE')).toEqual([]); }); it('should throw an error when unable to get content type during update', async () => { @@ -299,8 +331,10 @@ describe('content-type import command', (): void => { throw new Error('Error retrieving content type'); }); const client = mockDynamicContentClientFactory(); + const log = new FileLog(); - await expect(doUpdate(client, mutatedContentType)).rejects.toThrowErrorMatchingSnapshot(); + await expect(doUpdate(client, mutatedContentType, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('UPDATE')).toEqual([]); }); it('should throw an error when unable to update content type during update if a string error is returned by sdk', async () => { @@ -320,7 +354,9 @@ describe('content-type import command', (): void => { storedContentType.related.update = mockUpdate; mockGet.mockResolvedValue(storedContentType); const client = mockDynamicContentClientFactory(); - await expect(doUpdate(client, mutatedContentType)).rejects.toThrowErrorMatchingSnapshot(); + const log = new FileLog(); + await expect(doUpdate(client, mutatedContentType, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('UPDATE')).toEqual([]); expect(mockUpdate).toHaveBeenCalledWith(mutatedContentType); }); @@ -339,18 +375,19 @@ describe('content-type import command', (): void => { storedContentType.related.update = mockUpdate; mockGet.mockResolvedValue(storedContentType); const client = mockDynamicContentClientFactory(); - await expect(doUpdate(client, mutatedContentType)).rejects.toThrowErrorMatchingSnapshot(); + const log = new FileLog(); + await expect(doUpdate(client, mutatedContentType, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('UPDATE')).toEqual([]); expect(mockUpdate).toHaveBeenCalledWith(mutatedContentType); }); }); describe('processContentTypes', () => { - const mockStreamWrite = jest.fn(); + let mockTable: jest.Mock; beforeEach(() => { - (createStream as jest.Mock).mockReturnValue({ - write: mockStreamWrite - }); + mockTable = table as jest.Mock; + mockTable.mockImplementation(jest.requireActual('table').table); }); it('should create and update a content type', async () => { @@ -400,11 +437,11 @@ describe('content-type import command', (): void => { }; jest.spyOn(importModule, 'doUpdate').mockResolvedValueOnce(doUpdateResult2); - await processContentTypes(contentTypesToProcess, client, hub, false); + await processContentTypes(contentTypesToProcess, client, hub, false, new FileLog()); expect(paginator).toHaveBeenCalledTimes(1); - expect(importModule.doCreate).toHaveBeenCalledWith(hub, contentTypesToProcess[0]); - expect(importModule.doUpdate).toHaveBeenCalledWith(client, contentTypesToProcess[1]); + expect(importModule.doCreate).toHaveBeenCalledWith(hub, contentTypesToProcess[0], expect.any(FileLog)); + expect(importModule.doUpdate).toHaveBeenCalledWith(client, contentTypesToProcess[1], expect.any(FileLog)); expect(importModule.synchronizeContentTypeRepositories).toHaveBeenCalledTimes(3); const mappedReposByName = createContentRepositoriesMap(contentRepositories); expect(importModule.synchronizeContentTypeRepositories).toHaveBeenCalledTimes(3); @@ -427,27 +464,17 @@ describe('content-type import command', (): void => { expect.objectContaining({ ...contentTypesToProcess[2].toJSON(), repositories: ['Slots'] }), mappedReposByName ); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite).toHaveBeenNthCalledWith(1, [ - chalk.bold('ID'), - chalk.bold('Schema ID'), - chalk.bold('Result') - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(2, [ - createdContentType.id, - createdContentType.contentTypeUri, - 'CREATED' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(3, [ - doUpdateResult1.contentType.id, - doUpdateResult1.contentType.contentTypeUri, - 'UPDATED' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(4, [ - doUpdateResult2.contentType.id, - doUpdateResult2.contentType.contentTypeUri, - 'UP-TO-DATE' - ]); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable).toHaveBeenNthCalledWith( + 1, + [ + [chalk.bold('ID'), chalk.bold('Schema ID'), chalk.bold('Result')], + [createdContentType.id, createdContentType.contentTypeUri, 'CREATED'], + [doUpdateResult1.contentType.id, doUpdateResult1.contentType.contentTypeUri, 'UPDATED'], + [doUpdateResult2.contentType.id, doUpdateResult2.contentType.contentTypeUri, 'UP-TO-DATE'] + ], + streamTableOptions + ); }); }); @@ -841,7 +868,8 @@ describe('content-type import command', (): void => { Object.values(fileNamesAndContentTypesToImport), expect.any(Object), expect.any(Object), - false + false, + expect.any(Object) ); }); @@ -878,7 +906,8 @@ describe('content-type import command', (): void => { Object.values(fileNamesAndContentTypesToImport), expect.any(Object), expect.any(Object), - true + true, + expect.any(Object) ); }); diff --git a/src/commands/content-type/import.ts b/src/commands/content-type/import.ts index 0c5a0c02..2222ad7c 100644 --- a/src/commands/content-type/import.ts +++ b/src/commands/content-type/import.ts @@ -4,17 +4,22 @@ import dynamicContentClientFactory from '../../services/dynamic-content-client-f import paginator from '../../common/dc-management-sdk-js/paginator'; import { ContentRepository, ContentType, DynamicContent, Hub } from 'dc-management-sdk-js'; import { isEqual } from 'lodash'; -import { createStream } from 'table'; +import { table } from 'table'; import chalk from 'chalk'; import { ImportResult, loadJsonFromDirectory, UpdateStatus } from '../../services/import.service'; import { streamTableOptions } from '../../common/table/table.consts'; -import { TableStream } from '../../interfaces/table.interface'; import { ImportBuilderOptions } from '../../interfaces/import-builder-options.interface'; +import { FileLog } from '../../common/file-log'; +import { getDefaultLogPath } from '../../common/log-helpers'; +import { ResourceStatus, Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'import '; export const desc = 'Import Content Types'; +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('type', 'import', platform); + export type CommandParameters = { sync: boolean; }; @@ -30,6 +35,12 @@ export const builder = (yargs: Argv): void => { type: 'boolean', default: false }); + + yargs.option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.' + }); }; export class ContentTypeWithRepositoryAssignments extends ContentType { @@ -79,9 +90,28 @@ export const validateNoDuplicateContentTypeUris = (importedContentTypes: { } }; -export const doCreate = async (hub: Hub, contentType: ContentType): Promise => { +export const filterContentTypesById = ( + idFilter: string[], + importedContentTypes: { + [filename: string]: ContentType; + } +): void | never => { + for (const [filename, contentType] of Object.entries(importedContentTypes)) { + if (contentType.contentTypeUri) { + if (idFilter.indexOf(contentType.id as string) === -1) { + delete importedContentTypes[filename]; + } + } + } +}; + +export const doCreate = async (hub: Hub, contentType: ContentType, log: FileLog): Promise => { try { - return await hub.related.contentTypes.register(new ContentType(contentType)); + const result = await hub.related.contentTypes.register(new ContentType(contentType)); + + log.addAction('CREATE', `${contentType.id}`); + + return result; } catch (err) { throw new Error(`Error registering content type ${contentType.contentTypeUri}: ${err.message || err}`); } @@ -92,7 +122,8 @@ const equals = (a: ContentType, b: ContentType): boolean => export const doUpdate = async ( client: DynamicContent, - contentType: ContentTypeWithRepositoryAssignments + contentType: ContentTypeWithRepositoryAssignments, + log: FileLog ): Promise<{ contentType: ContentType; updateStatus: UpdateStatus }> => { let retrievedContentType: ContentType; try { @@ -102,6 +133,15 @@ export const doUpdate = async ( throw new Error(`Error unable to get content type ${contentType.id}: ${err.message}`); } + if ((retrievedContentType as ResourceStatus).status === Status.ARCHIVED) { + try { + // Resurrect this type before updating it. + retrievedContentType = await retrievedContentType.related.unarchive(); + } catch (err) { + throw new Error(`Error unable unarchive content type ${contentType.id}: ${err.message}`); + } + } + // Check if an update is required contentType.settings = { ...retrievedContentType.settings, ...contentType.settings }; @@ -114,6 +154,9 @@ export const doUpdate = async ( try { // Update the content-type updatedContentType = await retrievedContentType.related.update(contentType); + + log.addAction('UPDATE', `${contentType.id}`); + return { contentType: updatedContentType, updateStatus: UpdateStatus.UPDATED }; } catch (err) { throw new Error(`Error updating content type ${contentType.id}: ${err.message || err}`); @@ -196,22 +239,23 @@ export const processContentTypes = async ( contentTypes: ContentTypeWithRepositoryAssignments[], client: DynamicContent, hub: Hub, - sync: boolean + sync: boolean, + log: FileLog ): Promise => { - const tableStream = (createStream(streamTableOptions) as unknown) as TableStream; + const data: string[][] = []; const contentRepositoryList = await paginator(hub.related.contentRepositories.list, {}); const namedRepositories: MappedContentRepositories = new Map( contentRepositoryList.map(value => [value.name || '', value]) ); - tableStream.write([chalk.bold('ID'), chalk.bold('Schema ID'), chalk.bold('Result')]); + data.push([chalk.bold('ID'), chalk.bold('Schema ID'), chalk.bold('Result')]); for (const contentType of contentTypes) { let status: ImportResult; let contentTypeResult: ContentType; if (contentType.id) { status = 'UP-TO-DATE'; - const result = await doUpdate(client, contentType); + const result = await doUpdate(client, contentType, log); if (result.updateStatus === UpdateStatus.UPDATED) { status = 'UPDATED'; } @@ -224,7 +268,7 @@ export const processContentTypes = async ( } } } else { - contentTypeResult = await doCreate(hub, contentType); + contentTypeResult = await doCreate(hub, contentType, log); status = 'CREATED'; } @@ -238,15 +282,17 @@ export const processContentTypes = async ( status = contentType.id ? 'UPDATED' : 'CREATED'; } - tableStream.write([contentTypeResult.id || 'UNKNOWN', contentType.contentTypeUri || '', status]); + data.push([contentTypeResult.id || 'UNKNOWN', contentType.contentTypeUri || '', status]); } - process.stdout.write('\n'); + + log.appendLine(table(data, streamTableOptions)); }; export const handler = async ( - argv: Arguments + argv: Arguments, + idFilter?: string[] ): Promise => { - const { dir, sync } = argv; + const { dir, sync, logFile } = argv; const importedContentTypes = loadJsonFromDirectory( dir, ContentTypeWithRepositoryAssignments @@ -256,15 +302,25 @@ export const handler = async ( } validateNoDuplicateContentTypeUris(importedContentTypes); + if (idFilter) { + filterContentTypesById(idFilter, importedContentTypes); + } + const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); + const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; - const activeContentTypes = await paginator(hub.related.contentTypes.list, { status: 'ACTIVE' }); - const archivedContentTypes = await paginator(hub.related.contentTypes.list, { status: 'ARCHIVED' }); + const activeContentTypes = await paginator(hub.related.contentTypes.list, { status: Status.ACTIVE }); + const archivedContentTypes = await paginator(hub.related.contentTypes.list, { status: Status.ARCHIVED }); const storedContentTypes = [...activeContentTypes, ...archivedContentTypes]; for (const [filename, importedContentType] of Object.entries(importedContentTypes)) { importedContentTypes[filename] = storedContentTypeMapper(importedContentType, storedContentTypes); } - await processContentTypes(Object.values(importedContentTypes), client, hub, sync); + await processContentTypes(Object.values(importedContentTypes), client, hub, sync, log); + + if (typeof logFile !== 'object') { + // Only close the log if it was opened by this handler. + await log.close(); + } }; diff --git a/src/commands/content-type/unarchive.ts b/src/commands/content-type/unarchive.ts index d5b784b8..75865352 100644 --- a/src/commands/content-type/unarchive.ts +++ b/src/commands/content-type/unarchive.ts @@ -8,6 +8,7 @@ import paginator from '../../common/dc-management-sdk-js/paginator'; import { confirmArchive } from '../../common/archive/archive-helpers'; import UnarchiveOptions from '../../common/archive/unarchive-options'; import { getDefaultLogPath } from '../../common/log-helpers'; +import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const LOG_FILENAME = (platform: string = process.platform): string => getDefaultLogPath('type', 'unarchive', platform); @@ -82,7 +83,7 @@ export const handler = async (argv: Arguments ({ + ...jest.requireActual('../../common/log-helpers'), + getDefaultLogPath: jest.fn() +})); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function succeedOrFail(mock: any, succeed: boolean): void { + if (succeed) { + mock.mockResolvedValue(true); + } else { + mock.mockRejectedValue(false); + } +} + +describe('hub clone command', () => { + afterEach((): void => { + jest.restoreAllMocks(); + }); + + it('should command should defined', function() { + expect(command).toEqual('clone '); + }); + + it('should use getDefaultLogPath for LOG_FILENAME with process.platform as default', function() { + LOG_FILENAME(); + + expect(getDefaultLogPath).toHaveBeenCalledWith('hub', 'clone', process.platform); + }); + + it('should generate a default mapping path containing the given name', function() { + expect(getDefaultMappingPath('hub-1').indexOf('hub-1')).not.toEqual(-1); + }); + + describe('builder tests', function() { + it('should configure yargs', function() { + const argv = Yargs(process.argv.slice(2)); + const spyPositional = jest.spyOn(argv, 'positional').mockReturnThis(); + const spyOption = jest.spyOn(argv, 'option').mockReturnThis(); + + builder(argv); + + expect(spyPositional).toHaveBeenCalledWith('dir', { + describe: + 'Directory to export content to, then import from. This must be set to the previous directory for a revert.', + type: 'string' + }); + + expect(spyOption).toHaveBeenCalledWith('dstHubId', { + type: 'string', + describe: 'Destination hub ID. If not specified, it will be the same as the source.' + }); + + expect(spyOption).toHaveBeenCalledWith('dstClientId', { + type: 'string', + describe: "Destination account's client ID. If not specified, it will be the same as the source." + }); + + expect(spyOption).toHaveBeenCalledWith('dstSecret', { + type: 'string', + describe: "Destination account's secret. Must be used alongside dstClientId." + }); + + expect(spyOption).toHaveBeenCalledWith('mapFile', { + type: 'string', + describe: + 'Mapping file to use when updating content that already exists. Updated with any new mappings that are generated. If not present, will be created.' + }); + + expect(spyOption).toHaveBeenCalledWith('f', { + type: 'boolean', + boolean: true, + describe: + 'Overwrite content, create and assign content types, and ignore content with missing types/references without asking.' + }); + + expect(spyOption).toHaveBeenCalledWith('v', { + type: 'boolean', + boolean: true, + describe: 'Only recreate folder structure - content is validated but not imported.' + }); + + expect(spyOption).toHaveBeenCalledWith('skipIncomplete', { + type: 'boolean', + boolean: true, + describe: 'Skip any content item that has one or more missing dependancy.' + }); + + expect(spyOption).toHaveBeenCalledWith('copyConfig', { + type: 'string', + describe: + 'Path to a JSON configuration file for source/destination account. If the given file does not exist, it will be generated from the arguments.' + }); + + expect(spyOption).toHaveBeenCalledWith('lastPublish', { + type: 'boolean', + boolean: true, + describe: 'When available, export the last published version of a content item rather than its newest version.' + }); + + expect(spyOption).toHaveBeenCalledWith('publish', { + type: 'boolean', + boolean: true, + describe: 'Publish any content items that have an existing publish status in their JSON.' + }); + + expect(spyOption).toHaveBeenCalledWith('republish', { + type: 'boolean', + boolean: true, + describe: + 'Republish content items regardless of whether the import changed them or not. (--publish not required)' + }); + + expect(spyOption).toHaveBeenCalledWith('excludeKeys', { + type: 'boolean', + boolean: true, + describe: 'Exclude delivery keys when importing content items.' + }); + + expect(spyOption).toHaveBeenCalledWith('media', { + type: 'boolean', + boolean: true, + describe: + "Detect and rewrite media links to match assets in the target account's DAM. Your client must have DAM permissions configured." + }); + + expect(spyOption).toHaveBeenCalledWith('revertLog', { + type: 'string', + describe: + 'Revert a previous clone using a given revert log and given directory. Reverts steps in reverse order, starting at the specified one.' + }); + + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.' + }); + }); + }); + + describe('handler tests', function() { + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + beforeEach(async () => { + jest.mock('readline'); + jest.mock('../../services/dynamic-content-client-factory'); + }); + + beforeAll(async () => { + await rimraf('temp/clone/'); + }); + + afterAll(async () => { + await rimraf('temp/clone/'); + }); + + it('should call all steps in order with given parameters', async () => { + const copyCalls: Arguments[] = copierAny.calls; + copyCalls.splice(0, copyCalls.length); + + copierAny.setForceFail(false); + + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: 'temp/clone/steps', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + + mapFile: 'map.json', + force: false, + validate: false, + skipIncomplete: false, + media: true + }; + + const configImport = { + hubId: 'hub2-id', + clientId: 'acc2-id', + clientSecret: 'acc2-secret' + }; + + await ensureDirectoryExists('temp/clone/steps/settings'); + writeFileSync('temp/clone/steps/settings/hub-hub-id-test.json', '{}'); + + await handler(argv); + + expect(settingsExport.handler).toHaveBeenCalledWith({ + ...yargArgs, + ...config, + dir: 'temp/clone/steps/settings', + logFile: expect.any(FileLog), + force: false + }); + // Also backs up the destination settings. + expect(settingsExport.handler).toHaveBeenCalledWith({ + ...yargArgs, + ...configImport, + dir: 'temp/clone/steps/settings', + logFile: expect.any(FileLog), + force: false + }); + expect(settingsImport.handler).toHaveBeenCalledWith({ + ...yargArgs, + ...configImport, + filePath: 'temp/clone/steps/settings/hub-hub-id-test.json', + logFile: expect.any(FileLog), + mapFile: 'map.json', + force: false + }); + expect(schemaExport.handler).toHaveBeenCalledWith({ + ...yargArgs, + ...config, + dir: 'temp/clone/steps/schema', + logFile: expect.any(FileLog), + force: false + }); + expect(schemaImport.handler).toHaveBeenCalledWith({ + ...yargArgs, + ...configImport, + dir: 'temp/clone/steps/schema', + logFile: expect.any(FileLog) + }); + expect(typeExport.handler).toHaveBeenCalledWith({ + ...yargArgs, + ...config, + dir: 'temp/clone/steps/type', + logFile: expect.any(FileLog), + force: false + }); + expect(typeImport.handler).toHaveBeenCalledWith({ + ...yargArgs, + ...configImport, + dir: 'temp/clone/steps/type', + sync: true, + logFile: expect.any(FileLog) + }); + + expect(copyCalls.length).toEqual(1); + + expect(copyCalls[0].clientId).toEqual(config.clientId); + expect(copyCalls[0].clientSecret).toEqual(config.clientSecret); + expect(copyCalls[0].hubId).toEqual(config.hubId); + expect(copyCalls[0].schemaId).toEqual(argv.schemaId); + expect(copyCalls[0].name).toEqual(argv.name); + expect(copyCalls[0].srcRepo).toEqual(argv.srcRepo); + expect(copyCalls[0].dstRepo).toEqual(argv.dstRepo); + expect(copyCalls[0].dstHubId).toEqual(argv.dstHubId); + expect(copyCalls[0].dstSecret).toEqual(argv.dstSecret); + + expect(copyCalls[0].force).toEqual(argv.force); + expect(copyCalls[0].validate).toEqual(argv.validate); + expect(copyCalls[0].skipIncomplete).toEqual(argv.skipIncomplete); + expect(copyCalls[0].media).toEqual(argv.media); + }); + + it('should handle exceptions from each of the steps by stopping the process', async () => { + const copyCalls: Arguments[] = copierAny.calls; + + copierAny.setForceFail(false); + + for (let i = 1; i <= 7; i++) { + jest.resetAllMocks(); + copyCalls.splice(0, copyCalls.length); + + copierAny.setForceFail(i == 7); + + succeedOrFail(settingsExport.handler, i != 1); + succeedOrFail(settingsImport.handler, i != 2); + + succeedOrFail(schemaExport.handler, i != 3); + succeedOrFail(schemaImport.handler, i != 4); + + succeedOrFail(typeExport.handler, i != 5); + succeedOrFail(typeImport.handler, i != 6); + + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: 'temp/clone/stepExcept', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret' + }; + + await ensureDirectoryExists('temp/clone/stepExcept/settings'); + writeFileSync('temp/clone/stepExcept/settings/hub-hub-id-test.json', '{}'); + + await handler(argv); + + expect(settingsExport.handler).toHaveBeenCalledTimes(i == 1 ? 1 : 2); + expect(settingsImport.handler).toHaveBeenCalledTimes(i >= 2 ? 1 : 0); + + expect(schemaExport.handler).toHaveBeenCalledTimes(i >= 3 ? 1 : 0); + expect(schemaImport.handler).toHaveBeenCalledTimes(i >= 4 ? 1 : 0); + + expect(typeExport.handler).toHaveBeenCalledTimes(i == 5 ? 1 : i > 5 ? 2 : 0); + expect(typeImport.handler).toHaveBeenCalledTimes(i >= 6 ? 1 : 0); + + expect(copyCalls.length).toEqual(i >= 7 ? 1 : 0); + } + }); + + it('should start from the step given as a parameter', async () => { + const copyCalls: Arguments[] = copierAny.calls; + + succeedOrFail(settingsExport.handler, true); + succeedOrFail(settingsImport.handler, true); + + succeedOrFail(schemaExport.handler, true); + succeedOrFail(schemaImport.handler, true); + + succeedOrFail(typeExport.handler, true); + succeedOrFail(typeImport.handler, true); + + copierAny.setForceFail(false); + + for (let i = 1; i <= 4; i++) { + jest.resetAllMocks(); + copyCalls.splice(0, copyCalls.length); + + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: 'temp/clone/stepStart', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + + step: i + }; + + await ensureDirectoryExists('temp/clone/stepStart/settings'); + writeFileSync('temp/clone/stepStart/settings/hub-hub-id-test.json', '{}'); + + await handler(argv); + + expect(settingsExport.handler).toHaveBeenCalledTimes(i <= 1 ? 2 : 0); + expect(settingsImport.handler).toHaveBeenCalledTimes(i <= 1 ? 1 : 0); + + expect(schemaExport.handler).toHaveBeenCalledTimes(i <= 2 ? 1 : 0); + expect(schemaImport.handler).toHaveBeenCalledTimes(i <= 2 ? 1 : 0); + + expect(typeExport.handler).toHaveBeenCalledTimes(i <= 3 ? 2 : 0); + expect(typeImport.handler).toHaveBeenCalledTimes(i <= 3 ? 1 : 0); + + expect(copyCalls.length).toEqual(1); + } + }); + }); + + describe('revert tests', function() { + let mockContent: MockContent; + + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + beforeEach(async () => { + jest.resetAllMocks(); + jest.mock('readline'); + + mockContent = new MockContent(dynamicContentClientFactory as jest.Mock); + mockContent.createMockRepository('targetRepo'); + mockContent.registerContentType('http://type', 'type', 'targetRepo'); + mockContent.registerContentType('http://type2', 'type2', 'targetRepo'); + mockContent.registerContentType('http://type3', 'type3', 'targetRepo'); + }); + + beforeAll(async () => { + await rimraf('temp/clone-revert/'); + }); + + afterAll(async () => { + await rimraf('temp/clone-revert/'); + }); + + function expectTypeSchemaRevert(schemaArchived: boolean, typeArchived: boolean): void { + if (schemaArchived) { + expect(mockContent.metrics.typeSchemasArchived).toEqual(1); + expect(mockContent.metrics.typeSchemasUpdated).toEqual(1); + } else { + expect(mockContent.metrics.typeSchemasArchived).toEqual(0); + expect(mockContent.metrics.typeSchemasUpdated).toEqual(0); + } + + if (typeArchived) { + expect(mockContent.metrics.typesArchived).toEqual(1); + } else { + expect(mockContent.metrics.typesArchived).toEqual(0); + } + } + + async function prepareFakeLog(path: string): Promise { + const fakeLog = new FileLog(path + 'steps.log'); + fakeLog.switchGroup('Clone Content Types'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('UPDATE', 'type2 0 1'); + fakeLog.switchGroup('Clone Content Type Schema'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('UPDATE', 'type2 0 1'); + await fakeLog.close(); + } + + it('should call revert all steps in order with given parameters', async () => { + const copyCalls: Arguments[] = copierAny.calls; + copyCalls.splice(0, copyCalls.length); + + copierAny.setForceFail(false); + + await prepareFakeLog('temp/clone-revert/'); + + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: 'temp/clone-revert/steps', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + + mapFile: 'map.json', + force: false, + + revertLog: 'temp/clone-revert/steps.log' + }; + + const configImport = { + hubId: 'hub2-id', + clientId: 'acc2-id', + clientSecret: 'acc2-secret' + }; + + await ensureDirectoryExists('temp/clone-revert/steps/settings'); + writeFileSync('temp/clone-revert/steps/settings/hub-hub2-id-test.json', '{}'); + + await ensureDirectoryExists('temp/clone-revert/steps/oldType'); + + await handler(argv); + + expect(settingsImport.handler).toHaveBeenCalledWith({ + ...yargArgs, + ...configImport, + filePath: 'temp/clone-revert/steps/settings/hub-hub2-id-test.json', + logFile: expect.any(FileLog), + mapFile: 'map.json', + force: false + }); + + expect(typeImport.handler).toHaveBeenCalledWith( + { + ...yargArgs, + ...configImport, + dir: 'temp/clone-revert/steps/oldType', + sync: true, + logFile: expect.any(FileLog) + }, + ['type2'] + ); + + expectTypeSchemaRevert(true, true); + + expect(copyCalls.length).toEqual(1); + + expect(copyCalls[0].revertLog).toEqual(expect.any(FileLog)); + expect(copyCalls[0].clientId).toEqual(config.clientId); + expect(copyCalls[0].clientSecret).toEqual(config.clientSecret); + expect(copyCalls[0].hubId).toEqual(config.hubId); + expect(copyCalls[0].schemaId).toEqual(argv.schemaId); + expect(copyCalls[0].name).toEqual(argv.name); + expect(copyCalls[0].srcRepo).toEqual(argv.srcRepo); + expect(copyCalls[0].dstRepo).toEqual(argv.dstRepo); + expect(copyCalls[0].dstHubId).toEqual(argv.dstHubId); + expect(copyCalls[0].dstSecret).toEqual(argv.dstSecret); + + expect(copyCalls[0].force).toEqual(argv.force); + }); + + it('should handle exceptions from each of the revert steps by stopping the process', async () => { + const copyCalls: Arguments[] = copierAny.calls; + + copierAny.setForceFail(false); + + for (let i = 1; i <= 7; i++) { + jest.resetAllMocks(); + copyCalls.splice(0, copyCalls.length); + + copierAny.setForceFail(i == 7); + + succeedOrFail(settingsExport.handler, i != 1); + succeedOrFail(settingsImport.handler, i != 2); + + succeedOrFail(schemaExport.handler, i != 3); + succeedOrFail(schemaImport.handler, i != 4); + + succeedOrFail(typeExport.handler, i != 5); + succeedOrFail(typeImport.handler, i != 6); + + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: 'temp/clone/stepExcept', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret' + }; + + await ensureDirectoryExists('temp/clone/stepExcept/settings'); + writeFileSync('temp/clone/stepExcept/settings/hub-hub-id-test.json', '{}'); + + await handler(argv); + + expect(settingsExport.handler).toHaveBeenCalledTimes(i == 1 ? 1 : 2); + expect(settingsImport.handler).toHaveBeenCalledTimes(i >= 2 ? 1 : 0); + + expect(schemaExport.handler).toHaveBeenCalledTimes(i >= 3 ? 1 : 0); + expect(schemaImport.handler).toHaveBeenCalledTimes(i >= 4 ? 1 : 0); + + expect(typeExport.handler).toHaveBeenCalledTimes(i == 5 ? 1 : i > 5 ? 2 : 0); + expect(typeImport.handler).toHaveBeenCalledTimes(i >= 6 ? 1 : 0); + + expect(copyCalls.length).toEqual(i >= 7 ? 1 : 0); + } + }); + + it('should start reverting from the step given as a parameter (steps in decreasing order)', async () => {}); + }); +}); diff --git a/src/commands/hub/clone.ts b/src/commands/hub/clone.ts new file mode 100644 index 00000000..696b0019 --- /dev/null +++ b/src/commands/hub/clone.ts @@ -0,0 +1,257 @@ +import { getDefaultLogPath } from '../../common/log-helpers'; +import { Argv, Arguments } from 'yargs'; +import { join } from 'path'; +import { ConfigurationParameters } from '../configure'; +import rmdir from 'rimraf'; + +import { ensureDirectoryExists } from '../../common/import/directory-utils'; +import { FileLog } from '../../common/file-log'; +import { loadCopyConfig } from '../../common/content-item/copy-config'; +import { CloneHubBuilderOptions } from '../../interfaces/clone-hub-builder-options'; + +import { ContentCloneStep } from './steps/content-clone-step'; +import { SchemaCloneStep } from './steps/schema-clone-step'; +import { SettingsCloneStep } from './steps/settings-clone-step'; +import { TypeCloneStep } from './steps/type-clone-step'; +import { CloneHubState } from './model/clone-hub-state'; +import { revert } from '../content-item/import-revert'; + +export function getDefaultMappingPath(name: string, platform: string = process.platform): string { + return join( + process.env[platform == 'win32' ? 'USERPROFILE' : 'HOME'] || __dirname, + '.amplience', + `clone/`, + `${name}.json` + ); +} + +// Temp folder structure: +// hub-*/settings/ +// hub-*/extensions/ +// hub-*/schemas/ +// hub-*/types/ +// hub-*/content/ +// hub-*/events/ + +export const command = 'clone '; + +export const desc = + 'Clone an entire hub. The active account and hub are the source for the copy. Exported data from the source hub will be placed in the specified folder.'; + +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('hub', 'clone', platform); + +export const builder = (yargs: Argv): void => { + yargs + .positional('dir', { + describe: + 'Directory to export content to, then import from. This must be set to the previous directory for a revert.', + type: 'string' + }) + + .option('dstHubId', { + type: 'string', + describe: 'Destination hub ID. If not specified, it will be the same as the source.' + }) + + .option('dstClientId', { + type: 'string', + describe: "Destination account's client ID. If not specified, it will be the same as the source." + }) + + .option('dstSecret', { + type: 'string', + describe: "Destination account's secret. Must be used alongside dstClientId." + }) + + .option('mapFile', { + type: 'string', + describe: + 'Mapping file to use when updating content that already exists. Updated with any new mappings that are generated. If not present, will be created.' + }) + + .alias('f', 'force') + .option('f', { + type: 'boolean', + boolean: true, + describe: + 'Overwrite content, create and assign content types, and ignore content with missing types/references without asking.' + }) + + .alias('v', 'validate') + .option('v', { + type: 'boolean', + boolean: true, + describe: 'Only recreate folder structure - content is validated but not imported.' + }) + + .option('skipIncomplete', { + type: 'boolean', + boolean: true, + describe: 'Skip any content item that has one or more missing dependancy.' + }) + + .option('copyConfig', { + type: 'string', + describe: + 'Path to a JSON configuration file for source/destination account. If the given file does not exist, it will be generated from the arguments.' + }) + + .option('lastPublish', { + type: 'boolean', + boolean: true, + describe: 'When available, export the last published version of a content item rather than its newest version.' + }) + + .option('publish', { + type: 'boolean', + boolean: true, + describe: 'Publish any content items that have an existing publish status in their JSON.' + }) + + .option('republish', { + type: 'boolean', + boolean: true, + describe: 'Republish content items regardless of whether the import changed them or not. (--publish not required)' + }) + + .option('excludeKeys', { + type: 'boolean', + boolean: true, + describe: 'Exclude delivery keys when importing content items.' + }) + + .option('media', { + type: 'boolean', + boolean: true, + describe: + "Detect and rewrite media links to match assets in the target account's DAM. Your client must have DAM permissions configured." + }) + + .option('revertLog', { + type: 'string', + describe: + 'Revert a previous clone using a given revert log and given directory. Reverts steps in reverse order, starting at the specified one.' + }) + + .option('step', { + type: 'number', + describe: 'Start at a numbered step. 1: Settings, 2: Schema, 3: Type, 4: Content' + }) + + .option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.' + }); +}; + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +const steps = [new SettingsCloneStep(), new SchemaCloneStep(), new TypeCloneStep(), new ContentCloneStep()]; + +export const handler = async (argv: Arguments): Promise => { + const logFile = argv.logFile; + const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; + const tempFolder = argv.dir; + + if (argv.mapFile == null) { + argv.mapFile = getDefaultMappingPath(`hub-${argv.dstHubId}`); + } + + const copyConfig = typeof argv.copyConfig !== 'object' ? await loadCopyConfig(argv, log) : argv.copyConfig; + + if (copyConfig == null) { + return; + } + + const argvCore = { + $0: argv.$0, + _: argv._ + }; + + const state: CloneHubState = { + argv: argv, + from: { + clientId: copyConfig.srcClientId, + clientSecret: copyConfig.srcSecret, + hubId: copyConfig.srcHubId, + ...argvCore + }, + to: { + clientId: copyConfig.dstClientId, + clientSecret: copyConfig.dstSecret, + hubId: copyConfig.dstHubId, + ...argvCore + }, + path: tempFolder, + logFile: log + }; + + await ensureDirectoryExists(tempFolder); + + // Steps system: Each step performs another part of the clone command. + // If a step fails, we can return to that step on a future attempt. + + if (argv.revertLog) { + const revertLog = new FileLog(); + let loaded = false; + try { + await revertLog.loadFromFile(argv.revertLog); + loaded = true; + } catch (e) { + log.appendLine(`Could not open the revert log. Error: \n${e}`); + } + + if (loaded) { + state.revertLog = revertLog; + + for (let i = (argv.step || 1) - 1; i < steps.length; i++) { + const step = steps[i]; + + log.switchGroup(step.getName()); + revertLog.switchGroup(step.getName()); + log.appendLine(`=== Reverting Step ${i} - ${step.getName()} ===`); + + const success = await step.revert(state); + + if (!success) { + log.appendLine(`Reverting step ${i} (${step.getName()}) Failed. Terminating.`); + log.appendLine(''); + log.appendLine('To continue the revert from this point, use the option:'); + log.appendLine(`--step ${i}`); + + break; + } + } + } + } else { + for (let i = (argv.step || 1) - 1; i < steps.length; i++) { + const step = steps[i]; + + log.switchGroup(step.getName()); + log.appendLine(`=== Running Step ${i} - ${step.getName()} ===`); + + const success = await step.run(state); + + if (!success) { + log.appendLine(`Step ${i} (${step.getName()}) Failed. Terminating.`); + log.appendLine(''); + log.appendLine('To continue the clone from this point, use the option:'); + log.appendLine(`--step ${i}`); + + break; + } + } + } + + await rimraf(tempFolder); + + if (typeof logFile !== 'object') { + await log.close(); + } +}; diff --git a/src/commands/hub/model/clone-hub-state.ts b/src/commands/hub/model/clone-hub-state.ts new file mode 100644 index 00000000..2f89bac0 --- /dev/null +++ b/src/commands/hub/model/clone-hub-state.ts @@ -0,0 +1,14 @@ +import { Arguments } from 'yargs'; +import { FileLog } from '../../../common/file-log'; +import { CloneHubBuilderOptions } from '../../../interfaces/clone-hub-builder-options'; +import { ConfigurationParameters } from '../../configure'; + +export interface CloneHubState { + argv: Arguments; + from: Arguments; + to: Arguments; + path: string; + + logFile: FileLog; + revertLog?: FileLog; +} diff --git a/src/commands/hub/model/clone-hub-step.ts b/src/commands/hub/model/clone-hub-step.ts new file mode 100644 index 00000000..0667a948 --- /dev/null +++ b/src/commands/hub/model/clone-hub-step.ts @@ -0,0 +1,7 @@ +import { CloneHubState } from './clone-hub-state'; + +export interface CloneHubStep { + getName(): string; + run(state: CloneHubState): Promise; + revert(state: CloneHubState): Promise; +} diff --git a/src/commands/hub/steps/content-clone-step.ts b/src/commands/hub/steps/content-clone-step.ts new file mode 100644 index 00000000..310a1419 --- /dev/null +++ b/src/commands/hub/steps/content-clone-step.ts @@ -0,0 +1,33 @@ +import { CloneHubStep } from '../model/clone-hub-step'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; + +import { handler as copyContent } from '../../content-item/copy'; + +export class ContentCloneStep implements CloneHubStep { + getName(): string { + return 'Clone Content'; + } + + async run(state: CloneHubState): Promise { + const copySuccess = await copyContent({ + ...state.argv, + dir: join(state.path, 'content'), + logFile: state.logFile + }); + + return copySuccess; + } + + async revert(state: CloneHubState): Promise { + // Revert argument is passed as true to the clone command. + const revertSuccess = await copyContent({ + ...state.argv, + dir: join(state.path, 'content'), + logFile: state.logFile, + revertLog: state.revertLog + }); + + return revertSuccess; + } +} diff --git a/src/commands/hub/steps/schema-clone-step.ts b/src/commands/hub/steps/schema-clone-step.ts new file mode 100644 index 00000000..5fe8fc21 --- /dev/null +++ b/src/commands/hub/steps/schema-clone-step.ts @@ -0,0 +1,70 @@ +import { CloneHubStep } from '../model/clone-hub-step'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; + +import { handler as exportSchema } from '../../content-type-schema/export'; +import { handler as importSchema } from '../../content-type-schema/import'; +import dynamicContentClientFactory from '../../../services/dynamic-content-client-factory'; +import paginator from '../../../common/dc-management-sdk-js/paginator'; +import { FileLog } from '../../../common/file-log'; + +export class SchemaCloneStep implements CloneHubStep { + getName(): string { + return 'Clone Content Type Schema'; + } + + async run(state: CloneHubState): Promise { + try { + await exportSchema({ + dir: join(state.path, 'schema'), + force: state.argv.force, + logFile: state.logFile, + ...state.from + }); + } catch (e) { + return false; + } + + try { + await importSchema({ + dir: join(state.path, 'schema'), + logFile: state.logFile, + ...state.to + }); + } catch (e) { + return false; + } + + return true; + } + + async revert(state: CloneHubState): Promise { + const client = dynamicContentClientFactory(state.to); + const hub = await client.hubs.get(state.to.hubId); + + const types = await paginator(hub.related.contentTypes.list); + + const revertLog = state.revertLog as FileLog; + const toArchive = revertLog.getData('CREATE', this.getName()); + const toUpdate = revertLog.getData('UPDATE', this.getName()); + + for (let i = 0; i < toArchive.length; i++) { + const schema = await client.contentTypeSchemas.get(toArchive[i]); + await schema.related.archive(); + } + + for (let i = 0; i < toUpdate.length; i++) { + const updateArgs = toUpdate[i].split(' '); + + const schema = await client.contentTypeSchemas.getByVersion(updateArgs[0], Number(updateArgs[1])); + await schema.related.update(schema); + + const typeToSync = types.find(type => type.contentTypeUri === schema.schemaId); + if (typeToSync) { + typeToSync.related.contentTypeSchema.update(); + } + } + + return true; + } +} diff --git a/src/commands/hub/steps/settings-clone-step.ts b/src/commands/hub/steps/settings-clone-step.ts new file mode 100644 index 00000000..03ed60a5 --- /dev/null +++ b/src/commands/hub/steps/settings-clone-step.ts @@ -0,0 +1,87 @@ +import { CloneHubStep } from '../model/clone-hub-step'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; +import { readdirSync } from 'fs'; + +import { handler as exportSettings } from '../../settings/export'; +import { handler as importSettings } from '../../settings/import'; + +export class SettingsCloneStep implements CloneHubStep { + getName(): string { + return 'Clone Settings'; + } + + findItem(path: string, hubId: string): string | undefined { + const items = readdirSync(join(path, 'settings')); + return items.find(item => { + return /^hub\-.*\.json$/.test(item) && item.indexOf(hubId) != -1; + }); + } + + async run(state: CloneHubState): Promise { + try { + await exportSettings({ + dir: join(state.path, 'settings'), + logFile: state.logFile, + force: state.argv.force, + ...state.from + }); + } catch (e) { + return false; + } + + try { + try { + state.logFile.appendLine('Backing up destination settings.'); + await exportSettings({ + dir: join(state.path, 'settings'), + logFile: state.logFile, + force: state.argv.force, + ...state.to + }); + } catch (e) { + state.logFile.appendLine('Failed to back up destination settings. Continuing.'); + } + + const matchingFile = this.findItem(state.path, state.from.hubId); + if (matchingFile == null) { + state.logFile.appendLine('Error: Could not find exported settings file.'); + return false; + } + + await importSettings({ + filePath: join(state.path, 'settings', matchingFile), + mapFile: state.argv.mapFile, + force: state.argv.force, + logFile: state.logFile, + ...state.to + }); + } catch (e) { + return false; + } + + return true; + } + + async revert(state: CloneHubState): Promise { + try { + const matchingFile = this.findItem(state.path, state.to.hubId); + if (matchingFile == null) { + state.logFile.appendLine('Error: Could not find exported settings file.'); + return false; + } + + await importSettings({ + filePath: join(state.path, 'settings', matchingFile), + mapFile: state.argv.mapFile, + force: state.argv.force, + logFile: state.logFile, + ...state.to + }); + } catch (e) { + return false; + } + + return true; + } +} diff --git a/src/commands/hub/steps/type-clone-step.ts b/src/commands/hub/steps/type-clone-step.ts new file mode 100644 index 00000000..dfc0b627 --- /dev/null +++ b/src/commands/hub/steps/type-clone-step.ts @@ -0,0 +1,84 @@ +import { CloneHubStep } from '../model/clone-hub-step'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; + +import { handler as exportType } from '../../content-type/export'; +import { handler as importType } from '../../content-type/import'; +import dynamicContentClientFactory from '../../../services/dynamic-content-client-factory'; +import { FileLog } from '../../../common/file-log'; +import { existsSync } from 'fs'; + +export class TypeCloneStep implements CloneHubStep { + getName(): string { + return 'Clone Content Types'; + } + + async run(state: CloneHubState): Promise { + try { + await exportType({ + dir: join(state.path, 'oldType'), + force: state.argv.force, + logFile: state.logFile, + ...state.to + }); + } catch (e) { + return false; + } + + try { + await exportType({ + dir: join(state.path, 'type'), + force: state.argv.force, + logFile: state.logFile, + ...state.from + }); + } catch (e) { + return false; + } + + try { + await importType({ + dir: join(state.path, 'type'), + sync: true, + logFile: state.logFile, + ...state.to + }); + } catch (e) { + return false; + } + + return true; + } + + async revert(state: CloneHubState): Promise { + const client = dynamicContentClientFactory(state.to); + + const toArchive = (state.revertLog as FileLog).getData('CREATE', this.getName()); + const toUpdate = (state.revertLog as FileLog).getData('UPDATE', this.getName()); + + for (let i = 0; i < toArchive.length; i++) { + try { + const type = await client.contentTypes.get(toArchive[i]); + await type.related.archive(); + state.logFile.addAction('ARCHIVE', toArchive[i]); + } catch (e) { + state.logFile.appendLine(`Couldn't archive content type ${toArchive[i]}. Continuing.`); + } + } + + // Update using the oldType folder. + if (toUpdate.length > 0 && existsSync(join(state.path, 'oldType'))) { + await importType( + { + dir: join(state.path, 'oldType'), + sync: true, + logFile: state.logFile, + ...state.to + }, + toUpdate.map(item => item.split(' ')[0]) + ); + } + + return true; + } +} diff --git a/src/commands/settings/export.spec.ts b/src/commands/settings/export.spec.ts index 859476e5..b7e73a5d 100644 --- a/src/commands/settings/export.spec.ts +++ b/src/commands/settings/export.spec.ts @@ -27,6 +27,7 @@ describe('settings export command', (): void => { it('should configure yargs', () => { const argv = Yargs(process.argv.slice(2)); const spyPositional = jest.spyOn(argv, 'positional').mockReturnThis(); + const spyOption = jest.spyOn(argv, 'option').mockReturnThis(); builder(argv); @@ -34,6 +35,12 @@ describe('settings export command', (): void => { describe: 'Output directory for the exported Settings', type: 'string' }); + + expect(spyOption).toHaveBeenCalledWith('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite settings without asking.' + }); }); }); diff --git a/src/commands/settings/export.ts b/src/commands/settings/export.ts index 1402df58..7bb4469e 100644 --- a/src/commands/settings/export.ts +++ b/src/commands/settings/export.ts @@ -6,22 +6,32 @@ import { Hub, Settings, WorkflowState } from 'dc-management-sdk-js'; import { nothingExportedExit, promptToExportSettings, writeJsonToFile } from '../../services/export.service'; import { ExportBuilderOptions } from '../../interfaces/export-builder-options.interface'; import * as path from 'path'; +import { FileLog } from '../../common/file-log'; export const command = 'export '; export const desc = 'Export Hub Settings'; export const builder = (yargs: Argv): void => { - yargs.positional('dir', { - describe: 'Output directory for the exported Settings', - type: 'string' - }); + yargs + .positional('dir', { + describe: 'Output directory for the exported Settings', + type: 'string' + }) + .alias('f', 'force') + .option('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite settings without asking.' + }); }; export const processSettings = async ( outputDir: string, hubToExport: Hub, - workflowStates: WorkflowState[] + workflowStates: WorkflowState[], + log: FileLog, + force: boolean ): Promise => { const { id, name, label, settings = new Settings() } = hubToExport; let dir = outputDir; @@ -32,8 +42,8 @@ export const processSettings = async ( const uniqueFilename = dir + path.sep + file + '.json'; - if (!(await promptToExportSettings(uniqueFilename))) { - return nothingExportedExit(); + if (!(force || (await promptToExportSettings(uniqueFilename, log)))) { + return nothingExportedExit(log); } writeJsonToFile(uniqueFilename, { @@ -48,15 +58,21 @@ export const processSettings = async ( workflowStates: workflowStates }); - process.stdout.write('Settings exported successfully! \n'); + log.appendLine('Settings exported successfully!'); }; export const handler = async (argv: Arguments): Promise => { - const { dir } = argv; + const { dir, logFile, force } = argv; const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); + const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; const workflowStates = await paginator(hub.related.workflowStates.list); - await processSettings(dir, hub, workflowStates); + await processSettings(dir, hub, workflowStates, log, force || false); + + if (typeof logFile !== 'object') { + // Only close the log if it was opened by this handler. + await log.close(); + } }; diff --git a/src/commands/settings/import.ts b/src/commands/settings/import.ts index 8da15417..1ea98ffc 100644 --- a/src/commands/settings/import.ts +++ b/src/commands/settings/import.ts @@ -5,8 +5,7 @@ import dynamicContentClientFactory from '../../services/dynamic-content-client-f import { ImportSettingsBuilderOptions } from '../../interfaces/import-settings-builder-options.interface'; import { WorkflowStatesMapping } from '../../common/workflowStates/workflowStates-mapping'; import { FileLog } from '../../common/file-log'; -import { getDefaultLogPath } from '../../common/log-helpers'; -import { asyncQuestion } from '../../common/archive/archive-helpers'; +import { getDefaultLogPath, asyncQuestion } from '../../common/log-helpers'; import { join } from 'path'; import { readFile } from 'fs'; import { promisify } from 'util'; @@ -125,7 +124,8 @@ export const handler = async ( if (alreadyExists.length > 0) { const question = !force ? await asyncQuestion( - `${alreadyExists.length} of the workflow states being imported already exist in the mapping. Would you like to update these workflow states instead of skipping them? (y/n) ` + `${alreadyExists.length} of the workflow states being imported already exist in the mapping. Would you like to update these workflow states instead of skipping them? (y/n) `, + log ) : answer; @@ -169,7 +169,8 @@ export const handler = async ( await trySaveMapping(mapFile, mapping, log); - if (log) { + if (typeof logFile !== 'object') { + // Only close the log if it was opened by this handler. await log.close(); } diff --git a/src/common/archive/archive-helpers.ts b/src/common/archive/archive-helpers.ts index 64374904..00d5c429 100644 --- a/src/common/archive/archive-helpers.ts +++ b/src/common/archive/archive-helpers.ts @@ -1,10 +1,4 @@ -import readline, { ReadLine } from 'readline'; - -function asyncQuestionInternal(rl: ReadLine, question: string): Promise { - return new Promise((resolve): void => { - rl.question(question, resolve); - }); -} +import { asyncQuestion } from '../log-helpers'; export async function confirmArchive( action: string, @@ -12,32 +6,11 @@ export async function confirmArchive( allContent: boolean, missingContent: boolean ): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false - }); - const question = allContent ? `Providing no ID or filter will ${action} ALL ${type}! Are you sure you want to do this? (y/n)\n` : missingContent ? 'Warning: Some content specified on the log is missing. Are you sure you want to continue? (y/n)\n' : `Are you sure you want to ${action} these ${type}? (y/n)\n`; - const answer: string = await asyncQuestionInternal(rl, question); - rl.close(); - return answer.length > 0 && answer[0].toLowerCase() == 'y'; -} - -export async function asyncQuestion(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false - }); - - const answer = await asyncQuestionInternal(rl, question); - - rl.close(); - return answer.length > 0 && answer[0].toLowerCase() === 'y'; + return await asyncQuestion(question); } diff --git a/src/common/archive/archive-options.ts b/src/common/archive/archive-options.ts index 9d3eb46b..e4857ed2 100644 --- a/src/common/archive/archive-options.ts +++ b/src/common/archive/archive-options.ts @@ -1,7 +1,7 @@ import { FileLog } from '../file-log'; export default interface ArchiveOptions { - id?: string; + id?: string | string[]; schemaId?: string | string[]; revertLog?: string; repoId?: string | string[]; diff --git a/src/common/dc-management-sdk-js/mock-content.ts b/src/common/dc-management-sdk-js/mock-content.ts index e4cd8a44..d3a16f74 100644 --- a/src/common/dc-management-sdk-js/mock-content.ts +++ b/src/common/dc-management-sdk-js/mock-content.ts @@ -13,6 +13,7 @@ import { ContentTypeCachedSchema } from 'dc-management-sdk-js'; import MockPage from './mock-page'; +import { ResourceStatus, Status as TypeStatus } from './resource-status'; export interface ItemTemplate { label: string; @@ -49,7 +50,11 @@ export class MockContentMetrics { itemsVersionGet = 0; foldersCreated = 0; typesCreated = 0; + typesArchived = 0; + typesSynced = 0; typeSchemasCreated = 0; + typeSchemasUpdated = 0; + typeSchemasArchived = 0; reset(): void { this.itemsCreated = 0; @@ -60,7 +65,11 @@ export class MockContentMetrics { this.itemsVersionGet = 0; this.foldersCreated = 0; this.typesCreated = 0; + this.typesArchived = 0; + this.typesSynced = 0; this.typeSchemasCreated = 0; + this.typeSchemasUpdated = 0; + this.typeSchemasArchived = 0; } } @@ -83,6 +92,8 @@ export class MockContent { failItemActions: null | 'all' | 'not-version' = null; failFolderActions: null | 'list' | 'parent' | 'items' = null; failRepoActions: null | 'list' | 'create' = null; + failTypeActions: null | 'all' = null; + failSchemaActions: null | 'all' = null; failHubGet: boolean; failRepoList: boolean; @@ -109,6 +120,14 @@ export class MockContent { const mockTypeSchemaGet = jest.fn(id => Promise.resolve(this.typeSchemaById.get(id) as ContentTypeSchema)); + const mockTypeSchemaGetVersion = jest.fn((id, version) => { + const schema = this.typeSchemaById.get(id) as ContentTypeSchema; + + schema.version = version; + + return Promise.resolve(schema); + }); + const mockItemGet = jest.fn(id => { const result = this.items.find(item => item.id === id); if (result == null) { @@ -132,7 +151,8 @@ export class MockContent { get: mockTypeGet }, contentTypeSchemas: { - get: mockTypeSchemaGet + get: mockTypeSchemaGet, + getByVersion: mockTypeSchemaGetVersion }, contentItems: { get: mockItemGet @@ -364,17 +384,62 @@ export class MockContent { schemaOnly?: boolean ): void { if (!this.typeSchemaById.has(id)) { - const schema = new ContentTypeSchema({ id: id, schemaId: schemaName, body: JSON.stringify(body) }); + const schema = new ContentTypeSchema({ + id: id, + schemaId: schemaName, + body: JSON.stringify(body), + status: 'ACTIVE' + }); this.typeSchemaById.set(id, schema); + + const mockSchemaArchive = jest.fn(); + schema.related.archive = mockSchemaArchive; + + const mockSchemaUpdate = jest.fn(); + schema.related.update = mockSchemaUpdate; + + mockSchemaArchive.mockImplementation(() => { + if (this.failSchemaActions) throw new Error('Simulated network failure.'); + if ((schema as ResourceStatus).status != TypeStatus.ACTIVE) { + throw new Error('Cannot archive content that is already archived.'); + } + + this.metrics.typeSchemasArchived++; + + (schema as ResourceStatus).status = TypeStatus.ARCHIVED; + + return Promise.resolve(schema); + }); + + mockSchemaUpdate.mockImplementation(newSchema => { + if (this.failSchemaActions) throw new Error('Simulated network failure.'); + this.metrics.typeSchemasUpdated++; + + schema.body = newSchema.body; + schema.version = (schema.version as number) + 1; + + return Promise.resolve(schema); + }); } if (!schemaOnly) { - const type = new ContentType({ id: id, contentTypeUri: schemaName, settings: { label: basename(schemaName) } }); + const type = new ContentType({ + id: id, + contentTypeUri: schemaName, + settings: { label: basename(schemaName) }, + status: 'ACTIVE' + }); this.typeById.set(id, type); const mockCached = jest.fn(); type.related.contentTypeSchema.get = mockCached; + const mockCachedUpdate = jest.fn(); + type.related.contentTypeSchema.update = mockCachedUpdate; + + const mockTypeArchive = jest.fn(); + type.related.archive = mockTypeArchive; + mockCached.mockImplementation(() => { const cached = new ContentTypeCachedSchema({ contentTypeUri: schemaName, @@ -384,6 +449,30 @@ export class MockContent { return Promise.resolve(cached); }); + mockCachedUpdate.mockImplementation(() => { + const cached = new ContentTypeCachedSchema({ + contentTypeUri: schemaName, + cachedSchema: { ...body, $id: schemaName } + }); + + this.metrics.typesSynced; + + return Promise.resolve(cached); + }); + + mockTypeArchive.mockImplementation(() => { + if (this.failTypeActions) throw new Error('Simulated network failure.'); + if ((type as ResourceStatus).status != TypeStatus.ACTIVE) { + throw new Error('Cannot archive content that is already archived.'); + } + + this.metrics.typesArchived++; + + (type as ResourceStatus).status = TypeStatus.ARCHIVED; + + return Promise.resolve(type); + }); + const repoArray = typeof repos === 'string' ? [repos] : repos; repoArray.forEach(repoName => { const typeAssignments = this.typeAssignmentsByRepoId.get(repoName) || []; diff --git a/src/common/dc-management-sdk-js/paginator.ts b/src/common/dc-management-sdk-js/paginator.ts index f1a9db3e..ed8a043b 100644 --- a/src/common/dc-management-sdk-js/paginator.ts +++ b/src/common/dc-management-sdk-js/paginator.ts @@ -1,14 +1,11 @@ import { HalResource, Page, Pageable, Sortable } from 'dc-management-sdk-js'; +import { ResourceStatus } from './resource-status'; export const DEFAULT_SIZE = 100; -interface StatusQuery { - status?: 'ARCHIVED' | 'ACTIVE' | 'DELETED'; -} - const paginator = async ( - pagableFn: (options?: Pageable & Sortable & StatusQuery) => Promise>, - options: Pageable & Sortable & StatusQuery = {} + pagableFn: (options?: Pageable & Sortable & ResourceStatus) => Promise>, + options: Pageable & Sortable & ResourceStatus = {} ): Promise => { const currentPage = await pagableFn({ ...options, size: DEFAULT_SIZE }); if ( diff --git a/src/common/dc-management-sdk-js/resource-status.ts b/src/common/dc-management-sdk-js/resource-status.ts new file mode 100644 index 00000000..e209d09e --- /dev/null +++ b/src/common/dc-management-sdk-js/resource-status.ts @@ -0,0 +1,9 @@ +export enum Status { + ACTIVE = 'ACTIVE', + ARCHIVED = 'ARCHIVED', + DELETED = 'DELETED' +} + +export interface ResourceStatus { + status?: Status; +} diff --git a/src/common/file-log.ts b/src/common/file-log.ts index a93f0e91..a209203c 100644 --- a/src/common/file-log.ts +++ b/src/common/file-log.ts @@ -13,8 +13,10 @@ export class FileLog extends ArchiveLog { } } - public appendLine(text?: string): void { - console.log(text); + public appendLine(text = 'undefined', silent = false): void { + if (!silent) { + process.stdout.write(text + '\n'); + } this.addComment(text as string); } diff --git a/src/common/log-helpers.ts b/src/common/log-helpers.ts index 971fc4e1..f01cf481 100644 --- a/src/common/log-helpers.ts +++ b/src/common/log-helpers.ts @@ -1,4 +1,5 @@ import { join } from 'path'; +import readline, { ReadLine } from 'readline'; import { FileLog } from './file-log'; export function getDefaultLogPath(type: string, action: string, platform: string = process.platform): string { @@ -20,3 +21,25 @@ export function createLog(logFile: string, title?: string): FileLog { return log; } + +function asyncQuestionInternal(rl: ReadLine, question: string): Promise { + return new Promise((resolve): void => { + rl.question(question, resolve); + }); +} + +export async function asyncQuestion(question: string, log?: FileLog): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + + const answer = await asyncQuestionInternal(rl, question); + rl.close(); + + if (log != null) { + log.appendLine(question + answer, true); + } + return answer.length > 0 && answer[0].toLowerCase() === 'y'; +} diff --git a/src/interfaces/clone-hub-builder-options.ts b/src/interfaces/clone-hub-builder-options.ts new file mode 100644 index 00000000..803f91c6 --- /dev/null +++ b/src/interfaces/clone-hub-builder-options.ts @@ -0,0 +1,27 @@ +import { CopyConfig } from '../common/content-item/copy-config'; +import { FileLog } from '../common/file-log'; + +export interface CloneHubBuilderOptions { + dir: string; + + dstHubId?: string; + dstClientId?: string; + dstSecret?: string; + + revertLog?: string; + step?: number; + + mapFile?: string; + force?: boolean; + validate?: boolean; + skipIncomplete?: boolean; + media?: boolean; + logFile?: string | FileLog; + copyConfig?: string | CopyConfig; + + lastPublish?: boolean; + publish?: boolean; + republish?: boolean; + + excludeKeys?: boolean; +} diff --git a/src/interfaces/copy-item-builder-options.interface.ts b/src/interfaces/copy-item-builder-options.interface.ts index 6cbcb975..7de4d004 100644 --- a/src/interfaces/copy-item-builder-options.interface.ts +++ b/src/interfaces/copy-item-builder-options.interface.ts @@ -23,7 +23,7 @@ export interface CopyItemBuilderOptions { logFile: FileLog; copyConfig?: string | CopyConfig; - revertLog?: string; + revertLog?: string | FileLog; lastPublish?: boolean; publish?: boolean; diff --git a/src/interfaces/export-builder-options.interface.ts b/src/interfaces/export-builder-options.interface.ts index c32adb75..6bba7698 100644 --- a/src/interfaces/export-builder-options.interface.ts +++ b/src/interfaces/export-builder-options.interface.ts @@ -1,5 +1,9 @@ +import { FileLog } from '../common/file-log'; + export interface ExportBuilderOptions { dir: string; schemaId?: string[]; archived?: boolean; + logFile?: string | FileLog; + force?: boolean; } diff --git a/src/interfaces/import-builder-options.interface.ts b/src/interfaces/import-builder-options.interface.ts index 1b736e94..48b9e0bf 100644 --- a/src/interfaces/import-builder-options.interface.ts +++ b/src/interfaces/import-builder-options.interface.ts @@ -1,3 +1,6 @@ +import { FileLog } from '../common/file-log'; + export interface ImportBuilderOptions { dir: string; + logFile?: string | FileLog; } diff --git a/src/interfaces/import-item-builder-options.interface.ts b/src/interfaces/import-item-builder-options.interface.ts index afbd49dc..8e3c1fca 100644 --- a/src/interfaces/import-item-builder-options.interface.ts +++ b/src/interfaces/import-item-builder-options.interface.ts @@ -14,5 +14,5 @@ export interface ImportItemBuilderOptions { media?: boolean; logFile?: FileLog; - revertLog?: string; + revertLog?: string | FileLog; } diff --git a/src/interfaces/import-settings-builder-options.interface.ts b/src/interfaces/import-settings-builder-options.interface.ts index 8101c4b4..6f9fceb9 100644 --- a/src/interfaces/import-settings-builder-options.interface.ts +++ b/src/interfaces/import-settings-builder-options.interface.ts @@ -1,6 +1,8 @@ +import { FileLog } from '../common/file-log'; + export interface ImportSettingsBuilderOptions { filePath: string; mapFile?: string; - logFile?: string; + logFile?: string | FileLog; force?: boolean; } diff --git a/src/services/export.service.spec.ts b/src/services/export.service.spec.ts index 53ab5437..398908b4 100644 --- a/src/services/export.service.spec.ts +++ b/src/services/export.service.spec.ts @@ -4,6 +4,7 @@ import { uniqueFilename } from './export.service'; import { ContentType } from 'dc-management-sdk-js'; import * as readline from 'readline'; import { table } from 'table'; +import { FileLog } from '../common/file-log'; const mockQuestion = jest.fn(); const mockClose = jest.fn(); @@ -95,7 +96,7 @@ describe('export service tests', () => { }); const updatedExportsMap = [{ filename: 'my-export-filename', schemaId: 'my-content-type-uri' }]; - const res = await promptToOverwriteExports(updatedExportsMap); + const res = await promptToOverwriteExports(updatedExportsMap, new FileLog()); expect(res).toBeTruthy(); expect(createInterfaceSpy).toHaveBeenCalledTimes(1); @@ -112,7 +113,7 @@ describe('export service tests', () => { }); const updatedExportsMap = [{ filename: 'my-export-filename', schemaId: 'my-content-type-uri' }]; - const res = await promptToOverwriteExports(updatedExportsMap); + const res = await promptToOverwriteExports(updatedExportsMap, new FileLog()); expect(res).toBeFalsy(); expect(createInterfaceSpy).toHaveBeenCalledTimes(1); @@ -129,7 +130,7 @@ describe('export service tests', () => { }); const updatedExportsMap = [{ filename: 'my-export-filename', schemaId: 'my-content-type-uri' }]; - const res = await promptToOverwriteExports(updatedExportsMap); + const res = await promptToOverwriteExports(updatedExportsMap, new FileLog()); expect(res).toBeFalsy(); expect(createInterfaceSpy).toHaveBeenCalledTimes(1); @@ -152,7 +153,7 @@ describe('export service tests', () => { throw exitError; }); - expect(nothingExportedExit).toThrowError(exitError); + expect(() => nothingExportedExit(new FileLog())).toThrowError(exitError); expect(writeSpy.mock.calls).toMatchSnapshot(); }); }); diff --git a/src/services/export.service.ts b/src/services/export.service.ts index 943cc3cd..85ff69ea 100644 --- a/src/services/export.service.ts +++ b/src/services/export.service.ts @@ -2,7 +2,8 @@ import fs from 'fs'; import * as path from 'path'; import { URL } from 'url'; import DataPresenter from '../view/data-presenter'; -import readline from 'readline'; +import { asyncQuestion } from '../common/log-helpers'; +import { FileLog } from '../common/file-log'; export type ExportResult = 'CREATED' | 'UPDATED' | 'UP-TO-DATE'; @@ -38,43 +39,26 @@ export const writeJsonToFile = (filename: string, resource: T): vo } }; -export const promptToOverwriteExports = (updatedExportsMap: { [key: string]: string }[]): Promise => { - return new Promise((resolve): void => { - process.stdout.write('The following files will be overwritten:\n'); - // display updatedExportsMap as a table of uri x filename - const itemMapFn = ({ filename, schemaId }: { filename: string; schemaId: string }): object => ({ - File: filename, - 'Schema ID': schemaId - }); - new DataPresenter(updatedExportsMap).render({ itemMapFn }); - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); - - rl.question('Do you want to continue (y/n)?: ', answer => { - rl.close(); - return resolve(answer === 'y'); - }); +export const promptToOverwriteExports = ( + updatedExportsMap: { [key: string]: string }[], + log: FileLog +): Promise => { + log.appendLine('The following files will be overwritten:'); + // display updatedExportsMap as a table of uri x filename + const itemMapFn = ({ filename, schemaId }: { filename: string; schemaId: string }): object => ({ + File: filename, + 'Schema ID': schemaId }); -}; + new DataPresenter(updatedExportsMap).render({ itemMapFn, printFn: log.appendLine.bind(log) }); -export const promptToExportSettings = (filename: string): Promise => { - return new Promise((resolve): void => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); + return asyncQuestion('Do you want to continue (y/n)?: ', log); +}; - rl.question(`Do you want to export setting to ${filename} (y/n)?: `, answer => { - rl.close(); - return resolve(answer === 'y'); - }); - }); +export const promptToExportSettings = (filename: string, log: FileLog): Promise => { + return asyncQuestion(`Do you want to export setting to ${filename} (y/n)?: `, log); }; -export const nothingExportedExit = (msg = 'Nothing was exported, exiting.\n'): void => { - process.stdout.write(msg); +export const nothingExportedExit = (log: FileLog, msg = 'Nothing was exported, exiting.'): void => { + log.appendLine(msg); process.exit(1); }; diff --git a/src/view/data-presenter.ts b/src/view/data-presenter.ts index 266dac15..f8a365cd 100644 --- a/src/view/data-presenter.ts +++ b/src/view/data-presenter.ts @@ -16,11 +16,13 @@ export const RenderingOptions: CommandOptions = { }; type MapFn = (data: object) => object; +type PrintFn = (message: string) => void; interface RenderOptions { json?: boolean; tableUserConfig?: TableUserConfig; itemMapFn?: MapFn; + printFn?: PrintFn; } export default class DataPresenter { @@ -53,8 +55,13 @@ export default class DataPresenter { output = Array.isArray(this.data) ? this.generateHorizontalTable(this.data.map(itemMapFn), renderOptions.tableUserConfig) : this.generateVerticalTable(itemMapFn(this.data), renderOptions.tableUserConfig); - output += '\n'; } - process.stdout.write(output); + + if (renderOptions.printFn) { + renderOptions.printFn(output); + } else { + if (!renderOptions.json) output += '\n'; + process.stdout.write(output); + } } } From 2226c015988bd1dd735fb4245db8bdb92be3e051 Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 9 Mar 2021 09:59:57 +0000 Subject: [PATCH 02/15] fix(hub): improve error handling, avoid deleting work folder --- src/commands/hub/clone.spec.ts | 52 ++++++++++--------- src/commands/hub/clone.ts | 2 - src/commands/hub/steps/schema-clone-step.ts | 27 +++++++--- src/commands/hub/steps/settings-clone-step.ts | 3 ++ src/commands/hub/steps/type-clone-step.ts | 28 ++++++---- 5 files changed, 68 insertions(+), 44 deletions(-) diff --git a/src/commands/hub/clone.spec.ts b/src/commands/hub/clone.spec.ts index cc25192d..39adf62e 100644 --- a/src/commands/hub/clone.spec.ts +++ b/src/commands/hub/clone.spec.ts @@ -421,7 +421,7 @@ describe('hub clone command', () => { hubId: 'hub-id' }; - beforeEach(async () => { + function reset(): void { jest.resetAllMocks(); jest.mock('readline'); @@ -430,6 +430,10 @@ describe('hub clone command', () => { mockContent.registerContentType('http://type', 'type', 'targetRepo'); mockContent.registerContentType('http://type2', 'type2', 'targetRepo'); mockContent.registerContentType('http://type3', 'type3', 'targetRepo'); + } + + beforeEach(async () => { + reset(); }); beforeAll(async () => { @@ -457,7 +461,7 @@ describe('hub clone command', () => { } async function prepareFakeLog(path: string): Promise { - const fakeLog = new FileLog(path + 'steps.log'); + const fakeLog = new FileLog(path); fakeLog.switchGroup('Clone Content Types'); fakeLog.addAction('CREATE', 'type'); fakeLog.addAction('UPDATE', 'type2 0 1'); @@ -473,7 +477,7 @@ describe('hub clone command', () => { copierAny.setForceFail(false); - await prepareFakeLog('temp/clone-revert/'); + await prepareFakeLog('temp/clone-revert/steps.log'); const argv: Arguments = { ...yargArgs, @@ -547,47 +551,45 @@ describe('hub clone command', () => { copierAny.setForceFail(false); - for (let i = 1; i <= 7; i++) { - jest.resetAllMocks(); - copyCalls.splice(0, copyCalls.length); + await ensureDirectoryExists('temp/clone-revert/stepExcept/settings'); + await prepareFakeLog('temp/clone-revert/stepExcept.log'); + writeFileSync('temp/clone-revert/stepExcept/settings/hub-hub2-id-test.json', '{}'); - copierAny.setForceFail(i == 7); + await ensureDirectoryExists('temp/clone-revert/steps/oldType'); - succeedOrFail(settingsExport.handler, i != 1); - succeedOrFail(settingsImport.handler, i != 2); + for (let i = 1; i <= 5; i++) { + reset(); + copyCalls.splice(0, copyCalls.length); - succeedOrFail(schemaExport.handler, i != 3); - succeedOrFail(schemaImport.handler, i != 4); - - succeedOrFail(typeExport.handler, i != 5); - succeedOrFail(typeImport.handler, i != 6); + succeedOrFail(settingsImport.handler, i != 1); + mockContent.failSchemaActions = i == 2 ? 'all' : null; + mockContent.failTypeActions = i == 3 || i == 2 ? 'all' : null; + succeedOrFail(typeImport.handler, i != 4); + copierAny.setForceFail(i == 5); const argv: Arguments = { ...yargArgs, ...config, - dir: 'temp/clone/stepExcept', + dir: 'temp/clone-revert/stepExcept', + revertLog: 'temp/clone-revert/stepExcept.log', dstHubId: 'hub2-id', dstClientId: 'acc2-id', dstSecret: 'acc2-secret' }; - await ensureDirectoryExists('temp/clone/stepExcept/settings'); - writeFileSync('temp/clone/stepExcept/settings/hub-hub-id-test.json', '{}'); - await handler(argv); - expect(settingsExport.handler).toHaveBeenCalledTimes(i == 1 ? 1 : 2); - expect(settingsImport.handler).toHaveBeenCalledTimes(i >= 2 ? 1 : 0); + expect(settingsImport.handler).toHaveBeenCalledTimes(i >= 1 ? 1 : 0); - expect(schemaExport.handler).toHaveBeenCalledTimes(i >= 3 ? 1 : 0); - expect(schemaImport.handler).toHaveBeenCalledTimes(i >= 4 ? 1 : 0); + process.stdout.write(i.toString()); - expect(typeExport.handler).toHaveBeenCalledTimes(i == 5 ? 1 : i > 5 ? 2 : 0); - expect(typeImport.handler).toHaveBeenCalledTimes(i >= 6 ? 1 : 0); + expectTypeSchemaRevert(i > 2, i > 3); - expect(copyCalls.length).toEqual(i >= 7 ? 1 : 0); + expect(typeImport.handler).toHaveBeenCalledTimes(i >= 4 ? 1 : 0); + + expect(copyCalls.length).toEqual(i >= 5 ? 1 : 0); } }); diff --git a/src/commands/hub/clone.ts b/src/commands/hub/clone.ts index 696b0019..1284e6b5 100644 --- a/src/commands/hub/clone.ts +++ b/src/commands/hub/clone.ts @@ -249,8 +249,6 @@ export const handler = async (argv: Arguments type.contentTypeUri === schema.schemaId); - if (typeToSync) { - typeToSync.related.contentTypeSchema.update(); + const typeToSync = types.find(type => type.contentTypeUri === schema.schemaId); + if (typeToSync) { + typeToSync.related.contentTypeSchema.update(); + } + } catch (e) { + state.logFile.appendLine(`Error while updating ${toUpdate[i]}. Continuing...`); } } diff --git a/src/commands/hub/steps/settings-clone-step.ts b/src/commands/hub/steps/settings-clone-step.ts index 03ed60a5..d1075f85 100644 --- a/src/commands/hub/steps/settings-clone-step.ts +++ b/src/commands/hub/steps/settings-clone-step.ts @@ -27,6 +27,7 @@ export class SettingsCloneStep implements CloneHubStep { ...state.from }); } catch (e) { + state.logFile.appendLine(`ERROR: Could not export settings. \n${e}`); return false; } @@ -57,6 +58,7 @@ export class SettingsCloneStep implements CloneHubStep { ...state.to }); } catch (e) { + state.logFile.appendLine(`ERROR: Could not import settings. \n${e}`); return false; } @@ -79,6 +81,7 @@ export class SettingsCloneStep implements CloneHubStep { ...state.to }); } catch (e) { + state.logFile.appendLine(`ERROR: Could not import old settings. \n${e}`); return false; } diff --git a/src/commands/hub/steps/type-clone-step.ts b/src/commands/hub/steps/type-clone-step.ts index dfc0b627..333d6f7c 100644 --- a/src/commands/hub/steps/type-clone-step.ts +++ b/src/commands/hub/steps/type-clone-step.ts @@ -22,6 +22,7 @@ export class TypeCloneStep implements CloneHubStep { ...state.to }); } catch (e) { + state.logFile.appendLine(`ERROR: Could not export existing destination types. \n${e}`); return false; } @@ -33,6 +34,7 @@ export class TypeCloneStep implements CloneHubStep { ...state.from }); } catch (e) { + state.logFile.appendLine(`ERROR: Could not export types. \n${e}`); return false; } @@ -44,6 +46,7 @@ export class TypeCloneStep implements CloneHubStep { ...state.to }); } catch (e) { + state.logFile.appendLine(`ERROR: Could not import types. \n${e}`); return false; } @@ -62,21 +65,26 @@ export class TypeCloneStep implements CloneHubStep { await type.related.archive(); state.logFile.addAction('ARCHIVE', toArchive[i]); } catch (e) { - state.logFile.appendLine(`Couldn't archive content type ${toArchive[i]}. Continuing.`); + state.logFile.appendLine(`Couldn't archive content type ${toArchive[i]}. Continuing...`); } } // Update using the oldType folder. if (toUpdate.length > 0 && existsSync(join(state.path, 'oldType'))) { - await importType( - { - dir: join(state.path, 'oldType'), - sync: true, - logFile: state.logFile, - ...state.to - }, - toUpdate.map(item => item.split(' ')[0]) - ); + try { + await importType( + { + dir: join(state.path, 'oldType'), + sync: true, + logFile: state.logFile, + ...state.to + }, + toUpdate.map(item => item.split(' ')[0]) + ); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not import old types. \n${e}`); + return false; + } } return true; From df5c25ebe12e3d004aa8127c066e08b374e35460 Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 9 Mar 2021 10:40:29 +0000 Subject: [PATCH 03/15] fix(hub): ensure that settings export dir exists --- src/commands/hub/steps/settings-clone-step.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/commands/hub/steps/settings-clone-step.ts b/src/commands/hub/steps/settings-clone-step.ts index d1075f85..cce63526 100644 --- a/src/commands/hub/steps/settings-clone-step.ts +++ b/src/commands/hub/steps/settings-clone-step.ts @@ -5,6 +5,7 @@ import { readdirSync } from 'fs'; import { handler as exportSettings } from '../../settings/export'; import { handler as importSettings } from '../../settings/import'; +import { ensureDirectoryExists } from '../../../common/import/directory-utils'; export class SettingsCloneStep implements CloneHubStep { getName(): string { @@ -20,6 +21,7 @@ export class SettingsCloneStep implements CloneHubStep { async run(state: CloneHubState): Promise { try { + await ensureDirectoryExists(join(state.path, 'settings')); await exportSettings({ dir: join(state.path, 'settings'), logFile: state.logFile, From 96f2241daad128e75a6aaaa3e11310ecc7bf00bc Mon Sep 17 00:00:00 2001 From: Rhys Date: Thu, 11 Mar 2021 11:24:21 +0000 Subject: [PATCH 04/15] fix(export): return from export handler instead of process exit on failure --- .../content-type-schema/export.spec.ts | 30 +++++-------------- src/commands/content-type-schema/export.ts | 2 ++ src/commands/content-type/export.spec.ts | 24 +++++---------- src/commands/content-type/export.ts | 2 ++ src/services/export.service.spec.ts | 7 +---- src/services/export.service.ts | 1 - 6 files changed, 21 insertions(+), 45 deletions(-) diff --git a/src/commands/content-type-schema/export.spec.ts b/src/commands/content-type-schema/export.spec.ts index 03ab6de0..b93e74a0 100644 --- a/src/commands/content-type-schema/export.spec.ts +++ b/src/commands/content-type-schema/export.spec.ts @@ -295,10 +295,6 @@ describe('content-type-schema export command', (): void => { validationLevel: ValidationLevel.CONTENT_TYPE }); - const exitError = new Error('ERROR TO VALIDATE PROCESS EXIT'); - jest.spyOn(process, 'exit').mockImplementation(() => { - throw exitError; - }); const stdoutSpy = jest.spyOn(process.stdout, 'write'); stdoutSpy.mockImplementation(); @@ -333,15 +329,13 @@ describe('content-type-schema export command', (): void => { 'export-dir/export-filename-3.json': contentTypeSchemasToProcess[2] }; - await expect( - processContentTypeSchemas( - 'export-dir', - previouslyExportedContentTypeSchemas, - mutatedContentTypeSchemas, - new FileLog(), - false - ) - ).rejects.toThrowError(exitError); + await processContentTypeSchemas( + 'export-dir', + previouslyExportedContentTypeSchemas, + mutatedContentTypeSchemas, + new FileLog(), + false + ); expect(stdoutSpy.mock.calls).toMatchSnapshot(); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledTimes(1); @@ -355,20 +349,13 @@ describe('content-type-schema export command', (): void => { expect(mockWriteJsonToFile).toHaveBeenCalledTimes(0); expect(mockWriteSchemaBody).toHaveBeenCalledTimes(0); expect(mockTable).toHaveBeenCalledTimes(0); - expect(process.exit).toHaveBeenCalled(); }); it('should not do anything if the list of schemas to export is empty', async () => { - const exitError = new Error('ERROR TO VALIDATE PROCESS EXIT'); - jest.spyOn(process, 'exit').mockImplementation(() => { - throw exitError; - }); const stdoutSpy = jest.spyOn(process.stdout, 'write'); stdoutSpy.mockImplementation(); - await expect(processContentTypeSchemas('export-dir', {}, [], new FileLog(), false)).rejects.toThrowError( - exitError - ); + expect(processContentTypeSchemas('export-dir', {}, [], new FileLog(), false)); expect(stdoutSpy.mock.calls).toMatchSnapshot(); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledTimes(0); @@ -377,7 +364,6 @@ describe('content-type-schema export command', (): void => { expect(mockWriteJsonToFile).toHaveBeenCalledTimes(0); expect(mockWriteSchemaBody).toHaveBeenCalledTimes(0); expect(mockTable).toHaveBeenCalledTimes(0); - expect(process.exit).toHaveBeenCalled(); }); }); diff --git a/src/commands/content-type-schema/export.ts b/src/commands/content-type-schema/export.ts index c11d2268..0f943c7b 100644 --- a/src/commands/content-type-schema/export.ts +++ b/src/commands/content-type-schema/export.ts @@ -221,6 +221,7 @@ export const processContentTypeSchemas = async ( ): Promise => { if (storedContentTypeSchemas.length === 0) { nothingExportedExit(log, 'No content type schemas to export from this hub, exiting.'); + return; } const [allExports, updatedExportsMap] = getContentTypeSchemaExports( @@ -233,6 +234,7 @@ export const processContentTypeSchemas = async ( (Object.keys(updatedExportsMap).length > 0 && !(force || (await promptToOverwriteExports(updatedExportsMap, log)))) ) { nothingExportedExit(log); + return; } await ensureDirectoryExists(outputDir); diff --git a/src/commands/content-type/export.spec.ts b/src/commands/content-type/export.spec.ts index 5a73feb1..9ac1b60c 100644 --- a/src/commands/content-type/export.spec.ts +++ b/src/commands/content-type/export.spec.ts @@ -449,16 +449,9 @@ describe('content-type export command', (): void => { it('should output a message if no content types to export from hub', async () => { jest.spyOn(exportModule, 'getContentTypeExports').mockReturnValueOnce([[], []]); - const exitError = new Error('ERROR TO VALIDATE PROCESS EXIT'); - jest.spyOn(process, 'exit').mockImplementation(() => { - throw exitError; - }); - const previouslyExportedContentTypes = {}; - await expect( - processContentTypes('export-dir', previouslyExportedContentTypes, [], new FileLog(), false) - ).rejects.toThrowError(exitError); + await processContentTypes('export-dir', previouslyExportedContentTypes, [], new FileLog(), false); expect(mockEnsureDirectory).toHaveBeenCalledTimes(0); expect(exportModule.getContentTypeExports).toHaveBeenCalledTimes(0); @@ -602,10 +595,6 @@ describe('content-type export command', (): void => { settings: { label: 'content type 2 - mutated label' } }); - const exitError = new Error('ERROR TO VALIDATE PROCESS EXIT'); - jest.spyOn(process, 'exit').mockImplementation(() => { - throw exitError; - }); jest.spyOn(exportServiceModule, 'promptToOverwriteExports').mockResolvedValueOnce(false); jest.spyOn(exportModule, 'getContentTypeExports').mockReturnValueOnce([ [ @@ -637,9 +626,13 @@ describe('content-type export command', (): void => { 'export-dir/export-filename-2.json': new ContentType(exportedContentTypes[1]) }; - await expect( - processContentTypes('export-dir', previouslyExportedContentTypes, mutatedContentTypes, new FileLog(), false) - ).rejects.toThrowError(exitError); + await processContentTypes( + 'export-dir', + previouslyExportedContentTypes, + mutatedContentTypes, + new FileLog(), + false + ); expect(exportModule.getContentTypeExports).toHaveBeenCalledTimes(1); expect(exportModule.getContentTypeExports).toHaveBeenCalledWith( @@ -651,7 +644,6 @@ describe('content-type export command', (): void => { expect(mockEnsureDirectory).toHaveBeenCalledTimes(0); expect(exportServiceModule.writeJsonToFile).toHaveBeenCalledTimes(0); expect(mockTable).toHaveBeenCalledTimes(0); - expect(process.exit).toHaveBeenCalled(); }); }); diff --git a/src/commands/content-type/export.ts b/src/commands/content-type/export.ts index 71719081..4b225c6c 100644 --- a/src/commands/content-type/export.ts +++ b/src/commands/content-type/export.ts @@ -159,6 +159,7 @@ export const processContentTypes = async ( ): Promise => { if (contentTypesBeingExported.length === 0) { nothingExportedExit(log, 'No content types to export from this hub, exiting.'); + return; } const [allExports, updatedExportsMap] = getContentTypeExports( @@ -171,6 +172,7 @@ export const processContentTypes = async ( (Object.keys(updatedExportsMap).length > 0 && !(force || (await promptToOverwriteExports(updatedExportsMap, log)))) ) { nothingExportedExit(log); + return; } await ensureDirectoryExists(outputDir); diff --git a/src/services/export.service.spec.ts b/src/services/export.service.spec.ts index 398908b4..7b88b980 100644 --- a/src/services/export.service.spec.ts +++ b/src/services/export.service.spec.ts @@ -145,15 +145,10 @@ describe('export service tests', () => { describe('nothingExportedExit', () => { it('should exit with an export message', () => { const writeSpy = jest.spyOn(process.stdout, 'write'); - const exitSpy = jest.spyOn(process, 'exit'); - const exitError = new Error('PROCESS EXIT INVOKED FOR TEST'); writeSpy.mockImplementation(); - exitSpy.mockImplementation(() => { - throw exitError; - }); - expect(() => nothingExportedExit(new FileLog())).toThrowError(exitError); + nothingExportedExit(new FileLog()); expect(writeSpy.mock.calls).toMatchSnapshot(); }); }); diff --git a/src/services/export.service.ts b/src/services/export.service.ts index 85ff69ea..c712e6e9 100644 --- a/src/services/export.service.ts +++ b/src/services/export.service.ts @@ -60,5 +60,4 @@ export const promptToExportSettings = (filename: string, log: FileLog): Promise< export const nothingExportedExit = (log: FileLog, msg = 'Nothing was exported, exiting.'): void => { log.appendLine(msg); - process.exit(1); }; From ea8e4e79da7b24aba4cabe67693f5ddfe093c67a Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 16 Mar 2021 12:14:18 +0000 Subject: [PATCH 05/15] test(hub): update tests for hub clone to have test files for each step --- src/commands/hub/clone.spec.ts | 543 +++++++++--------- src/commands/hub/clone.ts | 12 +- .../hub/steps/content-clone-step.spec.ts | 150 +++++ .../hub/steps/schema-clone-step.spec.ts | 210 +++++++ src/commands/hub/steps/schema-clone-step.ts | 6 +- .../hub/steps/settings-clone-step.spec.ts | 218 +++++++ .../hub/steps/type-clone-step.spec.ts | 238 ++++++++ .../dc-management-sdk-js/mock-content.ts | 2 +- 8 files changed, 1107 insertions(+), 272 deletions(-) create mode 100644 src/commands/hub/steps/content-clone-step.spec.ts create mode 100644 src/commands/hub/steps/schema-clone-step.spec.ts create mode 100644 src/commands/hub/steps/settings-clone-step.spec.ts create mode 100644 src/commands/hub/steps/type-clone-step.spec.ts diff --git a/src/commands/hub/clone.spec.ts b/src/commands/hub/clone.spec.ts index 39adf62e..0cae92d9 100644 --- a/src/commands/hub/clone.spec.ts +++ b/src/commands/hub/clone.spec.ts @@ -12,6 +12,11 @@ import * as typeImport from '../content-type/import'; import * as typeExport from '../content-type/export'; import * as copier from '../content-item/copy'; +import * as content from './steps/content-clone-step'; +import * as settings from './steps/settings-clone-step'; +import * as schema from './steps/schema-clone-step'; +import * as type from './steps/type-clone-step'; + import rmdir from 'rimraf'; import { CloneHubBuilderOptions } from '../../interfaces/clone-hub-builder-options'; import { ConfigurationParameters } from '../configure'; @@ -20,17 +25,44 @@ import { CopyItemBuilderOptions } from '../../interfaces/copy-item-builder-optio import { FileLog } from '../../common/file-log'; import { MockContent } from '../../common/dc-management-sdk-js/mock-content'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; +import { CloneHubState } from './model/clone-hub-state'; jest.mock('readline'); jest.mock('../../services/dynamic-content-client-factory'); -jest.mock('../settings/import'); -jest.mock('../settings/export'); -jest.mock('../content-type-schema/import'); -jest.mock('../content-type-schema/export'); -jest.mock('../content-type/import'); -jest.mock('../content-type/export'); -jest.mock('../content-item/copy'); + +let success = [true, true, true, true]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function succeedOrFail(mock: any, succeed: () => boolean): jest.Mock { + mock.mockImplementation(() => Promise.resolve(succeed())); + /* + mock.mockImplementation(() => { + if (succeed()) { + return Promise.resolve(true); + } else { + return Promise.reject(false); + } + }); + */ + return mock; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mockStep(name: string, success: () => boolean): any { + return jest.fn().mockImplementation(() => ({ + run: succeedOrFail(jest.fn(), success), + revert: succeedOrFail(jest.fn(), success), + getName: jest.fn().mockReturnValue(name) + })); +} + +jest.mock('./steps/settings-clone-step', () => ({ SettingsCloneStep: mockStep('Clone Settings', () => success[0]) })); +jest.mock('./steps/schema-clone-step', () => ({ + SchemaCloneStep: mockStep('Clone Content Type Schemas', () => success[1]) +})); +jest.mock('./steps/type-clone-step', () => ({ TypeCloneStep: mockStep('Clone Content Types', () => success[2]) })); +jest.mock('./steps/content-clone-step', () => ({ ContentCloneStep: mockStep('Clone Content', () => success[3]) })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const copierAny = copier as any; @@ -46,13 +78,25 @@ function rimraf(dir: string): Promise { }); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function succeedOrFail(mock: any, succeed: boolean): void { - if (succeed) { - mock.mockResolvedValue(true); - } else { - mock.mockRejectedValue(false); - } +function getMocks(): jest.Mock[] { + return [ + settings.SettingsCloneStep as jest.Mock, + schema.SchemaCloneStep as jest.Mock, + type.TypeCloneStep as jest.Mock, + content.ContentCloneStep as jest.Mock + ]; +} + +function clearMocks(): void { + const mocks = getMocks(); + + mocks.forEach(mock => { + mock.mock.results.forEach(obj => { + const instance = obj.value; + (instance.run as jest.Mock).mockClear(); + (instance.revert as jest.Mock).mockClear(); + }); + }); } describe('hub clone command', () => { @@ -192,11 +236,6 @@ describe('hub clone command', () => { hubId: 'hub-id' }; - beforeEach(async () => { - jest.mock('readline'); - jest.mock('../../services/dynamic-content-client-factory'); - }); - beforeAll(async () => { await rimraf('temp/clone/'); }); @@ -205,11 +244,29 @@ describe('hub clone command', () => { await rimraf('temp/clone/'); }); - it('should call all steps in order with given parameters', async () => { - const copyCalls: Arguments[] = copierAny.calls; - copyCalls.splice(0, copyCalls.length); + function makeState(argv: Arguments): CloneHubState { + return { + argv: argv, + from: { + clientId: argv.clientId as string, + clientSecret: argv.clientSecret as string, + hubId: argv.hubId as string, + ...yargArgs + }, + to: { + clientId: argv.dstClientId as string, + clientSecret: argv.dstSecret as string, + hubId: argv.dstHubId as string, + ...yargArgs + }, + path: argv.dir, + logFile: expect.any(FileLog) + }; + } - copierAny.setForceFail(false); + it('should call all steps in order with given parameters', async () => { + clearMocks(); + success = [true, true, true, true]; const argv: Arguments = { ...yargArgs, @@ -220,196 +277,124 @@ describe('hub clone command', () => { dstHubId: 'hub2-id', dstClientId: 'acc2-id', dstSecret: 'acc2-secret', + logFile: 'temp/clone/steps/all.log', - mapFile: 'map.json', force: false, validate: false, skipIncomplete: false, media: true }; - const configImport = { - hubId: 'hub2-id', - clientId: 'acc2-id', - clientSecret: 'acc2-secret' - }; - - await ensureDirectoryExists('temp/clone/steps/settings'); - writeFileSync('temp/clone/steps/settings/hub-hub-id-test.json', '{}'); + const stepConfig = makeState(argv); await handler(argv); - expect(settingsExport.handler).toHaveBeenCalledWith({ - ...yargArgs, - ...config, - dir: 'temp/clone/steps/settings', - logFile: expect.any(FileLog), - force: false - }); - // Also backs up the destination settings. - expect(settingsExport.handler).toHaveBeenCalledWith({ - ...yargArgs, - ...configImport, - dir: 'temp/clone/steps/settings', - logFile: expect.any(FileLog), - force: false - }); - expect(settingsImport.handler).toHaveBeenCalledWith({ - ...yargArgs, - ...configImport, - filePath: 'temp/clone/steps/settings/hub-hub-id-test.json', - logFile: expect.any(FileLog), - mapFile: 'map.json', - force: false - }); - expect(schemaExport.handler).toHaveBeenCalledWith({ - ...yargArgs, - ...config, - dir: 'temp/clone/steps/schema', - logFile: expect.any(FileLog), - force: false - }); - expect(schemaImport.handler).toHaveBeenCalledWith({ - ...yargArgs, - ...configImport, - dir: 'temp/clone/steps/schema', - logFile: expect.any(FileLog) - }); - expect(typeExport.handler).toHaveBeenCalledWith({ - ...yargArgs, - ...config, - dir: 'temp/clone/steps/type', - logFile: expect.any(FileLog), - force: false - }); - expect(typeImport.handler).toHaveBeenCalledWith({ - ...yargArgs, - ...configImport, - dir: 'temp/clone/steps/type', - sync: true, - logFile: expect.any(FileLog) - }); - - expect(copyCalls.length).toEqual(1); - - expect(copyCalls[0].clientId).toEqual(config.clientId); - expect(copyCalls[0].clientSecret).toEqual(config.clientSecret); - expect(copyCalls[0].hubId).toEqual(config.hubId); - expect(copyCalls[0].schemaId).toEqual(argv.schemaId); - expect(copyCalls[0].name).toEqual(argv.name); - expect(copyCalls[0].srcRepo).toEqual(argv.srcRepo); - expect(copyCalls[0].dstRepo).toEqual(argv.dstRepo); - expect(copyCalls[0].dstHubId).toEqual(argv.dstHubId); - expect(copyCalls[0].dstSecret).toEqual(argv.dstSecret); - - expect(copyCalls[0].force).toEqual(argv.force); - expect(copyCalls[0].validate).toEqual(argv.validate); - expect(copyCalls[0].skipIncomplete).toEqual(argv.skipIncomplete); - expect(copyCalls[0].media).toEqual(argv.media); - }); - - it('should handle exceptions from each of the steps by stopping the process', async () => { - const copyCalls: Arguments[] = copierAny.calls; - - copierAny.setForceFail(false); + stepConfig.argv.mapFile = expect.any(String); - for (let i = 1; i <= 7; i++) { - jest.resetAllMocks(); - copyCalls.splice(0, copyCalls.length); + const mocks = getMocks(); - copierAny.setForceFail(i == 7); + mocks.forEach(mock => { + const instance = mock.mock.results[0].value; - succeedOrFail(settingsExport.handler, i != 1); - succeedOrFail(settingsImport.handler, i != 2); + expect(instance.run).toHaveBeenCalledWith(stepConfig); + }); - succeedOrFail(schemaExport.handler, i != 3); - succeedOrFail(schemaImport.handler, i != 4); + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone/steps/all.log'); + }); - succeedOrFail(typeExport.handler, i != 5); - succeedOrFail(typeImport.handler, i != 6); + it('should handle false returns from each of the steps by stopping the process', async () => { + for (let i = 0; i < 4; i++) { + clearMocks(); + success = [i != 0, i != 1, i != 2, i != 3]; const argv: Arguments = { ...yargArgs, ...config, - dir: 'temp/clone/stepExcept', + dir: 'temp/clone/steps', dstHubId: 'hub2-id', dstClientId: 'acc2-id', - dstSecret: 'acc2-secret' + dstSecret: 'acc2-secret', + logFile: 'temp/clone/steps/fail' + i + '.log', + + mapFile: 'temp/clone/steps/fail' + i + '.json', + force: false, + validate: false, + skipIncomplete: false, + media: true }; - await ensureDirectoryExists('temp/clone/stepExcept/settings'); - writeFileSync('temp/clone/stepExcept/settings/hub-hub-id-test.json', '{}'); + const stepConfig = makeState(argv); await handler(argv); - expect(settingsExport.handler).toHaveBeenCalledTimes(i == 1 ? 1 : 2); - expect(settingsImport.handler).toHaveBeenCalledTimes(i >= 2 ? 1 : 0); + const mocks = getMocks(); - expect(schemaExport.handler).toHaveBeenCalledTimes(i >= 3 ? 1 : 0); - expect(schemaImport.handler).toHaveBeenCalledTimes(i >= 4 ? 1 : 0); + mocks.forEach((mock, index) => { + const instance = mock.mock.results[0].value; - expect(typeExport.handler).toHaveBeenCalledTimes(i == 5 ? 1 : i > 5 ? 2 : 0); - expect(typeImport.handler).toHaveBeenCalledTimes(i >= 6 ? 1 : 0); + if (index > i) { + expect(instance.run).not.toHaveBeenCalled(); + } else { + expect(instance.run).toHaveBeenCalledWith(stepConfig); + } + }); - expect(copyCalls.length).toEqual(i >= 7 ? 1 : 0); + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone/steps/fail' + i + '.log'); } }); it('should start from the step given as a parameter', async () => { - const copyCalls: Arguments[] = copierAny.calls; - - succeedOrFail(settingsExport.handler, true); - succeedOrFail(settingsImport.handler, true); - - succeedOrFail(schemaExport.handler, true); - succeedOrFail(schemaImport.handler, true); - - succeedOrFail(typeExport.handler, true); - succeedOrFail(typeImport.handler, true); - - copierAny.setForceFail(false); - - for (let i = 1; i <= 4; i++) { - jest.resetAllMocks(); - copyCalls.splice(0, copyCalls.length); + for (let i = 0; i < 4; i++) { + clearMocks(); + success = [true, true, true, true]; const argv: Arguments = { ...yargArgs, ...config, - dir: 'temp/clone/stepStart', + step: i, + + dir: 'temp/clone/steps', dstHubId: 'hub2-id', dstClientId: 'acc2-id', dstSecret: 'acc2-secret', + logFile: 'temp/clone/steps/step' + i + '.log', - step: i + mapFile: 'temp/clone/steps/step' + i + '.json', + force: false, + validate: false, + skipIncomplete: false, + media: true }; - await ensureDirectoryExists('temp/clone/stepStart/settings'); - writeFileSync('temp/clone/stepStart/settings/hub-hub-id-test.json', '{}'); + const stepConfig = makeState(argv); await handler(argv); - expect(settingsExport.handler).toHaveBeenCalledTimes(i <= 1 ? 2 : 0); - expect(settingsImport.handler).toHaveBeenCalledTimes(i <= 1 ? 1 : 0); + const mocks = getMocks(); - expect(schemaExport.handler).toHaveBeenCalledTimes(i <= 2 ? 1 : 0); - expect(schemaImport.handler).toHaveBeenCalledTimes(i <= 2 ? 1 : 0); + mocks.forEach((mock, index) => { + const instance = mock.mock.results[0].value; - expect(typeExport.handler).toHaveBeenCalledTimes(i <= 3 ? 2 : 0); - expect(typeImport.handler).toHaveBeenCalledTimes(i <= 3 ? 1 : 0); + if (index < i) { + expect(instance.run).not.toHaveBeenCalled(); + } else { + expect(instance.run).toHaveBeenCalledWith(stepConfig); + } + }); - expect(copyCalls.length).toEqual(1); + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone/steps/step' + i + '.log'); } }); }); describe('revert tests', function() { - let mockContent: MockContent; - const yargArgs = { $0: 'test', _: ['test'] @@ -421,21 +406,6 @@ describe('hub clone command', () => { hubId: 'hub-id' }; - function reset(): void { - jest.resetAllMocks(); - jest.mock('readline'); - - mockContent = new MockContent(dynamicContentClientFactory as jest.Mock); - mockContent.createMockRepository('targetRepo'); - mockContent.registerContentType('http://type', 'type', 'targetRepo'); - mockContent.registerContentType('http://type2', 'type2', 'targetRepo'); - mockContent.registerContentType('http://type3', 'type3', 'targetRepo'); - } - - beforeEach(async () => { - reset(); - }); - beforeAll(async () => { await rimraf('temp/clone-revert/'); }); @@ -444,22 +414,6 @@ describe('hub clone command', () => { await rimraf('temp/clone-revert/'); }); - function expectTypeSchemaRevert(schemaArchived: boolean, typeArchived: boolean): void { - if (schemaArchived) { - expect(mockContent.metrics.typeSchemasArchived).toEqual(1); - expect(mockContent.metrics.typeSchemasUpdated).toEqual(1); - } else { - expect(mockContent.metrics.typeSchemasArchived).toEqual(0); - expect(mockContent.metrics.typeSchemasUpdated).toEqual(0); - } - - if (typeArchived) { - expect(mockContent.metrics.typesArchived).toEqual(1); - } else { - expect(mockContent.metrics.typesArchived).toEqual(0); - } - } - async function prepareFakeLog(path: string): Promise { const fakeLog = new FileLog(path); fakeLog.switchGroup('Clone Content Types'); @@ -471,12 +425,31 @@ describe('hub clone command', () => { await fakeLog.close(); } - it('should call revert all steps in order with given parameters', async () => { - const copyCalls: Arguments[] = copierAny.calls; - copyCalls.splice(0, copyCalls.length); - - copierAny.setForceFail(false); + function makeState(argv: Arguments): CloneHubState { + return { + argv: argv, + from: { + clientId: argv.clientId as string, + clientSecret: argv.clientSecret as string, + hubId: argv.hubId as string, + ...yargArgs + }, + to: { + clientId: argv.dstClientId as string, + clientSecret: argv.dstSecret as string, + hubId: argv.dstHubId as string, + ...yargArgs + }, + path: argv.dir, + logFile: expect.any(FileLog), + revertLog: expect.any(FileLog) + }; + } + it('should revert all steps in order with given parameters', async () => { + clearMocks(); + success = [true, true, true, true]; + await ensureDirectoryExists('temp/clone-revert/'); await prepareFakeLog('temp/clone-revert/steps.log'); const argv: Arguments = { @@ -488,111 +461,165 @@ describe('hub clone command', () => { dstHubId: 'hub2-id', dstClientId: 'acc2-id', dstSecret: 'acc2-secret', + logFile: 'temp/clone-revert/steps/all.log', + revertLog: 'temp/clone-revert/steps.log', - mapFile: 'map.json', + mapFile: 'temp/clone-revert/steps/all.json', force: false, - - revertLog: 'temp/clone-revert/steps.log' + validate: false, + skipIncomplete: false, + media: true }; - const configImport = { - hubId: 'hub2-id', - clientId: 'acc2-id', - clientSecret: 'acc2-secret' - }; + const stepConfig = makeState(argv); - await ensureDirectoryExists('temp/clone-revert/steps/settings'); - writeFileSync('temp/clone-revert/steps/settings/hub-hub2-id-test.json', '{}'); + await handler(argv); - await ensureDirectoryExists('temp/clone-revert/steps/oldType'); + const mocks = getMocks(); - await handler(argv); + mocks.forEach(mock => { + const instance = mock.mock.results[0].value; - expect(settingsImport.handler).toHaveBeenCalledWith({ - ...yargArgs, - ...configImport, - filePath: 'temp/clone-revert/steps/settings/hub-hub2-id-test.json', - logFile: expect.any(FileLog), - mapFile: 'map.json', - force: false + expect(instance.revert).toHaveBeenCalledWith(stepConfig); }); - expect(typeImport.handler).toHaveBeenCalledWith( - { + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone-revert/steps/all.log'); + }); + + it('should handle exceptions from each of the revert steps by stopping the process', async () => { + for (let i = 0; i < 4; i++) { + clearMocks(); + success = [i != 0, i != 1, i != 2, i != 3]; + + await ensureDirectoryExists('temp/clone-revert/'); + await prepareFakeLog('temp/clone-revert/fail.log'); + + const argv: Arguments = { ...yargArgs, - ...configImport, - dir: 'temp/clone-revert/steps/oldType', - sync: true, - logFile: expect.any(FileLog) - }, - ['type2'] - ); + ...config, + + dir: 'temp/clone-revert/fail', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + logFile: 'temp/clone-revert/fail/fail' + i + '.log', + revertLog: 'temp/clone-revert/fail.log', + + mapFile: 'temp/clone-revert/fail/fail' + i + '.json', + force: false, + validate: false, + skipIncomplete: false, + media: true + }; - expectTypeSchemaRevert(true, true); + const stepConfig = makeState(argv); - expect(copyCalls.length).toEqual(1); + await handler(argv); - expect(copyCalls[0].revertLog).toEqual(expect.any(FileLog)); - expect(copyCalls[0].clientId).toEqual(config.clientId); - expect(copyCalls[0].clientSecret).toEqual(config.clientSecret); - expect(copyCalls[0].hubId).toEqual(config.hubId); - expect(copyCalls[0].schemaId).toEqual(argv.schemaId); - expect(copyCalls[0].name).toEqual(argv.name); - expect(copyCalls[0].srcRepo).toEqual(argv.srcRepo); - expect(copyCalls[0].dstRepo).toEqual(argv.dstRepo); - expect(copyCalls[0].dstHubId).toEqual(argv.dstHubId); - expect(copyCalls[0].dstSecret).toEqual(argv.dstSecret); + const mocks = getMocks(); - expect(copyCalls[0].force).toEqual(argv.force); + mocks.forEach((mock, index) => { + const instance = mock.mock.results[0].value; + + if (index > i) { + expect(instance.revert).not.toHaveBeenCalled(); + } else { + expect(instance.revert).toHaveBeenCalledWith(stepConfig); + } + }); + + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone-revert/fail/fail' + i + '.log'); + } }); - it('should handle exceptions from each of the revert steps by stopping the process', async () => { - const copyCalls: Arguments[] = copierAny.calls; + it('should exit early if revert log cannot be read', async () => { + clearMocks(); + success = [true, true, true, true]; + await ensureDirectoryExists('temp/clone-revert/'); + + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: 'temp/clone-revert/steps', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + logFile: 'temp/clone-revert/steps/early.log', + revertLog: 'temp/clone-revert/missing.log', - copierAny.setForceFail(false); + mapFile: 'temp/clone-revert/steps/all.json', + force: false, + validate: false, + skipIncomplete: false, + media: true + }; + await handler(argv); - await ensureDirectoryExists('temp/clone-revert/stepExcept/settings'); - await prepareFakeLog('temp/clone-revert/stepExcept.log'); - writeFileSync('temp/clone-revert/stepExcept/settings/hub-hub2-id-test.json', '{}'); + const mocks = getMocks(); - await ensureDirectoryExists('temp/clone-revert/steps/oldType'); + mocks.forEach(mock => { + const instance = mock.mock.results[0].value; - for (let i = 1; i <= 5; i++) { - reset(); - copyCalls.splice(0, copyCalls.length); + expect(instance.revert).not.toHaveBeenCalled(); + }); - succeedOrFail(settingsImport.handler, i != 1); - mockContent.failSchemaActions = i == 2 ? 'all' : null; - mockContent.failTypeActions = i == 3 || i == 2 ? 'all' : null; - succeedOrFail(typeImport.handler, i != 4); - copierAny.setForceFail(i == 5); + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone-revert/steps/early.log'); + }); + + it('should start reverting from the step given as a parameter (steps in decreasing order)', async () => { + for (let i = 0; i < 4; i++) { + clearMocks(); + success = [true, true, true, true]; + + await ensureDirectoryExists('temp/clone-revert/'); + await prepareFakeLog('temp/clone-revert/step.log'); const argv: Arguments = { ...yargArgs, ...config, - dir: 'temp/clone-revert/stepExcept', - revertLog: 'temp/clone-revert/stepExcept.log', + step: i, + + dir: 'temp/clone-revert/step', dstHubId: 'hub2-id', dstClientId: 'acc2-id', - dstSecret: 'acc2-secret' + dstSecret: 'acc2-secret', + logFile: 'temp/clone-revert/step/step' + i + '.log', + revertLog: 'temp/clone-revert/step.log', + + mapFile: 'temp/clone-revert/step/step' + i + '.json', + force: false, + validate: false, + skipIncomplete: false, + media: true }; - await handler(argv); + const stepConfig = makeState(argv); - expect(settingsImport.handler).toHaveBeenCalledTimes(i >= 1 ? 1 : 0); + await handler(argv); - process.stdout.write(i.toString()); + const mocks = getMocks(); - expectTypeSchemaRevert(i > 2, i > 3); + mocks.forEach((mock, index) => { + const instance = mock.mock.results[0].value; - expect(typeImport.handler).toHaveBeenCalledTimes(i >= 4 ? 1 : 0); + if (index < i) { + expect(instance.revert).not.toHaveBeenCalled(); + } else { + expect(instance.revert).toHaveBeenCalledWith(stepConfig); + } + }); - expect(copyCalls.length).toEqual(i >= 5 ? 1 : 0); + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone-revert/step/step' + i + '.log'); } }); - - it('should start reverting from the step given as a parameter (steps in decreasing order)', async () => {}); }); }); diff --git a/src/commands/hub/clone.ts b/src/commands/hub/clone.ts index 1284e6b5..fcb56aaa 100644 --- a/src/commands/hub/clone.ts +++ b/src/commands/hub/clone.ts @@ -2,7 +2,6 @@ import { getDefaultLogPath } from '../../common/log-helpers'; import { Argv, Arguments } from 'yargs'; import { join } from 'path'; import { ConfigurationParameters } from '../configure'; -import rmdir from 'rimraf'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { FileLog } from '../../common/file-log'; @@ -14,7 +13,6 @@ import { SchemaCloneStep } from './steps/schema-clone-step'; import { SettingsCloneStep } from './steps/settings-clone-step'; import { TypeCloneStep } from './steps/type-clone-step'; import { CloneHubState } from './model/clone-hub-state'; -import { revert } from '../content-item/import-revert'; export function getDefaultMappingPath(name: string, platform: string = process.platform): string { return join( @@ -146,12 +144,6 @@ export const builder = (yargs: Argv): void => { }); }; -function rimraf(dir: string): Promise { - return new Promise((resolve): void => { - rmdir(dir, resolve); - }); -} - const steps = [new SettingsCloneStep(), new SchemaCloneStep(), new TypeCloneStep(), new ContentCloneStep()]; export const handler = async (argv: Arguments): Promise => { @@ -210,7 +202,7 @@ export const handler = async (argv: Arguments { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let copierAny: any; + + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + function reset(): void { + jest.resetAllMocks(); + + copierAny = copy; + } + + beforeEach(async () => { + reset(); + }); + + function generateState(directory: string, logName: string): CloneHubState { + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: directory, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret' + }; + + return { + argv: argv, + from: { + clientId: argv.clientId as string, + clientSecret: argv.clientSecret as string, + hubId: argv.hubId as string, + ...yargArgs + }, + to: { + clientId: argv.dstClientId as string, + clientSecret: argv.dstSecret as string, + hubId: argv.dstHubId as string, + ...yargArgs + }, + path: directory, + logFile: new FileLog(join(directory, logName + '.log')) + }; + } + + it('should have the name "Clone Content"', () => { + const step = new ContentCloneStep(); + expect(step.getName()).toEqual('Clone Content'); + }); + + it('should call the copy command with arguments from the state', async () => { + const state = generateState('temp/clone-content/run/', 'run'); + + const copyCalls: Arguments[] = copierAny.calls; + copyCalls.splice(0, copyCalls.length); + copierAny.setForceFail(false); + + const step = new ContentCloneStep(); + const result = await step.run(state); + + expect(copyCalls).toEqual([ + { + ...state.argv, + dir: join(state.path, 'content'), + logFile: state.logFile, + revertLog: state.revertLog + } + ]); + + expect(result).toBeTruthy(); + }); + + it('should return false when the copy command fails', async () => { + const state = generateState('temp/clone-content/fail/', 'fail'); + + const copyCalls: Arguments[] = copierAny.calls; + copyCalls.splice(0, copyCalls.length); + copierAny.setForceFail(true); + + const step = new ContentCloneStep(); + const result = await step.run(state); + + expect(copyCalls.length).toEqual(1); + expect(result).toBeFalsy(); + }); + + it('should call the copy revert command with arguments from the state', async () => { + const state = generateState('temp/clone-content/run/', 'run'); + state.revertLog = new FileLog(); + + const copyCalls: Arguments[] = copierAny.calls; + copyCalls.splice(0, copyCalls.length); + copierAny.setForceFail(false); + + const step = new ContentCloneStep(); + const result = await step.revert(state); + + expect(copyCalls).toEqual([ + { + ...state.argv, + dir: join(state.path, 'content'), + logFile: state.logFile, + revertLog: state.revertLog + } + ]); + + expect(result).toBeTruthy(); + }); + + it('should return false when the copy revert command fails', async () => { + const state = generateState('temp/clone-content/fail/', 'fail'); + state.revertLog = new FileLog(); + + const copyCalls: Arguments[] = copierAny.calls; + copyCalls.splice(0, copyCalls.length); + copierAny.setForceFail(true); + + const step = new ContentCloneStep(); + const result = await step.revert(state); + + expect(copyCalls.length).toEqual(1); + expect(result).toBeFalsy(); + }); +}); diff --git a/src/commands/hub/steps/schema-clone-step.spec.ts b/src/commands/hub/steps/schema-clone-step.spec.ts new file mode 100644 index 00000000..d184a7ec --- /dev/null +++ b/src/commands/hub/steps/schema-clone-step.spec.ts @@ -0,0 +1,210 @@ +import { Arguments } from 'yargs'; +import { MockContent } from '../../../common/dc-management-sdk-js/mock-content'; +import { FileLog } from '../../../common/file-log'; +import { ensureDirectoryExists } from '../../../common/import/directory-utils'; +import { CloneHubBuilderOptions } from '../../../interfaces/clone-hub-builder-options'; +import dynamicContentClientFactory from '../../../services/dynamic-content-client-factory'; +import { ConfigurationParameters } from '../../configure'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; +import rmdir from 'rimraf'; + +import * as schemaImport from '../../content-type-schema/import'; +import * as schemaExport from '../../content-type-schema/export'; + +import { SchemaCloneStep } from './schema-clone-step'; + +jest.mock('../../../services/dynamic-content-client-factory'); +jest.mock('../../content-type-schema/import'); +jest.mock('../../content-type-schema/export'); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +describe('schema clone step', () => { + let mockContent: MockContent; + + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + function reset(): void { + jest.resetAllMocks(); + + mockContent = new MockContent(dynamicContentClientFactory as jest.Mock); + mockContent.createMockRepository('targetRepo'); + mockContent.registerContentType('http://type', 'type', 'targetRepo'); + mockContent.registerContentType('http://type2', 'type2', 'targetRepo'); + mockContent.registerContentType('http://type3', 'type3', 'targetRepo'); + } + + beforeEach(async () => { + reset(); + }); + + beforeAll(async () => { + await rimraf('temp/clone-schema/'); + }); + + afterAll(async () => { + await rimraf('temp/clone-schema/'); + }); + + function generateState(directory: string, logName: string): CloneHubState { + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: directory, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret' + }; + + return { + argv: argv, + from: { + clientId: argv.clientId as string, + clientSecret: argv.clientSecret as string, + hubId: argv.hubId as string, + ...yargArgs + }, + to: { + clientId: argv.dstClientId as string, + clientSecret: argv.dstSecret as string, + hubId: argv.dstHubId as string, + ...yargArgs + }, + path: directory, + logFile: new FileLog(join(directory, logName + '.log')) + }; + } + + it('should have the name "Clone Content Type Schemas"', () => { + const step = new SchemaCloneStep(); + expect(step.getName()).toEqual('Clone Content Type Schemas'); + }); + + it('should call export on the source and import to the destination', async () => { + const state = generateState('temp/clone-schema/run/', 'run'); + + (schemaImport.handler as jest.Mock).mockResolvedValue(true); + (schemaExport.handler as jest.Mock).mockResolvedValue(true); + + const step = new SchemaCloneStep(); + const result = await step.run(state); + + expect(schemaExport.handler).toHaveBeenCalledWith({ + dir: join(state.path, 'schema'), + force: state.argv.force, + logFile: state.logFile, + ...state.from + }); + + expect(schemaImport.handler).toBeCalledWith({ + dir: join(state.path, 'schema'), + logFile: state.logFile, + ...state.to + }); + + expect(result).toBeTruthy(); + }); + + it('should fail the step when the export or import fails', async () => { + const state = generateState('temp/clone-schema/run/', 'run'); + + (schemaExport.handler as jest.Mock).mockRejectedValue(false); + + const step = new SchemaCloneStep(); + const exportFail = await step.run(state); + + expect(exportFail).toBeFalsy(); + expect(schemaExport.handler).toHaveBeenCalled(); + expect(schemaImport.handler).not.toHaveBeenCalled(); + + reset(); + + (schemaExport.handler as jest.Mock).mockResolvedValue(true); + (schemaImport.handler as jest.Mock).mockRejectedValue(false); + + const importFail = await step.run(state); + + expect(importFail).toBeFalsy(); + expect(schemaExport.handler).toHaveBeenCalled(); + expect(schemaImport.handler).toHaveBeenCalled(); + }); + + it('should attempt to archive schemas with the CREATE action on revert, skipping archived schemas', async () => { + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Content Type Schemas'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('CREATE', 'type3'); // is archived + + const state = generateState('temp/clone-schema/revert-create/', 'revert-create'); + + const client = dynamicContentClientFactory(config); + await (await client.contentTypeSchemas.get('type3')).related.archive(); + + state.revertLog = fakeLog; + mockContent.metrics.typeSchemasArchived = 0; + + const step = new SchemaCloneStep(); + await step.revert(state); + + expect(mockContent.metrics.typeSchemasArchived).toEqual(1); + }); + + it('should attempt to fetch and revert to the version of the schema in the revert log', async () => { + const state = generateState('temp/clone-schema/revert-update/', 'revert-update'); + + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Content Type Schemas'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('UPDATE', 'type2 0 1'); + + await ensureDirectoryExists('temp/clone-schema/revert-update/oldType'); + + state.revertLog = fakeLog; + + const step = new SchemaCloneStep(); + const result = await step.revert(state); + + expect(mockContent.metrics.typeSchemasArchived).toEqual(1); + expect(mockContent.metrics.typeSchemasUpdated).toEqual(1); + + expect(result).toBeTruthy(); + }); + + it('should return true when importing types for revert fails (ignore)', async () => { + const state = generateState('temp/clone-schema/revert-fail/', 'revert-fail'); + + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Content Type Schemas'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('UPDATE', 'type2 0 1'); + + await ensureDirectoryExists('temp/clone-schema/revert-fail/oldType'); + + state.revertLog = fakeLog; + mockContent.failSchemaActions = 'all'; + + const step = new SchemaCloneStep(); + const result = await step.revert(state); + + expect(mockContent.metrics.typeSchemasArchived).toEqual(0); + expect(mockContent.metrics.typeSchemasUpdated).toEqual(0); + + expect(result).toBeTruthy(); + }); +}); diff --git a/src/commands/hub/steps/schema-clone-step.ts b/src/commands/hub/steps/schema-clone-step.ts index 5052ef54..6f094221 100644 --- a/src/commands/hub/steps/schema-clone-step.ts +++ b/src/commands/hub/steps/schema-clone-step.ts @@ -11,7 +11,7 @@ import { ResourceStatus, Status } from '../../../common/dc-management-sdk-js/res export class SchemaCloneStep implements CloneHubStep { getName(): string { - return 'Clone Content Type Schema'; + return 'Clone Content Type Schemas'; } async run(state: CloneHubState): Promise { @@ -23,7 +23,7 @@ export class SchemaCloneStep implements CloneHubStep { ...state.from }); } catch (e) { - state.logFile.appendLine(`ERROR: Could not export schema. \n${e}`); + state.logFile.appendLine(`ERROR: Could not export schemas. \n${e}`); return false; } @@ -34,7 +34,7 @@ export class SchemaCloneStep implements CloneHubStep { ...state.to }); } catch (e) { - state.logFile.appendLine(`ERROR: Could not import schema. \n${e}`); + state.logFile.appendLine(`ERROR: Could not import schemas. \n${e}`); return false; } diff --git a/src/commands/hub/steps/settings-clone-step.spec.ts b/src/commands/hub/steps/settings-clone-step.spec.ts new file mode 100644 index 00000000..d1e6f7e7 --- /dev/null +++ b/src/commands/hub/steps/settings-clone-step.spec.ts @@ -0,0 +1,218 @@ +import { Arguments } from 'yargs'; +import { FileLog } from '../../../common/file-log'; +import { CloneHubBuilderOptions } from '../../../interfaces/clone-hub-builder-options'; +import { ConfigurationParameters } from '../../configure'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; +import * as fs from 'fs'; + +import * as settingsImport from '../../settings/import'; +import * as settingsExport from '../../settings/export'; + +import { SettingsCloneStep } from './settings-clone-step'; + +jest.mock('../../../services/dynamic-content-client-factory'); +jest.mock('../../settings/import'); +jest.mock('../../settings/export'); +jest.mock('fs'); +jest.mock('../../../common/import/directory-utils'); + +describe('settings clone step', () => { + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + function reset(): void { + jest.resetAllMocks(); + } + + beforeEach(async () => { + reset(); + }); + + function generateState(directory: string, logName: string): CloneHubState { + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: directory, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret' + }; + + return { + argv: argv, + from: { + clientId: argv.clientId as string, + clientSecret: argv.clientSecret as string, + hubId: argv.hubId as string, + ...yargArgs + }, + to: { + clientId: argv.dstClientId as string, + clientSecret: argv.dstSecret as string, + hubId: argv.dstHubId as string, + ...yargArgs + }, + path: directory, + logFile: new FileLog(join(directory, logName + '.log')) + }; + } + + it('should have the name "Clone Settings"', () => { + const step = new SettingsCloneStep(); + expect(step.getName()).toEqual('Clone Settings'); + }); + + it('should call the settings commands with arguments from the state, importing the result of the export and performing a backup', async () => { + const state = generateState('temp/clone-settings/run/', 'run'); + + (settingsImport.handler as jest.Mock).mockResolvedValue(true); + (settingsExport.handler as jest.Mock).mockResolvedValue(true); + + const settingsFile = 'hub-hub-id-test.json'; + (fs.readdirSync as jest.Mock).mockReturnValue([settingsFile]); + + const step = new SettingsCloneStep(); + const result = await step.run(state); + + // Export + expect(settingsExport.handler).toHaveBeenNthCalledWith(1, { + dir: join(state.path, 'settings'), + logFile: state.logFile, + force: state.argv.force, + ...state.from + }); + + // Backup + expect(settingsExport.handler).toHaveBeenNthCalledWith(2, { + dir: join(state.path, 'settings'), + logFile: state.logFile, + force: state.argv.force, + ...state.to + }); + + // Import + expect(settingsImport.handler).toHaveBeenCalledWith({ + filePath: join(state.path, 'settings', settingsFile), + mapFile: state.argv.mapFile, + force: state.argv.force, + logFile: state.logFile, + ...state.to + }); + + expect(result).toBeTruthy(); + }); + + it('should return false when exporting fails, the exported file is missing or import fails', async () => { + const state = generateState('temp/clone-settings/fail/', 'fail'); + const step = new SettingsCloneStep(); + + (settingsImport.handler as jest.Mock).mockResolvedValue(true); + (settingsExport.handler as jest.Mock).mockRejectedValue(false); + + const settingsFile = 'hub-hub-id-test.json'; + (fs.readdirSync as jest.Mock).mockReturnValue([settingsFile]); + + const failedExport = await step.run(state); + + expect(failedExport).toBeFalsy(); + expect(settingsExport.handler).toHaveBeenCalledTimes(1); + expect(settingsImport.handler).not.toHaveBeenCalled(); + + reset(); + + (settingsImport.handler as jest.Mock).mockResolvedValue(true); + (settingsExport.handler as jest.Mock).mockResolvedValue(true); + + // Hub ID must match the source. + (fs.readdirSync as jest.Mock).mockReturnValue(['mismatch', 'hub-hub2-id-test.json']); + const missingExport = await step.run(state); + + expect(missingExport).toBeFalsy(); + expect(settingsExport.handler).toHaveBeenCalledTimes(2); + expect(settingsImport.handler).not.toHaveBeenCalled(); + + reset(); + + (settingsImport.handler as jest.Mock).mockRejectedValue(false); + (settingsExport.handler as jest.Mock).mockResolvedValue(true); + + (fs.readdirSync as jest.Mock).mockReturnValue([settingsFile]); + const failingImport = await step.run(state); + + expect(failingImport).toBeFalsy(); + expect(settingsExport.handler).toHaveBeenCalledTimes(2); + expect(settingsImport.handler).toHaveBeenCalled(); + + reset(); + + (settingsImport.handler as jest.Mock).mockResolvedValue(true); + (settingsExport.handler as jest.Mock).mockResolvedValueOnce(true).mockRejectedValueOnce(false); + + (fs.readdirSync as jest.Mock).mockReturnValue([settingsFile]); + const backupFailiure = await step.run(state); + + expect(backupFailiure).toBeTruthy(); + expect(settingsExport.handler).toHaveBeenCalledTimes(2); + expect(settingsImport.handler).toHaveBeenCalled(); + }); + + it('should import saved settings in the given directory when reverting', async () => { + const state = generateState('temp/clone-settings/revert/', 'revert'); + + (settingsImport.handler as jest.Mock).mockResolvedValue(true); + + const settingsFile = 'hub-hub2-id-test.json'; + (fs.readdirSync as jest.Mock).mockReturnValue([settingsFile]); + + const step = new SettingsCloneStep(); + const result = await step.revert(state); + + expect(settingsImport.handler).toHaveBeenCalledWith({ + filePath: join(state.path, 'settings', settingsFile), + mapFile: state.argv.mapFile, + force: state.argv.force, + logFile: state.logFile, + ...state.to + }); + + expect(result).toBeTruthy(); + }); + + it('should fail revert if the saved settings are missing, or the import of them fails', async () => { + const state = generateState('temp/clone-settings/revert-fail/', 'revert-fail'); + const step = new SettingsCloneStep(); + + (settingsImport.handler as jest.Mock).mockResolvedValue(true); + + // Settings file is not present. + (fs.readdirSync as jest.Mock).mockReturnValue(['missing', 'hub-hub-id-test.json']); + + const revertSettingsMissing = await step.revert(state); + + expect(settingsImport.handler).not.toHaveBeenCalled(); + expect(revertSettingsMissing).toBeFalsy(); + + reset(); + + // Settings file is present, but import fails. + (settingsImport.handler as jest.Mock).mockRejectedValue(false); + + (fs.readdirSync as jest.Mock).mockReturnValue(['hub-hub2-id-test.json']); + + const importFailed = await step.revert(state); + + expect(settingsImport.handler).toHaveBeenCalled(); + expect(importFailed).toBeFalsy(); + }); +}); diff --git a/src/commands/hub/steps/type-clone-step.spec.ts b/src/commands/hub/steps/type-clone-step.spec.ts new file mode 100644 index 00000000..44867769 --- /dev/null +++ b/src/commands/hub/steps/type-clone-step.spec.ts @@ -0,0 +1,238 @@ +import { Arguments } from 'yargs'; +import { MockContent } from '../../../common/dc-management-sdk-js/mock-content'; +import { FileLog } from '../../../common/file-log'; +import { ensureDirectoryExists } from '../../../common/import/directory-utils'; +import { CloneHubBuilderOptions } from '../../../interfaces/clone-hub-builder-options'; +import dynamicContentClientFactory from '../../../services/dynamic-content-client-factory'; +import { ConfigurationParameters } from '../../configure'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; +import rmdir from 'rimraf'; + +import * as typeImport from '../../content-type/import'; +import * as typeExport from '../../content-type/export'; + +import { TypeCloneStep } from './type-clone-step'; + +jest.mock('../../../services/dynamic-content-client-factory'); +jest.mock('../../content-type/import'); +jest.mock('../../content-type/export'); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +describe('type clone step', () => { + let mockContent: MockContent; + + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + function reset(): void { + jest.resetAllMocks(); + + mockContent = new MockContent(dynamicContentClientFactory as jest.Mock); + mockContent.createMockRepository('targetRepo'); + mockContent.registerContentType('http://type', 'type', 'targetRepo'); + mockContent.registerContentType('http://type2', 'type2', 'targetRepo'); + mockContent.registerContentType('http://type3', 'type3', 'targetRepo'); + } + + beforeEach(async () => { + reset(); + }); + + beforeAll(async () => { + await rimraf('temp/clone-type/'); + }); + + afterAll(async () => { + await rimraf('temp/clone-type/'); + }); + + function generateState(directory: string, logName: string): CloneHubState { + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: directory, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret' + }; + + return { + argv: argv, + from: { + clientId: argv.clientId as string, + clientSecret: argv.clientSecret as string, + hubId: argv.hubId as string, + ...yargArgs + }, + to: { + clientId: argv.dstClientId as string, + clientSecret: argv.dstSecret as string, + hubId: argv.dstHubId as string, + ...yargArgs + }, + path: directory, + logFile: new FileLog(join(directory, logName + '.log')) + }; + } + + it('should have the name "Clone Content Types"', () => { + const step = new TypeCloneStep(); + expect(step.getName()).toEqual('Clone Content Types'); + }); + + it('should call export on the source, backup and import to the destination', async () => { + const state = generateState('temp/clone-type/run/', 'run'); + + (typeImport.handler as jest.Mock).mockResolvedValue(true); + (typeExport.handler as jest.Mock).mockResolvedValue(true); + + const step = new TypeCloneStep(); + const result = await step.run(state); + // Backup + expect(typeExport.handler).toHaveBeenNthCalledWith(1, { + dir: join(state.path, 'oldType'), + force: state.argv.force, + logFile: state.logFile, + ...state.to + }); + + // Export + expect(typeExport.handler).toHaveBeenNthCalledWith(2, { + dir: join(state.path, 'type'), + force: state.argv.force, + logFile: state.logFile, + ...state.from + }); + + expect(typeImport.handler).toBeCalledWith({ + dir: join(state.path, 'type'), + sync: true, + logFile: state.logFile, + ...state.to + }); + + expect(result).toBeTruthy(); + }); + + it('should fail the step when the export, backup or import fails', async () => { + const state = generateState('temp/clone-type/run/', 'run'); + + (typeExport.handler as jest.Mock).mockRejectedValue(false); + + const step = new TypeCloneStep(); + const backupFail = await step.run(state); + + expect(backupFail).toBeFalsy(); + expect(typeExport.handler).toBeCalledTimes(1); + expect(typeImport.handler).not.toBeCalled(); + + reset(); + + (typeExport.handler as jest.Mock).mockResolvedValueOnce(true); + (typeExport.handler as jest.Mock).mockRejectedValueOnce(false); + + const exportFail = await step.run(state); + + expect(exportFail).toBeFalsy(); + expect(typeExport.handler).toBeCalledTimes(2); + expect(typeImport.handler).not.toBeCalled(); + + reset(); + + (typeExport.handler as jest.Mock).mockResolvedValue(true); + (typeImport.handler as jest.Mock).mockRejectedValue(false); + + const importFail = await step.run(state); + + expect(importFail).toBeFalsy(); + expect(typeExport.handler).toBeCalledTimes(2); + expect(typeImport.handler).toBeCalled(); + }); + + it('should attempt to archive types with the CREATE action on revert, skipping archived types', async () => { + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Content Types'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('CREATE', 'type3'); // is archived + + const state = generateState('temp/clone-type/revert-create/', 'revert-create'); + + await ensureDirectoryExists('temp/clone-type/revert-create/oldType'); + const client = dynamicContentClientFactory(config); + await (await client.contentTypes.get('type3')).related.archive(); + + state.revertLog = fakeLog; + mockContent.metrics.typesArchived = 0; + + const step = new TypeCloneStep(); + await step.revert(state); + + expect(mockContent.metrics.typesArchived).toEqual(1); + expect(typeImport.handler).not.toBeCalled(); + }); + + it('should pass types with the UPDATE action to the type import command on revert, in the oldType folder', async () => { + const state = generateState('temp/clone-type/revert-update/', 'revert-update'); + + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Content Types'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('UPDATE', 'type2 0 1'); + + await ensureDirectoryExists('temp/clone-type/revert-update/oldType'); + + state.revertLog = fakeLog; + + const step = new TypeCloneStep(); + const result = await step.revert(state); + + expect(mockContent.metrics.typesArchived).toEqual(1); + expect(typeImport.handler).toBeCalledWith( + { + dir: join(state.path, 'oldType'), + sync: true, + logFile: state.logFile, + ...state.to + }, + ['type2'] + ); + + expect(result).toBeTruthy(); + }); + + it('should return false when importing types for revert fails', async () => { + const state = generateState('temp/clone-type/revert-update/', 'revert-update'); + + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Content Types'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('UPDATE', 'type2 0 1'); + + await ensureDirectoryExists('temp/clone-type/revert-update/oldType'); + + state.revertLog = fakeLog; + (typeImport.handler as jest.Mock).mockRejectedValue(false); + + const step = new TypeCloneStep(); + const result = await step.revert(state); + + expect(mockContent.metrics.typesArchived).toEqual(1); + expect(result).toBeFalsy(); + }); +}); diff --git a/src/common/dc-management-sdk-js/mock-content.ts b/src/common/dc-management-sdk-js/mock-content.ts index d3a16f74..da0147ae 100644 --- a/src/common/dc-management-sdk-js/mock-content.ts +++ b/src/common/dc-management-sdk-js/mock-content.ts @@ -455,7 +455,7 @@ export class MockContent { cachedSchema: { ...body, $id: schemaName } }); - this.metrics.typesSynced; + this.metrics.typesSynced++; return Promise.resolve(cached); }); From 8512f91e5fbe0d271717dc4d3dc1e043684a5309 Mon Sep 17 00:00:00 2001 From: Rhys Date: Wed, 17 Mar 2021 10:31:53 +0000 Subject: [PATCH 06/15] fix(hub): fix some issues with clone hub command --- src/commands/content-type/import.spec.ts | 2 +- src/commands/content-type/import.ts | 2 +- src/commands/hub/clone.spec.ts | 19 ------------------- .../hub/steps/schema-clone-step.spec.ts | 2 +- src/commands/hub/steps/schema-clone-step.ts | 2 +- .../hub/steps/settings-clone-step.spec.ts | 4 ++-- src/commands/hub/steps/settings-clone-step.ts | 4 ++-- .../hub/steps/type-clone-step.spec.ts | 4 ++-- src/commands/hub/steps/type-clone-step.ts | 4 ++-- 9 files changed, 12 insertions(+), 31 deletions(-) diff --git a/src/commands/content-type/import.spec.ts b/src/commands/content-type/import.spec.ts index 8aface53..2a55d6f2 100644 --- a/src/commands/content-type/import.spec.ts +++ b/src/commands/content-type/import.spec.ts @@ -118,7 +118,7 @@ describe('content-type import command', (): void => { expect(log.getData('CREATE')).toMatchInlineSnapshot(` Array [ - "undefined", + "created-id", ] `); expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining(contentType)); diff --git a/src/commands/content-type/import.ts b/src/commands/content-type/import.ts index 2222ad7c..25047272 100644 --- a/src/commands/content-type/import.ts +++ b/src/commands/content-type/import.ts @@ -109,7 +109,7 @@ export const doCreate = async (hub: Hub, contentType: ContentType, log: FileLog) try { const result = await hub.related.contentTypes.register(new ContentType(contentType)); - log.addAction('CREATE', `${contentType.id}`); + log.addAction('CREATE', `${result.id}`); return result; } catch (err) { diff --git a/src/commands/hub/clone.spec.ts b/src/commands/hub/clone.spec.ts index 0cae92d9..62f8b36e 100644 --- a/src/commands/hub/clone.spec.ts +++ b/src/commands/hub/clone.spec.ts @@ -1,15 +1,8 @@ import { builder, command, handler, LOG_FILENAME, getDefaultMappingPath } from './clone'; import { getDefaultLogPath } from '../../common/log-helpers'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; -import { writeFileSync } from 'fs'; import Yargs from 'yargs/yargs'; -import * as settingsImport from '../settings/import'; -import * as settingsExport from '../settings/export'; -import * as schemaImport from '../content-type-schema/import'; -import * as schemaExport from '../content-type-schema/export'; -import * as typeImport from '../content-type/import'; -import * as typeExport from '../content-type/export'; import * as copier from '../content-item/copy'; import * as content from './steps/content-clone-step'; @@ -21,10 +14,7 @@ import rmdir from 'rimraf'; import { CloneHubBuilderOptions } from '../../interfaces/clone-hub-builder-options'; import { ConfigurationParameters } from '../configure'; import { Arguments } from 'yargs'; -import { CopyItemBuilderOptions } from '../../interfaces/copy-item-builder-options.interface'; import { FileLog } from '../../common/file-log'; -import { MockContent } from '../../common/dc-management-sdk-js/mock-content'; -import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { CloneHubState } from './model/clone-hub-state'; jest.mock('readline'); @@ -36,15 +26,6 @@ let success = [true, true, true, true]; // eslint-disable-next-line @typescript-eslint/no-explicit-any function succeedOrFail(mock: any, succeed: () => boolean): jest.Mock { mock.mockImplementation(() => Promise.resolve(succeed())); - /* - mock.mockImplementation(() => { - if (succeed()) { - return Promise.resolve(true); - } else { - return Promise.reject(false); - } - }); - */ return mock; } diff --git a/src/commands/hub/steps/schema-clone-step.spec.ts b/src/commands/hub/steps/schema-clone-step.spec.ts index d184a7ec..6892c2ab 100644 --- a/src/commands/hub/steps/schema-clone-step.spec.ts +++ b/src/commands/hub/steps/schema-clone-step.spec.ts @@ -107,7 +107,7 @@ describe('schema clone step', () => { expect(schemaExport.handler).toHaveBeenCalledWith({ dir: join(state.path, 'schema'), - force: state.argv.force, + force: true, logFile: state.logFile, ...state.from }); diff --git a/src/commands/hub/steps/schema-clone-step.ts b/src/commands/hub/steps/schema-clone-step.ts index 6f094221..3d0909a1 100644 --- a/src/commands/hub/steps/schema-clone-step.ts +++ b/src/commands/hub/steps/schema-clone-step.ts @@ -18,7 +18,7 @@ export class SchemaCloneStep implements CloneHubStep { try { await exportSchema({ dir: join(state.path, 'schema'), - force: state.argv.force, + force: true, logFile: state.logFile, ...state.from }); diff --git a/src/commands/hub/steps/settings-clone-step.spec.ts b/src/commands/hub/steps/settings-clone-step.spec.ts index d1e6f7e7..87aa817b 100644 --- a/src/commands/hub/steps/settings-clone-step.spec.ts +++ b/src/commands/hub/steps/settings-clone-step.spec.ts @@ -89,7 +89,7 @@ describe('settings clone step', () => { expect(settingsExport.handler).toHaveBeenNthCalledWith(1, { dir: join(state.path, 'settings'), logFile: state.logFile, - force: state.argv.force, + force: true, ...state.from }); @@ -97,7 +97,7 @@ describe('settings clone step', () => { expect(settingsExport.handler).toHaveBeenNthCalledWith(2, { dir: join(state.path, 'settings'), logFile: state.logFile, - force: state.argv.force, + force: true, ...state.to }); diff --git a/src/commands/hub/steps/settings-clone-step.ts b/src/commands/hub/steps/settings-clone-step.ts index cce63526..2839f7a2 100644 --- a/src/commands/hub/steps/settings-clone-step.ts +++ b/src/commands/hub/steps/settings-clone-step.ts @@ -25,7 +25,7 @@ export class SettingsCloneStep implements CloneHubStep { await exportSettings({ dir: join(state.path, 'settings'), logFile: state.logFile, - force: state.argv.force, + force: true, ...state.from }); } catch (e) { @@ -39,7 +39,7 @@ export class SettingsCloneStep implements CloneHubStep { await exportSettings({ dir: join(state.path, 'settings'), logFile: state.logFile, - force: state.argv.force, + force: true, ...state.to }); } catch (e) { diff --git a/src/commands/hub/steps/type-clone-step.spec.ts b/src/commands/hub/steps/type-clone-step.spec.ts index 44867769..f4d1e3b8 100644 --- a/src/commands/hub/steps/type-clone-step.spec.ts +++ b/src/commands/hub/steps/type-clone-step.spec.ts @@ -107,7 +107,7 @@ describe('type clone step', () => { // Backup expect(typeExport.handler).toHaveBeenNthCalledWith(1, { dir: join(state.path, 'oldType'), - force: state.argv.force, + force: true, logFile: state.logFile, ...state.to }); @@ -115,7 +115,7 @@ describe('type clone step', () => { // Export expect(typeExport.handler).toHaveBeenNthCalledWith(2, { dir: join(state.path, 'type'), - force: state.argv.force, + force: true, logFile: state.logFile, ...state.from }); diff --git a/src/commands/hub/steps/type-clone-step.ts b/src/commands/hub/steps/type-clone-step.ts index 333d6f7c..28c428b1 100644 --- a/src/commands/hub/steps/type-clone-step.ts +++ b/src/commands/hub/steps/type-clone-step.ts @@ -17,7 +17,7 @@ export class TypeCloneStep implements CloneHubStep { try { await exportType({ dir: join(state.path, 'oldType'), - force: state.argv.force, + force: true, logFile: state.logFile, ...state.to }); @@ -29,7 +29,7 @@ export class TypeCloneStep implements CloneHubStep { try { await exportType({ dir: join(state.path, 'type'), - force: state.argv.force, + force: true, logFile: state.logFile, ...state.from }); From 426abd18af6a7282b1390b62038f95ab5cec92f6 Mon Sep 17 00:00:00 2001 From: Rhys Date: Fri, 23 Apr 2021 12:49:13 +0100 Subject: [PATCH 07/15] refactor(clone): add logs to type clone to differentiate exports for dest and src --- src/commands/hub/steps/type-clone-step.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/commands/hub/steps/type-clone-step.ts b/src/commands/hub/steps/type-clone-step.ts index 28c428b1..24e7b289 100644 --- a/src/commands/hub/steps/type-clone-step.ts +++ b/src/commands/hub/steps/type-clone-step.ts @@ -15,6 +15,7 @@ export class TypeCloneStep implements CloneHubStep { async run(state: CloneHubState): Promise { try { + state.logFile.appendLine(`Exporting existing types from destination.`); await exportType({ dir: join(state.path, 'oldType'), force: true, @@ -27,6 +28,7 @@ export class TypeCloneStep implements CloneHubStep { } try { + state.logFile.appendLine(`Exporting types from source.`); await exportType({ dir: join(state.path, 'type'), force: true, From 8184140507ed9d534d21d7ad237cc6d9d7dc5854 Mon Sep 17 00:00:00 2001 From: Rhys Date: Mon, 17 May 2021 16:41:26 +0100 Subject: [PATCH 08/15] refactor: address feedback, part 1 --- src/commands/content-item/import-revert.ts | 2 +- src/commands/content-item/import.ts | 3 +- src/commands/event/archive.spec.ts | 18 ++++++++++++ src/commands/event/archive.ts | 32 ++++++++++++--------- src/commands/hub/steps/schema-clone-step.ts | 12 ++++---- src/commands/settings/import.ts | 3 +- src/common/archive/archive-helpers.ts | 2 +- src/common/log-helpers.ts | 23 --------------- src/common/question-helpers.ts | 24 ++++++++++++++++ src/services/export.service.ts | 2 +- 10 files changed, 73 insertions(+), 48 deletions(-) create mode 100644 src/common/question-helpers.ts diff --git a/src/commands/content-item/import-revert.ts b/src/commands/content-item/import-revert.ts index b93b3a2f..7cb9320a 100644 --- a/src/commands/content-item/import-revert.ts +++ b/src/commands/content-item/import-revert.ts @@ -4,7 +4,7 @@ import { Arguments } from 'yargs'; import { FileLog } from '../../common/file-log'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ContentItem } from 'dc-management-sdk-js'; -import { asyncQuestion } from '../../common/log-helpers'; +import { asyncQuestion } from '../../common/question-helpers'; export const revert = async (argv: Arguments): Promise => { let log: FileLog; diff --git a/src/commands/content-item/import.ts b/src/commands/content-item/import.ts index f1042553..a93adb85 100644 --- a/src/commands/content-item/import.ts +++ b/src/commands/content-item/import.ts @@ -28,7 +28,8 @@ import { } from '../../common/content-item/content-dependancy-tree'; import { AmplienceSchemaValidator, defaultSchemaLookup } from '../../common/content-item/amplience-schema-validator'; -import { getDefaultLogPath, asyncQuestion } from '../../common/log-helpers'; +import { getDefaultLogPath } from '../../common/log-helpers'; +import { asyncQuestion } from '../../common/question-helpers'; import { PublishQueue } from '../../common/import/publish-queue'; import { MediaRewriter } from '../../common/media/media-rewriter'; diff --git a/src/commands/event/archive.spec.ts b/src/commands/event/archive.spec.ts index 4fe36d59..c8cd81c3 100644 --- a/src/commands/event/archive.spec.ts +++ b/src/commands/event/archive.spec.ts @@ -349,6 +349,24 @@ describe('event archive command', () => { expect(archiveMock).toBeCalledTimes(2); }); + it('should archive events when multiple ids provided', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (readline as any).setResponses(['y']); + + const { mockGet, mockEditionsList, archiveMock } = mockValues({ status: 'PUBLISHED' }); + + const argv = { + ...yargArgs, + ...config, + id: ['1', '2'] + }; + await handler(argv); + + expect(mockGet).toHaveBeenCalledTimes(4); + expect(mockEditionsList).toHaveBeenCalledTimes(2); + expect(archiveMock).toBeCalledTimes(4); + }); + it('should delete event with scheduled edition', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (readline as any).setResponses(['y']); diff --git a/src/commands/event/archive.ts b/src/commands/event/archive.ts index 8b75f584..58684566 100644 --- a/src/commands/event/archive.ts +++ b/src/commands/event/archive.ts @@ -80,7 +80,7 @@ export const getEvents = async ({ hubId, name }: { - id?: string; + id?: string | string[]; hubId: string; name?: string | string[]; client: DynamicContent; @@ -96,19 +96,23 @@ export const getEvents = async ({ > => { try { if (id != null) { - const event = await client.events.get(id); - const editions = await paginator(event.related.editions.list); - - return [ - { - event, - editions, - command: 'ARCHIVE', - unscheduleEditions: [], - deleteEditions: [], - archiveEditions: [] - } - ]; + const ids = Array.isArray(id) ? id : [id]; + + return await Promise.all( + ids.map(async id => { + const event = await client.events.get(id); + const editions = await paginator(event.related.editions.list); + + return { + event, + editions, + command: 'ARCHIVE', + unscheduleEditions: [], + deleteEditions: [], + archiveEditions: [] + }; + }) + ); } const hub = await client.hubs.get(hubId); diff --git a/src/commands/hub/steps/schema-clone-step.ts b/src/commands/hub/steps/schema-clone-step.ts index 3d0909a1..22da9c18 100644 --- a/src/commands/hub/steps/schema-clone-step.ts +++ b/src/commands/hub/steps/schema-clone-step.ts @@ -51,19 +51,19 @@ export class SchemaCloneStep implements CloneHubStep { const toArchive = revertLog.getData('CREATE', this.getName()); const toUpdate = revertLog.getData('UPDATE', this.getName()); - for (let i = 0; i < toArchive.length; i++) { + for (const id of toArchive) { try { - const schema = await client.contentTypeSchemas.get(toArchive[i]); + const schema = await client.contentTypeSchemas.get(id); if ((schema as ResourceStatus).status == Status.ACTIVE) { await schema.related.archive(); } } catch (e) { - state.logFile.appendLine(`Could not archive ${toArchive[i]}. Continuing...`); + state.logFile.appendLine(`Could not archive ${id}. Continuing...`); } } - for (let i = 0; i < toUpdate.length; i++) { - const updateArgs = toUpdate[i].split(' '); + for (const id of toUpdate) { + const updateArgs = id.split(' '); try { const schema = await client.contentTypeSchemas.getByVersion(updateArgs[0], Number(updateArgs[1])); @@ -74,7 +74,7 @@ export class SchemaCloneStep implements CloneHubStep { typeToSync.related.contentTypeSchema.update(); } } catch (e) { - state.logFile.appendLine(`Error while updating ${toUpdate[i]}. Continuing...`); + state.logFile.appendLine(`Error while updating ${id}. Continuing...`); } } diff --git a/src/commands/settings/import.ts b/src/commands/settings/import.ts index 1ea98ffc..1c1655eb 100644 --- a/src/commands/settings/import.ts +++ b/src/commands/settings/import.ts @@ -5,7 +5,8 @@ import dynamicContentClientFactory from '../../services/dynamic-content-client-f import { ImportSettingsBuilderOptions } from '../../interfaces/import-settings-builder-options.interface'; import { WorkflowStatesMapping } from '../../common/workflowStates/workflowStates-mapping'; import { FileLog } from '../../common/file-log'; -import { getDefaultLogPath, asyncQuestion } from '../../common/log-helpers'; +import { getDefaultLogPath } from '../../common/log-helpers'; +import { asyncQuestion } from '../../common/question-helpers'; import { join } from 'path'; import { readFile } from 'fs'; import { promisify } from 'util'; diff --git a/src/common/archive/archive-helpers.ts b/src/common/archive/archive-helpers.ts index 00d5c429..6ee558c5 100644 --- a/src/common/archive/archive-helpers.ts +++ b/src/common/archive/archive-helpers.ts @@ -1,4 +1,4 @@ -import { asyncQuestion } from '../log-helpers'; +import { asyncQuestion } from '../question-helpers'; export async function confirmArchive( action: string, diff --git a/src/common/log-helpers.ts b/src/common/log-helpers.ts index f01cf481..971fc4e1 100644 --- a/src/common/log-helpers.ts +++ b/src/common/log-helpers.ts @@ -1,5 +1,4 @@ import { join } from 'path'; -import readline, { ReadLine } from 'readline'; import { FileLog } from './file-log'; export function getDefaultLogPath(type: string, action: string, platform: string = process.platform): string { @@ -21,25 +20,3 @@ export function createLog(logFile: string, title?: string): FileLog { return log; } - -function asyncQuestionInternal(rl: ReadLine, question: string): Promise { - return new Promise((resolve): void => { - rl.question(question, resolve); - }); -} - -export async function asyncQuestion(question: string, log?: FileLog): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false - }); - - const answer = await asyncQuestionInternal(rl, question); - rl.close(); - - if (log != null) { - log.appendLine(question + answer, true); - } - return answer.length > 0 && answer[0].toLowerCase() === 'y'; -} diff --git a/src/common/question-helpers.ts b/src/common/question-helpers.ts new file mode 100644 index 00000000..6e987afc --- /dev/null +++ b/src/common/question-helpers.ts @@ -0,0 +1,24 @@ +import readline, { ReadLine } from 'readline'; +import { FileLog } from './file-log'; + +function asyncQuestionInternal(rl: ReadLine, question: string): Promise { + return new Promise((resolve): void => { + rl.question(question, resolve); + }); +} + +export async function asyncQuestion(question: string, log?: FileLog): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + + const answer = await asyncQuestionInternal(rl, question); + rl.close(); + + if (log != null) { + log.appendLine(question + answer, true); + } + return answer.length > 0 && answer[0].toLowerCase() === 'y'; +} diff --git a/src/services/export.service.ts b/src/services/export.service.ts index c712e6e9..d464f703 100644 --- a/src/services/export.service.ts +++ b/src/services/export.service.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import * as path from 'path'; import { URL } from 'url'; import DataPresenter from '../view/data-presenter'; -import { asyncQuestion } from '../common/log-helpers'; +import { asyncQuestion } from '../common/question-helpers'; import { FileLog } from '../common/file-log'; export type ExportResult = 'CREATED' | 'UPDATED' | 'UP-TO-DATE'; From 6916a22d7c0870d28c54854942aea3dadb020cd0 Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 25 May 2021 09:18:23 +0100 Subject: [PATCH 09/15] refactor: rebase on top of clean, change filelog use --- src/commands/content-item/copy.ts | 2 ++ src/commands/content-item/export.spec.ts | 9 ++++++--- src/commands/content-item/export.ts | 11 +++++------ src/commands/content-item/import.spec.ts | 8 +++++--- src/commands/content-item/import.ts | 11 +++++------ src/commands/content-item/move.spec.ts | 3 ++- src/commands/content-item/move.ts | 1 + src/commands/content-item/unarchive.ts | 3 +-- .../content-type-schema/export.spec.ts | 7 +++++-- src/commands/content-type-schema/export.ts | 12 +++++------- .../content-type-schema/import.spec.ts | 7 +++++-- src/commands/content-type-schema/import.ts | 12 +++++------- src/commands/content-type/export.spec.ts | 17 +++++++++++++---- src/commands/content-type/export.ts | 12 +++++------- src/commands/content-type/import.spec.ts | 10 ++++++---- src/commands/content-type/import.ts | 12 +++++------- src/commands/hub/clone.spec.ts | 19 ++++++++++--------- src/commands/hub/clone.ts | 12 +++++------- src/commands/settings/export.spec.ts | 4 +++- src/commands/settings/export.ts | 7 ++----- src/commands/settings/import.spec.ts | 14 ++++++++++---- src/commands/settings/import.ts | 12 +++++------- src/interfaces/clone-hub-builder-options.ts | 2 +- .../export-builder-options.interface.ts | 2 +- .../export-item-builder-options.interface.ts | 2 +- .../import-builder-options.interface.ts | 2 +- .../import-item-builder-options.interface.ts | 2 +- ...port-settings-builder-options.interface.ts | 2 +- 28 files changed, 117 insertions(+), 100 deletions(-) diff --git a/src/commands/content-item/copy.ts b/src/commands/content-item/copy.ts index 5b967546..b040c3cf 100644 --- a/src/commands/content-item/copy.ts +++ b/src/commands/content-item/copy.ts @@ -10,6 +10,7 @@ import { handler as importer } from './import'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { revert } from './import-revert'; import { loadCopyConfig } from '../../common/content-item/copy-config'; +import { FileLog } from '../../common/file-log'; export function getTempFolder(name: string, platform: string = process.platform): string { return join(process.env[platform == 'win32' ? 'USERPROFILE' : 'HOME'] || __dirname, '.amplience', `copy-${name}/`); @@ -176,6 +177,7 @@ export const handler = async (argv: Arguments { expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }); }); @@ -116,7 +118,8 @@ describe('content-item export command', () => { const config = { clientId: 'client-id', clientSecret: 'client-id', - hubId: 'hub-id' + hubId: 'hub-id', + logFile: new FileLog() }; beforeAll(async () => { diff --git a/src/commands/content-item/export.ts b/src/commands/content-item/export.ts index 80c3205e..b31d0c12 100644 --- a/src/commands/content-item/export.ts +++ b/src/commands/content-item/export.ts @@ -14,7 +14,7 @@ import { ContentItem, Folder, DynamicContent, Hub, ContentRepository } from 'dc- import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { ContentDependancyTree, RepositoryContentItem } from '../../common/content-item/content-dependancy-tree'; import { ContentMapping } from '../../common/content-item/content-mapping'; -import { getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { AmplienceSchemaValidator, defaultSchemaLookup } from '../../common/content-item/amplience-schema-validator'; import { Status } from '../../common/dc-management-sdk-js/resource-status'; @@ -65,7 +65,8 @@ export const builder = (yargs: Argv): void => { .option('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }; @@ -222,7 +223,7 @@ export const handler = async (argv: Arguments = new Map(); const client = dynamicContentClientFactory(argv); - const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; + const log = logFile.open(); const hub = await client.hubs.get(argv.hubId); log.appendLine('Retrieving content items, please wait.'); @@ -346,7 +347,5 @@ export const handler = async (argv: Arguments { expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }); }); @@ -147,7 +148,8 @@ describe('content-item import command', () => { const config = { clientId: 'client-id', clientSecret: 'client-id', - hubId: 'hub-id' + hubId: 'hub-id', + logFile: new FileLog() }; beforeEach(async () => { diff --git a/src/commands/content-item/import.ts b/src/commands/content-item/import.ts index a93adb85..17ce8407 100644 --- a/src/commands/content-item/import.ts +++ b/src/commands/content-item/import.ts @@ -28,7 +28,7 @@ import { } from '../../common/content-item/content-dependancy-tree'; import { AmplienceSchemaValidator, defaultSchemaLookup } from '../../common/content-item/amplience-schema-validator'; -import { getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { asyncQuestion } from '../../common/question-helpers'; import { PublishQueue } from '../../common/import/publish-queue'; import { MediaRewriter } from '../../common/media/media-rewriter'; @@ -125,7 +125,8 @@ export const builder = (yargs: Argv): void => { .option('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }; @@ -863,12 +864,10 @@ export const handler = async ( argv.publish = argv.publish || argv.republish; const client = dynamicContentClientFactory(argv); - const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; + const log = logFile.open(); const closeLog = async (): Promise => { - if (typeof logFile !== 'object') { - await log.close(); - } + await log.close(); }; let hub: Hub; diff --git a/src/commands/content-item/move.spec.ts b/src/commands/content-item/move.spec.ts index 5bc9beb8..fd332d0d 100644 --- a/src/commands/content-item/move.spec.ts +++ b/src/commands/content-item/move.spec.ts @@ -316,7 +316,8 @@ describe('content-item move command', () => { clientSecret: 'acc2-secret', dir: '', hubId: 'hub2-id', - revertLog: 'temp/move/moveRevert.txt' + revertLog: 'temp/move/moveRevert.txt', + logFile: expect.any(FileLog) }); rimraf('temp/move/moveRevert.txt'); diff --git a/src/commands/content-item/move.ts b/src/commands/content-item/move.ts index 8616ba26..635b141a 100644 --- a/src/commands/content-item/move.ts +++ b/src/commands/content-item/move.ts @@ -202,6 +202,7 @@ export const handler = async (argv: Arguments { - const items = await paginator(source.related.contentItems.list, { status: 'ARCHIVED' }); - + const items = await paginator(source.related.contentItems.list, { status: Status.ARCHIVED }); contentItems.push(...items); }) ) diff --git a/src/commands/content-type-schema/export.spec.ts b/src/commands/content-type-schema/export.spec.ts index b93e74a0..90f8a736 100644 --- a/src/commands/content-type-schema/export.spec.ts +++ b/src/commands/content-type-schema/export.spec.ts @@ -22,6 +22,7 @@ import { table } from 'table'; import { loadJsonFromDirectory } from '../../services/import.service'; import { resolveSchemaBody } from '../../services/resolve-schema-body'; import { FileLog } from '../../common/file-log'; +import { createLog } from '../../common/log-helpers'; jest.mock('fs'); jest.mock('../../services/import.service'); @@ -76,7 +77,8 @@ describe('content-type-schema export command', (): void => { expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }); }); @@ -615,7 +617,8 @@ describe('content-type-schema export command', (): void => { const config = { clientId: 'client-id', clientSecret: 'client-id', - hubId: 'hub-id' + hubId: 'hub-id', + logFile: new FileLog() }; const contentTypeSchemasToExport: ContentTypeSchema[] = [ new ContentTypeSchema({ diff --git a/src/commands/content-type-schema/export.ts b/src/commands/content-type-schema/export.ts index 0f943c7b..eb6c55c6 100644 --- a/src/commands/content-type-schema/export.ts +++ b/src/commands/content-type-schema/export.ts @@ -20,7 +20,7 @@ import * as fs from 'fs'; import { resolveSchemaBody } from '../../services/resolve-schema-body'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { FileLog } from '../../common/file-log'; -import { getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const streamTableOptions = { @@ -78,7 +78,8 @@ export const builder = (yargs: Argv): void => { .option('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }; @@ -273,7 +274,7 @@ export const handler = async (argv: Arguments { expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }); }); @@ -329,7 +331,8 @@ describe('content-type-schema import command', (): void => { const argv = { ...yargArgs, ...config, - dir: 'my-dir' + dir: 'my-dir', + logFile: new FileLog() }; beforeEach(() => { diff --git a/src/commands/content-type-schema/import.ts b/src/commands/content-type-schema/import.ts index ef9f82cd..a56cf13e 100644 --- a/src/commands/content-type-schema/import.ts +++ b/src/commands/content-type-schema/import.ts @@ -12,7 +12,7 @@ import { updateContentTypeSchema } from './update.service'; import { ImportResult, loadJsonFromDirectory, UpdateStatus } from '../../services/import.service'; import { resolveSchemaBody } from '../../services/resolve-schema-body'; import { FileLog } from '../../common/file-log'; -import { getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { ResourceStatus, Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'import '; @@ -35,7 +35,8 @@ export const builder = (yargs: Argv): void => { yargs.option('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }; @@ -132,7 +133,7 @@ export const handler = async (argv: Arguments(dir, ContentTypeSchema); const [resolvedSchemas, resolveSchemaErrors] = await resolveSchemaBody(schemas, dir); if (Object.keys(resolveSchemaErrors).length > 0) { @@ -151,8 +152,5 @@ export const handler = async (argv: Arguments { expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }); }); @@ -705,7 +707,7 @@ describe('content-type export command', (): void => { it('should export all content types for the current hub if no schemaIds specified', async (): Promise => { const schemaIdsToExport: string[] | undefined = undefined; - const argv = { ...yargArgs, ...config, dir: 'my-dir', schemaId: schemaIdsToExport }; + const argv = { ...yargArgs, ...config, dir: 'my-dir', schemaId: schemaIdsToExport, logFile: new FileLog() }; const filteredContentTypesToExport = [...contentTypesToExport]; jest.spyOn(exportModule, 'filterContentTypesByUri').mockReturnValue(filteredContentTypesToExport); @@ -725,7 +727,14 @@ describe('content-type export command', (): void => { void > => { const schemaIdsToExport: string[] | undefined = undefined; - const argv = { ...yargArgs, ...config, dir: 'my-dir', schemaId: schemaIdsToExport, archived: true }; + const argv = { + ...yargArgs, + ...config, + dir: 'my-dir', + schemaId: schemaIdsToExport, + archived: true, + logFile: new FileLog() + }; const filteredContentTypesToExport = [...contentTypesToExport]; jest.spyOn(exportModule, 'filterContentTypesByUri').mockReturnValue(filteredContentTypesToExport); @@ -744,7 +753,7 @@ describe('content-type export command', (): void => { it('should export only selected content types if schemaIds specified', async (): Promise => { const schemaIdsToExport: string[] | undefined = ['content-type-uri-2']; - const argv = { ...yargArgs, ...config, dir: 'my-dir', schemaId: schemaIdsToExport }; + const argv = { ...yargArgs, ...config, dir: 'my-dir', schemaId: schemaIdsToExport, logFile: new FileLog() }; const filteredContentTypesToExport = [contentTypesToExport[1]]; jest.spyOn(exportModule, 'filterContentTypesByUri').mockReturnValue(filteredContentTypesToExport); diff --git a/src/commands/content-type/export.ts b/src/commands/content-type/export.ts index 4b225c6c..e72ec14e 100644 --- a/src/commands/content-type/export.ts +++ b/src/commands/content-type/export.ts @@ -19,7 +19,7 @@ import { isEqual } from 'lodash'; import { ExportBuilderOptions } from '../../interfaces/export-builder-options.interface'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { FileLog } from '../../common/file-log'; -import { getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'export '; @@ -55,7 +55,8 @@ export const builder = (yargs: Argv): void => { .option('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }; @@ -199,7 +200,7 @@ export const handler = async (argv: Arguments { expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }); }); @@ -836,7 +838,7 @@ describe('content-type import command', (): void => { }); it('should create a content type and update', async (): Promise => { - const argv = { ...yargArgs, ...config, dir: 'my-dir', sync: false }; + const argv = { ...yargArgs, ...config, dir: 'my-dir', sync: false, logFile: new FileLog() }; const fileNamesAndContentTypesToImport = { 'file-1': new ContentTypeWithRepositoryAssignments({ contentTypeUri: 'type-uri-1', @@ -874,7 +876,7 @@ describe('content-type import command', (): void => { }); it('should create a content type, update and sync a content type', async (): Promise => { - const argv = { ...yargArgs, ...config, dir: 'my-dir', sync: true }; + const argv = { ...yargArgs, ...config, dir: 'my-dir', sync: true, logFile: new FileLog() }; const fileNamesAndContentTypesToImport = { 'file-1': new ContentTypeWithRepositoryAssignments({ contentTypeUri: 'type-uri-1', @@ -912,7 +914,7 @@ describe('content-type import command', (): void => { }); it('should throw an error when no content found in import directory', async (): Promise => { - const argv = { ...yargArgs, ...config, dir: 'my-empty-dir', sync: false }; + const argv = { ...yargArgs, ...config, dir: 'my-empty-dir', sync: false, logFile: new FileLog() }; (loadJsonFromDirectory as jest.Mock).mockReturnValue([]); diff --git a/src/commands/content-type/import.ts b/src/commands/content-type/import.ts index 25047272..5ff9b020 100644 --- a/src/commands/content-type/import.ts +++ b/src/commands/content-type/import.ts @@ -10,7 +10,7 @@ import { ImportResult, loadJsonFromDirectory, UpdateStatus } from '../../service import { streamTableOptions } from '../../common/table/table.consts'; import { ImportBuilderOptions } from '../../interfaces/import-builder-options.interface'; import { FileLog } from '../../common/file-log'; -import { getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { ResourceStatus, Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'import '; @@ -39,7 +39,8 @@ export const builder = (yargs: Argv): void => { yargs.option('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }; @@ -308,7 +309,7 @@ export const handler = async ( const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); - const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; + const log = logFile.open(); const activeContentTypes = await paginator(hub.related.contentTypes.list, { status: Status.ACTIVE }); const archivedContentTypes = await paginator(hub.related.contentTypes.list, { status: Status.ARCHIVED }); @@ -319,8 +320,5 @@ export const handler = async ( } await processContentTypes(Object.values(importedContentTypes), client, hub, sync, log); - if (typeof logFile !== 'object') { - // Only close the log if it was opened by this handler. - await log.close(); - } + await log.close(); }; diff --git a/src/commands/hub/clone.spec.ts b/src/commands/hub/clone.spec.ts index 62f8b36e..5a1e5e16 100644 --- a/src/commands/hub/clone.spec.ts +++ b/src/commands/hub/clone.spec.ts @@ -1,5 +1,5 @@ import { builder, command, handler, LOG_FILENAME, getDefaultMappingPath } from './clone'; -import { getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import Yargs from 'yargs/yargs'; @@ -200,7 +200,8 @@ describe('hub clone command', () => { expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }); }); @@ -258,7 +259,7 @@ describe('hub clone command', () => { dstHubId: 'hub2-id', dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - logFile: 'temp/clone/steps/all.log', + logFile: createLog('temp/clone/steps/all.log'), force: false, validate: false, @@ -298,7 +299,7 @@ describe('hub clone command', () => { dstHubId: 'hub2-id', dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - logFile: 'temp/clone/steps/fail' + i + '.log', + logFile: createLog('temp/clone/steps/fail' + i + '.log'), mapFile: 'temp/clone/steps/fail' + i + '.json', force: false, @@ -344,7 +345,7 @@ describe('hub clone command', () => { dstHubId: 'hub2-id', dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - logFile: 'temp/clone/steps/step' + i + '.log', + logFile: createLog('temp/clone/steps/step' + i + '.log'), mapFile: 'temp/clone/steps/step' + i + '.json', force: false, @@ -442,7 +443,7 @@ describe('hub clone command', () => { dstHubId: 'hub2-id', dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - logFile: 'temp/clone-revert/steps/all.log', + logFile: createLog('temp/clone-revert/steps/all.log'), revertLog: 'temp/clone-revert/steps.log', mapFile: 'temp/clone-revert/steps/all.json', @@ -485,7 +486,7 @@ describe('hub clone command', () => { dstHubId: 'hub2-id', dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - logFile: 'temp/clone-revert/fail/fail' + i + '.log', + logFile: createLog('temp/clone-revert/fail/fail' + i + '.log'), revertLog: 'temp/clone-revert/fail.log', mapFile: 'temp/clone-revert/fail/fail' + i + '.json', @@ -530,7 +531,7 @@ describe('hub clone command', () => { dstHubId: 'hub2-id', dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - logFile: 'temp/clone-revert/steps/early.log', + logFile: createLog('temp/clone-revert/steps/early.log'), revertLog: 'temp/clone-revert/missing.log', mapFile: 'temp/clone-revert/steps/all.json', @@ -572,7 +573,7 @@ describe('hub clone command', () => { dstHubId: 'hub2-id', dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - logFile: 'temp/clone-revert/step/step' + i + '.log', + logFile: createLog('temp/clone-revert/step/step' + i + '.log'), revertLog: 'temp/clone-revert/step.log', mapFile: 'temp/clone-revert/step/step' + i + '.json', diff --git a/src/commands/hub/clone.ts b/src/commands/hub/clone.ts index fcb56aaa..6d42ad11 100644 --- a/src/commands/hub/clone.ts +++ b/src/commands/hub/clone.ts @@ -1,4 +1,4 @@ -import { getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { Argv, Arguments } from 'yargs'; import { join } from 'path'; import { ConfigurationParameters } from '../configure'; @@ -140,15 +140,15 @@ export const builder = (yargs: Argv): void => { .option('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }; const steps = [new SettingsCloneStep(), new SchemaCloneStep(), new TypeCloneStep(), new ContentCloneStep()]; export const handler = async (argv: Arguments): Promise => { - const logFile = argv.logFile; - const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; + const log = argv.logFile.open(); const tempFolder = argv.dir; if (argv.mapFile == null) { @@ -241,7 +241,5 @@ export const handler = async (argv: Arguments { const argv = { ...yargArgs, ...config, - dir: './' + dir: './', + logFile: new FileLog() }; await handler(argv); diff --git a/src/commands/settings/export.ts b/src/commands/settings/export.ts index 7bb4469e..8e5310a3 100644 --- a/src/commands/settings/export.ts +++ b/src/commands/settings/export.ts @@ -66,13 +66,10 @@ export const handler = async (argv: Arguments { expect(spyOptions).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); + expect(spyOptions).toHaveBeenCalledWith('f', { type: 'boolean', boolean: true, @@ -449,7 +453,7 @@ describe('settings import command', (): void => { await handler({ ...argv, mapFile: './mapSettings.json', - logFile: './log.json', + logFile: createLog('./log.json'), force: true }); @@ -475,7 +479,8 @@ describe('settings import command', (): void => { ...argv, mapFile: './mapSettings2.json', force: true, - answer: ['n'] + answer: ['n'], + logFile: new FileLog() }); expect(mockGetHub).toHaveBeenCalled(); @@ -490,7 +495,8 @@ describe('settings import command', (): void => { await handler({ ...argv, - force: true + force: true, + logFile: new FileLog() }); expect(mockGetHub).toHaveBeenCalled(); diff --git a/src/commands/settings/import.ts b/src/commands/settings/import.ts index 1c1655eb..63771148 100644 --- a/src/commands/settings/import.ts +++ b/src/commands/settings/import.ts @@ -5,7 +5,7 @@ import dynamicContentClientFactory from '../../services/dynamic-content-client-f import { ImportSettingsBuilderOptions } from '../../interfaces/import-settings-builder-options.interface'; import { WorkflowStatesMapping } from '../../common/workflowStates/workflowStates-mapping'; import { FileLog } from '../../common/file-log'; -import { getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { asyncQuestion } from '../../common/question-helpers'; import { join } from 'path'; import { readFile } from 'fs'; @@ -61,7 +61,8 @@ export const builder = (yargs: Argv): void => { .option('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }) .alias('f', 'force') .option('f', { @@ -78,7 +79,7 @@ export const handler = async ( let { mapFile } = argv; const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); - const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; + const log = logFile.open(); const mapping = new WorkflowStatesMapping(); let uniqueLocales = []; let uniqueApplications = []; @@ -170,10 +171,7 @@ export const handler = async ( await trySaveMapping(mapFile, mapping, log); - if (typeof logFile !== 'object') { - // Only close the log if it was opened by this handler. - await log.close(); - } + await log.close(); process.stdout.write('\n'); } catch (e) { diff --git a/src/interfaces/clone-hub-builder-options.ts b/src/interfaces/clone-hub-builder-options.ts index 803f91c6..d8f55c38 100644 --- a/src/interfaces/clone-hub-builder-options.ts +++ b/src/interfaces/clone-hub-builder-options.ts @@ -16,7 +16,7 @@ export interface CloneHubBuilderOptions { validate?: boolean; skipIncomplete?: boolean; media?: boolean; - logFile?: string | FileLog; + logFile: FileLog; copyConfig?: string | CopyConfig; lastPublish?: boolean; diff --git a/src/interfaces/export-builder-options.interface.ts b/src/interfaces/export-builder-options.interface.ts index 6bba7698..547ddad9 100644 --- a/src/interfaces/export-builder-options.interface.ts +++ b/src/interfaces/export-builder-options.interface.ts @@ -4,6 +4,6 @@ export interface ExportBuilderOptions { dir: string; schemaId?: string[]; archived?: boolean; - logFile?: string | FileLog; + logFile: FileLog; force?: boolean; } diff --git a/src/interfaces/export-item-builder-options.interface.ts b/src/interfaces/export-item-builder-options.interface.ts index 084fdb28..1fe0aeed 100644 --- a/src/interfaces/export-item-builder-options.interface.ts +++ b/src/interfaces/export-item-builder-options.interface.ts @@ -6,7 +6,7 @@ export interface ExportItemBuilderOptions { repoId?: string[] | string; schemaId?: string[] | string; name?: string[] | string; - logFile?: FileLog; + logFile: FileLog; publish?: boolean; exportedIds?: string[]; diff --git a/src/interfaces/import-builder-options.interface.ts b/src/interfaces/import-builder-options.interface.ts index 48b9e0bf..7694db2d 100644 --- a/src/interfaces/import-builder-options.interface.ts +++ b/src/interfaces/import-builder-options.interface.ts @@ -2,5 +2,5 @@ import { FileLog } from '../common/file-log'; export interface ImportBuilderOptions { dir: string; - logFile?: string | FileLog; + logFile: FileLog; } diff --git a/src/interfaces/import-item-builder-options.interface.ts b/src/interfaces/import-item-builder-options.interface.ts index 8e3c1fca..2228907c 100644 --- a/src/interfaces/import-item-builder-options.interface.ts +++ b/src/interfaces/import-item-builder-options.interface.ts @@ -12,7 +12,7 @@ export interface ImportItemBuilderOptions { skipIncomplete?: boolean; excludeKeys?: boolean; media?: boolean; - logFile?: FileLog; + logFile: FileLog; revertLog?: string | FileLog; } diff --git a/src/interfaces/import-settings-builder-options.interface.ts b/src/interfaces/import-settings-builder-options.interface.ts index 6f9fceb9..e8cd2418 100644 --- a/src/interfaces/import-settings-builder-options.interface.ts +++ b/src/interfaces/import-settings-builder-options.interface.ts @@ -3,6 +3,6 @@ import { FileLog } from '../common/file-log'; export interface ImportSettingsBuilderOptions { filePath: string; mapFile?: string; - logFile?: string | FileLog; + logFile: FileLog; force?: boolean; } From 94af5748cf2358f7dced9005e7e0ba4c7932a500 Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 25 May 2021 17:14:24 +0100 Subject: [PATCH 10/15] fix(content-item): fix content-item unarchive rebase --- src/commands/content-item/unarchive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/content-item/unarchive.ts b/src/commands/content-item/unarchive.ts index 5aed70d1..e81d0e38 100644 --- a/src/commands/content-item/unarchive.ts +++ b/src/commands/content-item/unarchive.ts @@ -206,7 +206,7 @@ export const getContentItems = async ({ ) : await Promise.all( contentRepositories.map(async source => { - const items = await paginator(source.related.contentItems.list, { status: Status.ACTIVE }); + const items = await paginator(source.related.contentItems.list, { status: Status.ARCHIVED }); contentItems.push(...items); }) ); From 4f76bf173a7ecd515515b45f954e7321db350223 Mon Sep 17 00:00:00 2001 From: Rhys Date: Wed, 26 May 2021 09:19:20 +0100 Subject: [PATCH 11/15] refactor: update dc-management-sdk-js to use new status enum (not released) --- package-lock.json | 22 +++++++++++++++---- package.json | 2 +- src/commands/content-item/archive.ts | 3 +-- src/commands/content-item/export.ts | 3 +-- .../content-item/import-revert.spec.ts | 4 +++- src/commands/content-item/unarchive.ts | 3 +-- src/commands/content-type-schema/archive.ts | 3 +-- src/commands/content-type-schema/export.ts | 3 +-- src/commands/content-type-schema/import.ts | 5 ++--- src/commands/content-type-schema/unarchive.ts | 3 +-- src/commands/content-type/archive.ts | 3 +-- src/commands/content-type/export.ts | 3 +-- src/commands/content-type/import.ts | 5 ++--- src/commands/content-type/unarchive.ts | 3 +-- .../hub/steps/content-clone-step.spec.ts | 1 + .../hub/steps/schema-clone-step.spec.ts | 1 + src/commands/hub/steps/schema-clone-step.ts | 4 ++-- .../hub/steps/settings-clone-step.spec.ts | 1 + .../hub/steps/type-clone-step.spec.ts | 1 + .../dc-management-sdk-js/mock-content.ts | 13 +++++------ src/common/dc-management-sdk-js/paginator.ts | 7 ++++-- .../dc-management-sdk-js/resource-status.ts | 9 -------- 22 files changed, 52 insertions(+), 50 deletions(-) delete mode 100644 src/common/dc-management-sdk-js/resource-status.ts diff --git a/package-lock.json b/package-lock.json index 5b792482..e8dd6ba5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2656,12 +2656,26 @@ "dev": true }, "dc-management-sdk-js": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/dc-management-sdk-js/-/dc-management-sdk-js-1.9.0.tgz", - "integrity": "sha512-ChljW30c/BJbDlCiEfXz4bBwlCFPidJWiI3CLXgxm2GPnMq/rQxX+P9K5YQ7iIigPeEZX6K2n5+Z/ai+qSBp0w==", + "version": "github:rs-amp/dc-management-sdk-js#302c89ed37fa00f4ad6bb5358280924cbc106d6b", + "from": "github:rs-amp/dc-management-sdk-js#feature/status", "requires": { - "axios": "^0.18.0", + "axios": "^0.21.1", "url-template": "^2.0.8" + }, + "dependencies": { + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, + "follow-redirects": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz", + "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==" + } } }, "debug": { diff --git a/package.json b/package.json index 56e45def..6ea3f645 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "ajv": "^6.12.3", "axios": "^0.18.1", "chalk": "^2.4.2", - "dc-management-sdk-js": "^1.9.0", + "dc-management-sdk-js": "github:rs-amp/dc-management-sdk-js#feature/status", "lodash": "^4.17.15", "node-fetch": "^2.6.0", "promise-retry": "^2.0.1", diff --git a/src/commands/content-item/archive.ts b/src/commands/content-item/archive.ts index 3aa9fc95..2d2d0799 100644 --- a/src/commands/content-item/archive.ts +++ b/src/commands/content-item/archive.ts @@ -5,11 +5,10 @@ import { ArchiveLog } from '../../common/archive/archive-log'; import paginator from '../../common/dc-management-sdk-js/paginator'; import { confirmArchive } from '../../common/archive/archive-helpers'; import ArchiveOptions from '../../common/archive/archive-options'; -import { ContentItem, DynamicContent } from 'dc-management-sdk-js'; +import { ContentItem, DynamicContent, Status } from 'dc-management-sdk-js'; import { equalsOrRegex } from '../../common/filter/filter'; import { getDefaultLogPath, createLog } from '../../common/log-helpers'; import { FileLog } from '../../common/file-log'; -import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'archive [id]'; diff --git a/src/commands/content-item/export.ts b/src/commands/content-item/export.ts index b31d0c12..c98ebc62 100644 --- a/src/commands/content-item/export.ts +++ b/src/commands/content-item/export.ts @@ -9,14 +9,13 @@ import { uniqueFilenamePath, writeJsonToFile } from '../../services/export.servi import { ExportItemBuilderOptions } from '../../interfaces/export-item-builder-options.interface'; import paginator from '../../common/dc-management-sdk-js/paginator'; -import { ContentItem, Folder, DynamicContent, Hub, ContentRepository } from 'dc-management-sdk-js'; +import { ContentItem, Folder, DynamicContent, Hub, ContentRepository, Status } from 'dc-management-sdk-js'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { ContentDependancyTree, RepositoryContentItem } from '../../common/content-item/content-dependancy-tree'; import { ContentMapping } from '../../common/content-item/content-mapping'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { AmplienceSchemaValidator, defaultSchemaLookup } from '../../common/content-item/amplience-schema-validator'; -import { Status } from '../../common/dc-management-sdk-js/resource-status'; interface PublishedContentItem { lastPublishedVersion?: number; diff --git a/src/commands/content-item/import-revert.spec.ts b/src/commands/content-item/import-revert.spec.ts index 0371d3ac..8a050a8c 100644 --- a/src/commands/content-item/import-revert.spec.ts +++ b/src/commands/content-item/import-revert.spec.ts @@ -9,6 +9,7 @@ import rmdir from 'rimraf'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { MockContent, ItemTemplate } from '../../common/dc-management-sdk-js/mock-content'; import { Status } from 'dc-management-sdk-js'; +import { FileLog } from '../../common/file-log'; jest.mock('readline'); jest.mock('../../services/dynamic-content-client-factory'); @@ -28,7 +29,8 @@ describe('revert tests', function() { const config = { clientId: 'client-id', clientSecret: 'client-id', - hubId: 'hub-id' + hubId: 'hub-id', + logFile: new FileLog() }; beforeAll(async () => { diff --git a/src/commands/content-item/unarchive.ts b/src/commands/content-item/unarchive.ts index e81d0e38..80252255 100644 --- a/src/commands/content-item/unarchive.ts +++ b/src/commands/content-item/unarchive.ts @@ -5,10 +5,9 @@ import { ArchiveLog } from '../../common/archive/archive-log'; import paginator from '../../common/dc-management-sdk-js/paginator'; import { confirmArchive } from '../../common/archive/archive-helpers'; import UnarchiveOptions from '../../common/archive/unarchive-options'; -import { ContentItem, DynamicContent } from 'dc-management-sdk-js'; +import { ContentItem, DynamicContent, Status } from 'dc-management-sdk-js'; import { equalsOrRegex } from '../../common/filter/filter'; import { getDefaultLogPath } from '../../common/log-helpers'; -import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'unarchive [id]'; diff --git a/src/commands/content-type-schema/archive.ts b/src/commands/content-type-schema/archive.ts index 43a23977..fc4ba6c2 100644 --- a/src/commands/content-type-schema/archive.ts +++ b/src/commands/content-type-schema/archive.ts @@ -1,6 +1,6 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; -import { ContentTypeSchema } from 'dc-management-sdk-js'; +import { ContentTypeSchema, Status } from 'dc-management-sdk-js'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ArchiveLog } from '../../common/archive/archive-log'; import paginator from '../../common/dc-management-sdk-js/paginator'; @@ -9,7 +9,6 @@ import { confirmArchive } from '../../common/archive/archive-helpers'; import ArchiveOptions from '../../common/archive/archive-options'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { FileLog } from '../../common/file-log'; -import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'archive [id]'; diff --git a/src/commands/content-type-schema/export.ts b/src/commands/content-type-schema/export.ts index eb6c55c6..2622971b 100644 --- a/src/commands/content-type-schema/export.ts +++ b/src/commands/content-type-schema/export.ts @@ -2,7 +2,7 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; -import { ContentTypeSchema } from 'dc-management-sdk-js'; +import { ContentTypeSchema, Status } from 'dc-management-sdk-js'; import { table } from 'table'; import { baseTableConfig } from '../../common/table/table.consts'; import chalk from 'chalk'; @@ -21,7 +21,6 @@ import { resolveSchemaBody } from '../../services/resolve-schema-body'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { FileLog } from '../../common/file-log'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; -import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const streamTableOptions = { ...baseTableConfig, diff --git a/src/commands/content-type-schema/import.ts b/src/commands/content-type-schema/import.ts index a56cf13e..3a4501d0 100644 --- a/src/commands/content-type-schema/import.ts +++ b/src/commands/content-type-schema/import.ts @@ -1,6 +1,6 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; -import { ContentTypeSchema, DynamicContent, Hub, ValidationLevel } from 'dc-management-sdk-js'; +import { ContentTypeSchema, DynamicContent, Hub, Status, ValidationLevel } from 'dc-management-sdk-js'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; import { table } from 'table'; @@ -13,7 +13,6 @@ import { ImportResult, loadJsonFromDirectory, UpdateStatus } from '../../service import { resolveSchemaBody } from '../../services/resolve-schema-body'; import { FileLog } from '../../common/file-log'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; -import { ResourceStatus, Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'import '; @@ -80,7 +79,7 @@ export const doUpdate = async ( return { contentTypeSchema: retrievedSchema, updateStatus: UpdateStatus.SKIPPED }; } - if ((retrievedSchema as ResourceStatus).status === Status.ARCHIVED) { + if (retrievedSchema.status === Status.ARCHIVED) { try { // Resurrect this schema before updating it. retrievedSchema = await retrievedSchema.related.unarchive(); diff --git a/src/commands/content-type-schema/unarchive.ts b/src/commands/content-type-schema/unarchive.ts index d3300f30..2b45e756 100644 --- a/src/commands/content-type-schema/unarchive.ts +++ b/src/commands/content-type-schema/unarchive.ts @@ -1,6 +1,6 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; -import { ContentTypeSchema } from 'dc-management-sdk-js'; +import { ContentTypeSchema, Status } from 'dc-management-sdk-js'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ArchiveLog } from '../../common/archive/archive-log'; import { equalsOrRegex } from '../../common/filter/filter'; @@ -8,7 +8,6 @@ import paginator from '../../common/dc-management-sdk-js/paginator'; import { confirmArchive } from '../../common/archive/archive-helpers'; import UnarchiveOptions from '../../common/archive/unarchive-options'; import { getDefaultLogPath } from '../../common/log-helpers'; -import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const LOG_FILENAME = (platform: string = process.platform): string => getDefaultLogPath('schema', 'unarchive', platform); diff --git a/src/commands/content-type/archive.ts b/src/commands/content-type/archive.ts index 97b2e12e..53ffc722 100644 --- a/src/commands/content-type/archive.ts +++ b/src/commands/content-type/archive.ts @@ -1,6 +1,6 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; -import { ContentType } from 'dc-management-sdk-js'; +import { ContentType, Status } from 'dc-management-sdk-js'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ArchiveLog } from '../../common/archive/archive-log'; import paginator from '../../common/dc-management-sdk-js/paginator'; @@ -10,7 +10,6 @@ import { confirmArchive } from '../../common/archive/archive-helpers'; import ArchiveOptions from '../../common/archive/archive-options'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { FileLog } from '../../common/file-log'; -import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'archive [id]'; diff --git a/src/commands/content-type/export.ts b/src/commands/content-type/export.ts index e72ec14e..ad00f743 100644 --- a/src/commands/content-type/export.ts +++ b/src/commands/content-type/export.ts @@ -2,7 +2,7 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; -import { ContentType } from 'dc-management-sdk-js'; +import { ContentType, Status } from 'dc-management-sdk-js'; import { table } from 'table'; import { streamTableOptions } from '../../common/table/table.consts'; import chalk from 'chalk'; @@ -20,7 +20,6 @@ import { ExportBuilderOptions } from '../../interfaces/export-builder-options.in import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { FileLog } from '../../common/file-log'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; -import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'export '; diff --git a/src/commands/content-type/import.ts b/src/commands/content-type/import.ts index 5ff9b020..3b38d20e 100644 --- a/src/commands/content-type/import.ts +++ b/src/commands/content-type/import.ts @@ -2,7 +2,7 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; -import { ContentRepository, ContentType, DynamicContent, Hub } from 'dc-management-sdk-js'; +import { ContentRepository, ContentType, DynamicContent, Hub, Status } from 'dc-management-sdk-js'; import { isEqual } from 'lodash'; import { table } from 'table'; import chalk from 'chalk'; @@ -11,7 +11,6 @@ import { streamTableOptions } from '../../common/table/table.consts'; import { ImportBuilderOptions } from '../../interfaces/import-builder-options.interface'; import { FileLog } from '../../common/file-log'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; -import { ResourceStatus, Status } from '../../common/dc-management-sdk-js/resource-status'; export const command = 'import '; @@ -134,7 +133,7 @@ export const doUpdate = async ( throw new Error(`Error unable to get content type ${contentType.id}: ${err.message}`); } - if ((retrievedContentType as ResourceStatus).status === Status.ARCHIVED) { + if (retrievedContentType.status === Status.ARCHIVED) { try { // Resurrect this type before updating it. retrievedContentType = await retrievedContentType.related.unarchive(); diff --git a/src/commands/content-type/unarchive.ts b/src/commands/content-type/unarchive.ts index 75865352..2ecc99db 100644 --- a/src/commands/content-type/unarchive.ts +++ b/src/commands/content-type/unarchive.ts @@ -1,6 +1,6 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; -import { ContentType } from 'dc-management-sdk-js'; +import { ContentType, Status } from 'dc-management-sdk-js'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ArchiveLog } from '../../common/archive/archive-log'; import { equalsOrRegex } from '../../common/filter/filter'; @@ -8,7 +8,6 @@ import paginator from '../../common/dc-management-sdk-js/paginator'; import { confirmArchive } from '../../common/archive/archive-helpers'; import UnarchiveOptions from '../../common/archive/unarchive-options'; import { getDefaultLogPath } from '../../common/log-helpers'; -import { Status } from '../../common/dc-management-sdk-js/resource-status'; export const LOG_FILENAME = (platform: string = process.platform): string => getDefaultLogPath('type', 'unarchive', platform); diff --git a/src/commands/hub/steps/content-clone-step.spec.ts b/src/commands/hub/steps/content-clone-step.spec.ts index 96118e05..dc218ee8 100644 --- a/src/commands/hub/steps/content-clone-step.spec.ts +++ b/src/commands/hub/steps/content-clone-step.spec.ts @@ -42,6 +42,7 @@ describe('content clone step', () => { const argv: Arguments = { ...yargArgs, ...config, + logFile: new FileLog(), dir: directory, diff --git a/src/commands/hub/steps/schema-clone-step.spec.ts b/src/commands/hub/steps/schema-clone-step.spec.ts index 6892c2ab..97c4117d 100644 --- a/src/commands/hub/steps/schema-clone-step.spec.ts +++ b/src/commands/hub/steps/schema-clone-step.spec.ts @@ -64,6 +64,7 @@ describe('schema clone step', () => { const argv: Arguments = { ...yargArgs, ...config, + logFile: new FileLog(), dir: directory, diff --git a/src/commands/hub/steps/schema-clone-step.ts b/src/commands/hub/steps/schema-clone-step.ts index 22da9c18..83e163cc 100644 --- a/src/commands/hub/steps/schema-clone-step.ts +++ b/src/commands/hub/steps/schema-clone-step.ts @@ -7,7 +7,7 @@ import { handler as importSchema } from '../../content-type-schema/import'; import dynamicContentClientFactory from '../../../services/dynamic-content-client-factory'; import paginator from '../../../common/dc-management-sdk-js/paginator'; import { FileLog } from '../../../common/file-log'; -import { ResourceStatus, Status } from '../../../common/dc-management-sdk-js/resource-status'; +import { Status } from 'dc-management-sdk-js'; export class SchemaCloneStep implements CloneHubStep { getName(): string { @@ -54,7 +54,7 @@ export class SchemaCloneStep implements CloneHubStep { for (const id of toArchive) { try { const schema = await client.contentTypeSchemas.get(id); - if ((schema as ResourceStatus).status == Status.ACTIVE) { + if (schema.status === Status.ACTIVE) { await schema.related.archive(); } } catch (e) { diff --git a/src/commands/hub/steps/settings-clone-step.spec.ts b/src/commands/hub/steps/settings-clone-step.spec.ts index 87aa817b..2512062b 100644 --- a/src/commands/hub/steps/settings-clone-step.spec.ts +++ b/src/commands/hub/steps/settings-clone-step.spec.ts @@ -41,6 +41,7 @@ describe('settings clone step', () => { const argv: Arguments = { ...yargArgs, ...config, + logFile: new FileLog(), dir: directory, diff --git a/src/commands/hub/steps/type-clone-step.spec.ts b/src/commands/hub/steps/type-clone-step.spec.ts index f4d1e3b8..f82bdf81 100644 --- a/src/commands/hub/steps/type-clone-step.spec.ts +++ b/src/commands/hub/steps/type-clone-step.spec.ts @@ -64,6 +64,7 @@ describe('type clone step', () => { const argv: Arguments = { ...yargArgs, ...config, + logFile: new FileLog(), dir: directory, diff --git a/src/common/dc-management-sdk-js/mock-content.ts b/src/common/dc-management-sdk-js/mock-content.ts index da0147ae..aa47cf02 100644 --- a/src/common/dc-management-sdk-js/mock-content.ts +++ b/src/common/dc-management-sdk-js/mock-content.ts @@ -13,7 +13,6 @@ import { ContentTypeCachedSchema } from 'dc-management-sdk-js'; import MockPage from './mock-page'; -import { ResourceStatus, Status as TypeStatus } from './resource-status'; export interface ItemTemplate { label: string; @@ -335,7 +334,7 @@ export class MockContent { mockItemArchive.mockImplementation(() => { if (this.failItemActions) throw new Error('Simulated network failure.'); - if (item.status != Status.ACTIVE) { + if (item.status !== Status.ACTIVE) { throw new Error('Cannot archive content that is already archived.'); } @@ -348,7 +347,7 @@ export class MockContent { mockItemUnarchive.mockImplementation(() => { if (this.failItemActions) throw new Error('Simulated network failure.'); - if (item.status == Status.ACTIVE) { + if (item.status === Status.ACTIVE) { throw new Error('Cannot unarchive content that is not archived.'); } @@ -400,13 +399,13 @@ export class MockContent { mockSchemaArchive.mockImplementation(() => { if (this.failSchemaActions) throw new Error('Simulated network failure.'); - if ((schema as ResourceStatus).status != TypeStatus.ACTIVE) { + if (schema.status !== Status.ACTIVE) { throw new Error('Cannot archive content that is already archived.'); } this.metrics.typeSchemasArchived++; - (schema as ResourceStatus).status = TypeStatus.ARCHIVED; + schema.status = Status.ARCHIVED; return Promise.resolve(schema); }); @@ -462,13 +461,13 @@ export class MockContent { mockTypeArchive.mockImplementation(() => { if (this.failTypeActions) throw new Error('Simulated network failure.'); - if ((type as ResourceStatus).status != TypeStatus.ACTIVE) { + if (type.status !== Status.ACTIVE) { throw new Error('Cannot archive content that is already archived.'); } this.metrics.typesArchived++; - (type as ResourceStatus).status = TypeStatus.ARCHIVED; + type.status = Status.ARCHIVED; return Promise.resolve(type); }); diff --git a/src/common/dc-management-sdk-js/paginator.ts b/src/common/dc-management-sdk-js/paginator.ts index ed8a043b..8cedb073 100644 --- a/src/common/dc-management-sdk-js/paginator.ts +++ b/src/common/dc-management-sdk-js/paginator.ts @@ -1,8 +1,11 @@ -import { HalResource, Page, Pageable, Sortable } from 'dc-management-sdk-js'; -import { ResourceStatus } from './resource-status'; +import { HalResource, Page, Pageable, Sortable, Status } from 'dc-management-sdk-js'; export const DEFAULT_SIZE = 100; +interface ResourceStatus { + status?: Status; +} + const paginator = async ( pagableFn: (options?: Pageable & Sortable & ResourceStatus) => Promise>, options: Pageable & Sortable & ResourceStatus = {} diff --git a/src/common/dc-management-sdk-js/resource-status.ts b/src/common/dc-management-sdk-js/resource-status.ts deleted file mode 100644 index e209d09e..00000000 --- a/src/common/dc-management-sdk-js/resource-status.ts +++ /dev/null @@ -1,9 +0,0 @@ -export enum Status { - ACTIVE = 'ACTIVE', - ARCHIVED = 'ARCHIVED', - DELETED = 'DELETED' -} - -export interface ResourceStatus { - status?: Status; -} From e97993d26361b998d0f45ae39b2b3ce024cd68e6 Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 1 Jun 2021 22:44:40 +0100 Subject: [PATCH 12/15] refactor: revertLog now passes around FileLog object similar to logFile param --- package-lock.json | 18 +++++-- src/commands/content-item/copy.spec.ts | 41 ++++++++++++++-- src/commands/content-item/copy.ts | 19 +++++-- .../content-item/import-revert.spec.ts | 34 ++++++++----- src/commands/content-item/import-revert.ts | 18 ++----- src/commands/content-item/import.spec.ts | 6 ++- src/commands/content-item/import.ts | 2 +- src/commands/content-item/move.spec.ts | 21 ++++---- src/commands/content-item/move.ts | 27 +++++----- src/commands/hub/clone.spec.ts | 18 ++++--- src/commands/hub/clone.ts | 49 +++++++++---------- .../hub/steps/content-clone-step.spec.ts | 7 +-- src/commands/hub/steps/content-clone-step.ts | 2 +- .../hub/steps/schema-clone-step.spec.ts | 3 +- .../hub/steps/settings-clone-step.spec.ts | 3 +- .../hub/steps/type-clone-step.spec.ts | 3 +- src/common/archive/archive-log.ts | 3 +- src/common/content-item/copy-config.spec.ts | 4 +- .../dc-management-sdk-js/resource-status.ts | 9 ++++ src/common/log-helpers.ts | 17 +++++++ src/interfaces/clone-hub-builder-options.ts | 2 +- .../copy-item-builder-options.interface.ts | 2 +- .../import-item-builder-options.interface.ts | 2 +- 23 files changed, 202 insertions(+), 108 deletions(-) create mode 100644 src/common/dc-management-sdk-js/resource-status.ts diff --git a/package-lock.json b/package-lock.json index e8dd6ba5..78d33c10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8829,9 +8829,9 @@ "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==" }, "yargs": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.0.0.tgz", - "integrity": "sha512-ssa5JuRjMeZEUjg7bEL99AwpitxU/zWGAGpdj0di41pOEmJti8NR6kyUIJBkR78DTYNPZOU08luUo0GTHuB+ow==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", + "integrity": "sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==", "requires": { "cliui": "^5.0.0", "decamelize": "^1.2.0", @@ -8843,7 +8843,7 @@ "string-width": "^3.0.0", "which-module": "^2.0.0", "y18n": "^4.0.0", - "yargs-parser": "^13.1.1" + "yargs-parser": "^15.0.1" }, "dependencies": { "string-width": { @@ -8855,6 +8855,15 @@ "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^5.1.0" } + }, + "yargs-parser": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.1.tgz", + "integrity": "sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } } } }, @@ -8862,6 +8871,7 @@ "version": "13.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.1.tgz", "integrity": "sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==", + "dev": true, "requires": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" diff --git a/src/commands/content-item/copy.spec.ts b/src/commands/content-item/copy.spec.ts index 85b85b0e..96bada43 100644 --- a/src/commands/content-item/copy.spec.ts +++ b/src/commands/content-item/copy.spec.ts @@ -13,7 +13,7 @@ import { Arguments } from 'yargs'; import { ExportItemBuilderOptions } from '../../interfaces/export-item-builder-options.interface'; import { ConfigurationParameters } from '../configure'; import { ImportItemBuilderOptions } from '../../interfaces/import-item-builder-options.interface'; -import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath, openRevertLog } from '../../common/log-helpers'; import * as copyConfig from '../../common/content-item/copy-config'; import { FileLog } from '../../common/file-log'; @@ -22,7 +22,10 @@ jest.mock('../../services/dynamic-content-client-factory'); jest.mock('./export'); jest.mock('./import'); jest.mock('./import-revert'); -jest.mock('../../common/log-helpers'); +jest.mock('../../common/log-helpers', () => ({ + ...jest.requireActual('../../common/log-helpers'), + getDefaultLogPath: jest.fn() +})); function rimraf(dir: string): Promise { return new Promise((resolve): void => { @@ -49,7 +52,8 @@ describe('content-item copy command', () => { expect(spyOption).toHaveBeenCalledWith('revertLog', { type: 'string', describe: - 'Path to a log file to revert a copy for. This will archive the most recently copied resources, and revert updated ones.' + 'Path to a log file to revert a copy for. This will archive the most recently copied resources, and revert updated ones.', + coerce: openRevertLog }); expect(spyOption).toHaveBeenCalledWith('srcRepo', { @@ -173,7 +177,9 @@ describe('content-item copy command', () => { clientId: 'client-id', clientSecret: 'client-id', hubId: 'hub-id', - logFile: new FileLog() + + logFile: new FileLog(), + revertLog: Promise.resolve(undefined) }; beforeAll(async () => { @@ -285,7 +291,7 @@ describe('content-item copy command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - revertLog: 'revertTest.txt' + revertLog: Promise.resolve(new FileLog()) }; await handler(argv); @@ -299,6 +305,31 @@ describe('content-item copy command', () => { expect(revertCalls[0].revertLog).toEqual(argv.revertLog); }); + it('should exit early when revertLog is not present.', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const exportCalls: Arguments[] = (exporter as any).calls; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const importCalls: Arguments[] = (importer as any).calls; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const revertCalls: Arguments[] = (reverter as any).calls; + + const argv = { + ...yargArgs, + ...config, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + + revertLog: openRevertLog('temp/copy/revertMissing.txt') + }; + await handler(argv); + + expect(exportCalls.length).toEqual(0); + expect(importCalls.length).toEqual(0); + expect(revertCalls.length).toEqual(0); + }); + it('should return false and remove temp folder when import fails or throws.', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const exportCalls: Arguments[] = (exporter as any).calls; diff --git a/src/commands/content-item/copy.ts b/src/commands/content-item/copy.ts index b040c3cf..9530fc04 100644 --- a/src/commands/content-item/copy.ts +++ b/src/commands/content-item/copy.ts @@ -1,4 +1,4 @@ -import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath, openRevertLog } from '../../common/log-helpers'; import { Argv, Arguments } from 'yargs'; import { join } from 'path'; import { CopyItemBuilderOptions } from '../../interfaces/copy-item-builder-options.interface'; @@ -11,6 +11,7 @@ import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { revert } from './import-revert'; import { loadCopyConfig } from '../../common/content-item/copy-config'; import { FileLog } from '../../common/file-log'; +import { LogErrorLevel } from '../../common/archive/archive-log'; export function getTempFolder(name: string, platform: string = process.platform): string { return join(process.env[platform == 'win32' ? 'USERPROFILE' : 'HOME'] || __dirname, '.amplience', `copy-${name}/`); @@ -28,7 +29,8 @@ export const builder = (yargs: Argv): void => { .option('revertLog', { type: 'string', describe: - 'Path to a log file to revert a copy for. This will archive the most recently copied resources, and revert updated ones.' + 'Path to a log file to revert a copy for. This will archive the most recently copied resources, and revert updated ones.', + coerce: openRevertLog }) .option('srcRepo', { @@ -168,7 +170,15 @@ export const handler = async (argv: Arguments): Promise => { - let log: FileLog; - - if (typeof argv.revertLog === 'string') { - log = new FileLog(); - try { - await log.loadFromFile(argv.revertLog as string); - } catch (e) { - console.log('Could not open the import log! Aborting.'); - return false; - } - } else { - log = argv.revertLog as FileLog; + const log = await argv.revertLog; + if (!log || log.errorLevel === LogErrorLevel.INVALID) { + console.log('No valid log file provided. Aborting.'); + return false; } // We just need to access the destination repo to undo a import. diff --git a/src/commands/content-item/import.spec.ts b/src/commands/content-item/import.spec.ts index 7186470d..1ef4b1e8 100644 --- a/src/commands/content-item/import.spec.ts +++ b/src/commands/content-item/import.spec.ts @@ -149,7 +149,9 @@ describe('content-item import command', () => { clientId: 'client-id', clientSecret: 'client-id', hubId: 'hub-id', - logFile: new FileLog() + + logFile: new FileLog(), + revertLog: Promise.resolve(undefined) }; beforeEach(async () => { @@ -1085,7 +1087,7 @@ describe('content-item import command', () => { ...yargArgs, ...config, dir: 'temp/import/unused/', - revertLog: 'log.txt' + revertLog: Promise.resolve(new FileLog()) }; expect(await handler(argv)).toBeTruthy(); diff --git a/src/commands/content-item/import.ts b/src/commands/content-item/import.ts index 17ce8407..ba8dbdda 100644 --- a/src/commands/content-item/import.ts +++ b/src/commands/content-item/import.ts @@ -854,7 +854,7 @@ const importTree = async ( export const handler = async ( argv: Arguments ): Promise => { - if (argv.revertLog != null) { + if (await argv.revertLog) { return revert(argv); } diff --git a/src/commands/content-item/move.spec.ts b/src/commands/content-item/move.spec.ts index fd332d0d..b3493e0f 100644 --- a/src/commands/content-item/move.spec.ts +++ b/src/commands/content-item/move.spec.ts @@ -18,7 +18,7 @@ import { CopyItemBuilderOptions } from '../../interfaces/copy-item-builder-optio import { ItemTemplate, MockContent } from '../../common/dc-management-sdk-js/mock-content'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; -import { getDefaultLogPath, createLog as createFileLog, createLog } from '../../common/log-helpers'; +import { getDefaultLogPath, createLog as createFileLog, createLog, openRevertLog } from '../../common/log-helpers'; import * as copyConfig from '../../common/content-item/copy-config'; import { ImportItemBuilderOptions } from '../../interfaces/import-item-builder-options.interface'; import { FileLog } from '../../common/file-log'; @@ -59,7 +59,8 @@ describe('content-item move command', () => { expect(spyOption).toHaveBeenCalledWith('revertLog', { type: 'string', describe: - 'Path to a log file to revert a move for. This will archive the most recently moved resources from the destination, unarchive from the source, and revert updated ones.' + 'Path to a log file to revert a move for. This will archive the most recently moved resources from the destination, unarchive from the source, and revert updated ones.', + coerce: openRevertLog }); expect(spyOption).toHaveBeenCalledWith('srcRepo', { @@ -158,7 +159,9 @@ describe('content-item move command', () => { clientId: 'client-id', clientSecret: 'client-id', hubId: 'hub-id', - logFile: new FileLog() + + logFile: new FileLog(), + revertLog: Promise.resolve(undefined) }; beforeAll(async () => { @@ -300,7 +303,7 @@ describe('content-item move command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - revertLog: 'temp/move/moveRevert.txt' + revertLog: openRevertLog('temp/move/moveRevert.txt') }; await handler(argv); @@ -316,7 +319,7 @@ describe('content-item move command', () => { clientSecret: 'acc2-secret', dir: '', hubId: 'hub2-id', - revertLog: 'temp/move/moveRevert.txt', + revertLog: expect.any(Promise), logFile: expect.any(FileLog) }); @@ -356,7 +359,7 @@ describe('content-item move command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - revertLog: 'temp/move/moveRevertFetch.txt' + revertLog: openRevertLog('temp/move/moveRevertFetch.txt') }; await handler(argv); @@ -368,8 +371,6 @@ describe('content-item move command', () => { // should revert uninterrupted when unarchiving an item fails - // should abort early when passing a missing revert log - it('should abort early when passing a missing revert log', async () => { const copyCalls: Arguments[] = copierAny.calls; @@ -391,7 +392,7 @@ describe('content-item move command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - revertLog: 'temp/move/moveRevertMissing.txt' + revertLog: openRevertLog('temp/move/moveRevertMissing.txt') }; await handler(argv); @@ -514,7 +515,7 @@ describe('content-item move command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - revertLog: 'temp/move/abort.txt' + revertLog: Promise.resolve(new FileLog()) }; await handler(argv); diff --git a/src/commands/content-item/move.ts b/src/commands/content-item/move.ts index 635b141a..7d9a8294 100644 --- a/src/commands/content-item/move.ts +++ b/src/commands/content-item/move.ts @@ -1,4 +1,4 @@ -import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath, openRevertLog } from '../../common/log-helpers'; import { Argv, Arguments } from 'yargs'; import { CopyItemBuilderOptions } from '../../interfaces/copy-item-builder-options.interface'; import { ConfigurationParameters } from '../configure'; @@ -10,6 +10,7 @@ import dynamicContentClientFactory from '../../services/dynamic-content-client-f import { ContentItem, Status } from 'dc-management-sdk-js'; import { loadCopyConfig } from '../../common/content-item/copy-config'; import { revert } from './import-revert'; +import { LogErrorLevel } from '../../common/archive/archive-log'; export const command = 'move'; @@ -23,7 +24,8 @@ export const builder = (yargs: Argv): void => { .option('revertLog', { type: 'string', describe: - 'Path to a log file to revert a move for. This will archive the most recently moved resources from the destination, unarchive from the source, and revert updated ones.' + 'Path to a log file to revert a move for. This will archive the most recently moved resources from the destination, unarchive from the source, and revert updated ones.', + coerce: openRevertLog }) .option('srcRepo', { @@ -140,7 +142,14 @@ export const builder = (yargs: Argv): void => { export const handler = async (argv: Arguments): Promise => { argv.exportedIds = []; - if (argv.revertLog != null) { + const revertLog = await argv.revertLog; + + if (revertLog) { + if (revertLog.errorLevel === LogErrorLevel.INVALID) { + console.error('Could not read the revert log.'); + return; + } + const copyConfig = await loadCopyConfig(argv, new FileLog()); if (copyConfig == null) { @@ -154,15 +163,7 @@ export const handler = async (argv: Arguments { expect(spyOption).toHaveBeenCalledWith('revertLog', { type: 'string', describe: - 'Revert a previous clone using a given revert log and given directory. Reverts steps in reverse order, starting at the specified one.' + 'Revert a previous clone using a given revert log and given directory. Reverts steps in reverse order, starting at the specified one.', + coerce: openRevertLog }); expect(spyOption).toHaveBeenCalledWith('logFile', { @@ -215,7 +216,9 @@ describe('hub clone command', () => { const config = { clientId: 'client-id', clientSecret: 'client-id', - hubId: 'hub-id' + hubId: 'hub-id', + + revertLog: Promise.resolve(undefined) }; beforeAll(async () => { @@ -444,7 +447,7 @@ describe('hub clone command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', logFile: createLog('temp/clone-revert/steps/all.log'), - revertLog: 'temp/clone-revert/steps.log', + revertLog: openRevertLog('temp/clone-revert/steps.log'), mapFile: 'temp/clone-revert/steps/all.json', force: false, @@ -487,7 +490,7 @@ describe('hub clone command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', logFile: createLog('temp/clone-revert/fail/fail' + i + '.log'), - revertLog: 'temp/clone-revert/fail.log', + revertLog: openRevertLog('temp/clone-revert/fail.log'), mapFile: 'temp/clone-revert/fail/fail' + i + '.json', force: false, @@ -532,7 +535,7 @@ describe('hub clone command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', logFile: createLog('temp/clone-revert/steps/early.log'), - revertLog: 'temp/clone-revert/missing.log', + revertLog: openRevertLog('temp/clone-revert/missing.log'), mapFile: 'temp/clone-revert/steps/all.json', force: false, @@ -540,6 +543,7 @@ describe('hub clone command', () => { skipIncomplete: false, media: true }; + await handler(argv); const mocks = getMocks(); @@ -574,7 +578,7 @@ describe('hub clone command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', logFile: createLog('temp/clone-revert/step/step' + i + '.log'), - revertLog: 'temp/clone-revert/step.log', + revertLog: openRevertLog('temp/clone-revert/step.log'), mapFile: 'temp/clone-revert/step/step' + i + '.json', force: false, diff --git a/src/commands/hub/clone.ts b/src/commands/hub/clone.ts index 6d42ad11..a1e7887b 100644 --- a/src/commands/hub/clone.ts +++ b/src/commands/hub/clone.ts @@ -1,4 +1,4 @@ -import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath, openRevertLog } from '../../common/log-helpers'; import { Argv, Arguments } from 'yargs'; import { join } from 'path'; import { ConfigurationParameters } from '../configure'; @@ -13,6 +13,7 @@ import { SchemaCloneStep } from './steps/schema-clone-step'; import { SettingsCloneStep } from './steps/settings-clone-step'; import { TypeCloneStep } from './steps/type-clone-step'; import { CloneHubState } from './model/clone-hub-state'; +import { LogErrorLevel } from '../../common/archive/archive-log'; export function getDefaultMappingPath(name: string, platform: string = process.platform): string { return join( @@ -129,7 +130,8 @@ export const builder = (yargs: Argv): void => { .option('revertLog', { type: 'string', describe: - 'Revert a previous clone using a given revert log and given directory. Reverts steps in reverse order, starting at the specified one.' + 'Revert a previous clone using a given revert log and given directory. Reverts steps in reverse order, starting at the specified one.', + coerce: openRevertLog }) .option('step', { @@ -189,36 +191,33 @@ export const handler = async (argv: Arguments { dstHubId: 'hub2-id', dstClientId: 'acc2-id', - dstSecret: 'acc2-secret' + dstSecret: 'acc2-secret', + revertLog: Promise.resolve(new FileLog()) }; return { @@ -90,7 +91,7 @@ describe('content clone step', () => { ...state.argv, dir: join(state.path, 'content'), logFile: state.logFile, - revertLog: state.revertLog + revertLog: expect.any(Promise) } ]); @@ -127,7 +128,7 @@ describe('content clone step', () => { ...state.argv, dir: join(state.path, 'content'), logFile: state.logFile, - revertLog: state.revertLog + revertLog: expect.any(Promise) } ]); diff --git a/src/commands/hub/steps/content-clone-step.ts b/src/commands/hub/steps/content-clone-step.ts index 310a1419..ac0d5048 100644 --- a/src/commands/hub/steps/content-clone-step.ts +++ b/src/commands/hub/steps/content-clone-step.ts @@ -25,7 +25,7 @@ export class ContentCloneStep implements CloneHubStep { ...state.argv, dir: join(state.path, 'content'), logFile: state.logFile, - revertLog: state.revertLog + revertLog: Promise.resolve(state.revertLog) }); return revertSuccess; diff --git a/src/commands/hub/steps/schema-clone-step.spec.ts b/src/commands/hub/steps/schema-clone-step.spec.ts index 97c4117d..619058a6 100644 --- a/src/commands/hub/steps/schema-clone-step.spec.ts +++ b/src/commands/hub/steps/schema-clone-step.spec.ts @@ -70,7 +70,8 @@ describe('schema clone step', () => { dstHubId: 'hub2-id', dstClientId: 'acc2-id', - dstSecret: 'acc2-secret' + dstSecret: 'acc2-secret', + revertLog: Promise.resolve(new FileLog()) }; return { diff --git a/src/commands/hub/steps/settings-clone-step.spec.ts b/src/commands/hub/steps/settings-clone-step.spec.ts index 2512062b..fe8f15ba 100644 --- a/src/commands/hub/steps/settings-clone-step.spec.ts +++ b/src/commands/hub/steps/settings-clone-step.spec.ts @@ -47,7 +47,8 @@ describe('settings clone step', () => { dstHubId: 'hub2-id', dstClientId: 'acc2-id', - dstSecret: 'acc2-secret' + dstSecret: 'acc2-secret', + revertLog: Promise.resolve(new FileLog()) }; return { diff --git a/src/commands/hub/steps/type-clone-step.spec.ts b/src/commands/hub/steps/type-clone-step.spec.ts index f82bdf81..c774111b 100644 --- a/src/commands/hub/steps/type-clone-step.spec.ts +++ b/src/commands/hub/steps/type-clone-step.spec.ts @@ -70,7 +70,8 @@ describe('type clone step', () => { dstHubId: 'hub2-id', dstClientId: 'acc2-id', - dstSecret: 'acc2-secret' + dstSecret: 'acc2-secret', + revertLog: Promise.resolve(new FileLog()) }; return { diff --git a/src/common/archive/archive-log.ts b/src/common/archive/archive-log.ts index d7658f6b..e5d26d9d 100644 --- a/src/common/archive/archive-log.ts +++ b/src/common/archive/archive-log.ts @@ -12,7 +12,8 @@ export interface ArchiveLogItem { export enum LogErrorLevel { NONE = 0, WARNING, - ERROR + ERROR, + INVALID } export class ArchiveLog { diff --git a/src/common/content-item/copy-config.spec.ts b/src/common/content-item/copy-config.spec.ts index 23ce188d..3e7b4906 100644 --- a/src/common/content-item/copy-config.spec.ts +++ b/src/common/content-item/copy-config.spec.ts @@ -13,7 +13,9 @@ const yargArgs = { $0: 'test', _: ['test'], json: true, - logFile: new FileLog() + + logFile: new FileLog(), + revertLog: Promise.resolve(undefined) }; describe('copy-config', () => { diff --git a/src/common/dc-management-sdk-js/resource-status.ts b/src/common/dc-management-sdk-js/resource-status.ts new file mode 100644 index 00000000..e209d09e --- /dev/null +++ b/src/common/dc-management-sdk-js/resource-status.ts @@ -0,0 +1,9 @@ +export enum Status { + ACTIVE = 'ACTIVE', + ARCHIVED = 'ARCHIVED', + DELETED = 'DELETED' +} + +export interface ResourceStatus { + status?: Status; +} diff --git a/src/common/log-helpers.ts b/src/common/log-helpers.ts index 971fc4e1..7a69e3b7 100644 --- a/src/common/log-helpers.ts +++ b/src/common/log-helpers.ts @@ -1,4 +1,5 @@ import { join } from 'path'; +import { LogErrorLevel } from './archive/archive-log'; import { FileLog } from './file-log'; export function getDefaultLogPath(type: string, action: string, platform: string = process.platform): string { @@ -20,3 +21,19 @@ export function createLog(logFile: string, title?: string): FileLog { return log; } + +export async function openRevertLog(filename: string): Promise { + if (filename == null) { + return undefined; + } + + const log = new FileLog(); + + try { + await log.loadFromFile(filename); + } catch { + log.errorLevel = LogErrorLevel.INVALID; + } + + return log; +} diff --git a/src/interfaces/clone-hub-builder-options.ts b/src/interfaces/clone-hub-builder-options.ts index d8f55c38..2ac64cd7 100644 --- a/src/interfaces/clone-hub-builder-options.ts +++ b/src/interfaces/clone-hub-builder-options.ts @@ -8,7 +8,7 @@ export interface CloneHubBuilderOptions { dstClientId?: string; dstSecret?: string; - revertLog?: string; + revertLog: Promise; step?: number; mapFile?: string; diff --git a/src/interfaces/copy-item-builder-options.interface.ts b/src/interfaces/copy-item-builder-options.interface.ts index 7de4d004..a123789e 100644 --- a/src/interfaces/copy-item-builder-options.interface.ts +++ b/src/interfaces/copy-item-builder-options.interface.ts @@ -23,7 +23,7 @@ export interface CopyItemBuilderOptions { logFile: FileLog; copyConfig?: string | CopyConfig; - revertLog?: string | FileLog; + revertLog: Promise; lastPublish?: boolean; publish?: boolean; diff --git a/src/interfaces/import-item-builder-options.interface.ts b/src/interfaces/import-item-builder-options.interface.ts index 2228907c..2a387937 100644 --- a/src/interfaces/import-item-builder-options.interface.ts +++ b/src/interfaces/import-item-builder-options.interface.ts @@ -14,5 +14,5 @@ export interface ImportItemBuilderOptions { media?: boolean; logFile: FileLog; - revertLog?: string | FileLog; + revertLog: Promise; } From 829b29104e637a8397c45450134a17a855508be6 Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 1 Jun 2021 22:56:42 +0100 Subject: [PATCH 13/15] refactor: address more feedback --- src/commands/content-item/move.ts | 2 +- src/commands/event/archive.ts | 2 +- src/commands/hub/clone.spec.ts | 5 ----- src/commands/hub/clone.ts | 1 - 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/commands/content-item/move.ts b/src/commands/content-item/move.ts index 7d9a8294..050d4567 100644 --- a/src/commands/content-item/move.ts +++ b/src/commands/content-item/move.ts @@ -203,7 +203,7 @@ export const handler = async (argv: Arguments ({ jest.mock('./steps/type-clone-step', () => ({ TypeCloneStep: mockStep('Clone Content Types', () => success[2]) })); jest.mock('./steps/content-clone-step', () => ({ ContentCloneStep: mockStep('Clone Content', () => success[3]) })); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const copierAny = copier as any; - jest.mock('../../common/log-helpers', () => ({ ...jest.requireActual('../../common/log-helpers'), getDefaultLogPath: jest.fn() diff --git a/src/commands/hub/clone.ts b/src/commands/hub/clone.ts index a1e7887b..168fa86a 100644 --- a/src/commands/hub/clone.ts +++ b/src/commands/hub/clone.ts @@ -4,7 +4,6 @@ import { join } from 'path'; import { ConfigurationParameters } from '../configure'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; -import { FileLog } from '../../common/file-log'; import { loadCopyConfig } from '../../common/content-item/copy-config'; import { CloneHubBuilderOptions } from '../../interfaces/clone-hub-builder-options'; From 7e9b7155ecda9f7464b6ea7b44539d87730e1cb1 Mon Sep 17 00:00:00 2001 From: Rhys Date: Wed, 16 Jun 2021 15:16:28 +0100 Subject: [PATCH 14/15] refactor: update dc-mangement-sdk-js --- package-lock.json | 5 +++-- package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 78d33c10..c0d43fec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2656,8 +2656,9 @@ "dev": true }, "dc-management-sdk-js": { - "version": "github:rs-amp/dc-management-sdk-js#302c89ed37fa00f4ad6bb5358280924cbc106d6b", - "from": "github:rs-amp/dc-management-sdk-js#feature/status", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/dc-management-sdk-js/-/dc-management-sdk-js-1.13.0.tgz", + "integrity": "sha512-E97UYNvDqLQ80SvxV1T73/1k6Qb43+kV043QJIiB5QgYIiyRIleBOIX5NCzZzzb65Ti0D7WOvSqYHoVM8lQ4Ag==", "requires": { "axios": "^0.21.1", "url-template": "^2.0.8" diff --git a/package.json b/package.json index 6ea3f645..457f38ba 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "ajv": "^6.12.3", "axios": "^0.18.1", "chalk": "^2.4.2", - "dc-management-sdk-js": "github:rs-amp/dc-management-sdk-js#feature/status", + "dc-management-sdk-js": "^1.13.0", "lodash": "^4.17.15", "node-fetch": "^2.6.0", "promise-retry": "^2.0.1", From bcd4c7e23e314cebf6328dcb3edd33a1f951f335 Mon Sep 17 00:00:00 2001 From: Rhys Date: Thu, 17 Jun 2021 12:01:00 +0100 Subject: [PATCH 15/15] refactor: use string enum for the --step argument --- src/commands/hub/clean.spec.ts | 2 +- src/commands/hub/clone.spec.ts | 47 +++++++++++++++---- src/commands/hub/clone.ts | 23 +++++---- src/commands/hub/model/clone-hub-step.ts | 8 ++++ .../hub/steps/content-clone-step.spec.ts | 6 +++ src/commands/hub/steps/content-clone-step.ts | 6 ++- .../hub/steps/schema-clone-step.spec.ts | 6 +++ src/commands/hub/steps/schema-clone-step.ts | 6 ++- .../hub/steps/settings-clone-step.spec.ts | 6 +++ src/commands/hub/steps/settings-clone-step.ts | 6 ++- .../hub/steps/type-clone-step.spec.ts | 6 +++ src/commands/hub/steps/type-clone-step.ts | 6 ++- src/interfaces/clone-hub-builder-options.ts | 3 +- 13 files changed, 106 insertions(+), 25 deletions(-) diff --git a/src/commands/hub/clean.spec.ts b/src/commands/hub/clean.spec.ts index 490a904c..64320b2c 100644 --- a/src/commands/hub/clean.spec.ts +++ b/src/commands/hub/clean.spec.ts @@ -210,7 +210,7 @@ describe('hub clean command', () => { ...yargArgs, ...config, - step: Object.values(CleanHubStepId)[i], + step: steps[i].getId(), logFile: createLog('temp/clean/steps/step' + i + '.log'), force: true }; diff --git a/src/commands/hub/clone.spec.ts b/src/commands/hub/clone.spec.ts index d40ba19f..d864def9 100644 --- a/src/commands/hub/clone.spec.ts +++ b/src/commands/hub/clone.spec.ts @@ -1,4 +1,4 @@ -import { builder, command, handler, LOG_FILENAME, getDefaultMappingPath } from './clone'; +import { builder, command, handler, LOG_FILENAME, getDefaultMappingPath, steps } from './clone'; import { createLog, getDefaultLogPath, openRevertLog } from '../../common/log-helpers'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import Yargs from 'yargs/yargs'; @@ -14,6 +14,7 @@ import { ConfigurationParameters } from '../configure'; import { Arguments } from 'yargs'; import { FileLog } from '../../common/file-log'; import { CloneHubState } from './model/clone-hub-state'; +import { CloneHubStepId } from './model/clone-hub-step'; jest.mock('readline'); @@ -28,20 +29,30 @@ function succeedOrFail(mock: any, succeed: () => boolean): jest.Mock { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -function mockStep(name: string, success: () => boolean): any { +function mockStep(name: string, id: string, success: () => boolean): any { return jest.fn().mockImplementation(() => ({ run: succeedOrFail(jest.fn(), success), revert: succeedOrFail(jest.fn(), success), - getName: jest.fn().mockReturnValue(name) + getName: jest.fn().mockReturnValue(name), + getId: jest.fn().mockReturnValue(id) })); } -jest.mock('./steps/settings-clone-step', () => ({ SettingsCloneStep: mockStep('Clone Settings', () => success[0]) })); +jest.mock('./steps/settings-clone-step', () => ({ + SettingsCloneStep: mockStep('Clone Settings', 'settings', () => success[0]) +})); + jest.mock('./steps/schema-clone-step', () => ({ - SchemaCloneStep: mockStep('Clone Content Type Schemas', () => success[1]) + SchemaCloneStep: mockStep('Clone Content Type Schemas', 'schemas', () => success[1]) +})); + +jest.mock('./steps/type-clone-step', () => ({ + TypeCloneStep: mockStep('Clone Content Types', 'types', () => success[2]) +})); + +jest.mock('./steps/content-clone-step', () => ({ + ContentCloneStep: mockStep('Clone Content', 'content', () => success[3]) })); -jest.mock('./steps/type-clone-step', () => ({ TypeCloneStep: mockStep('Clone Content Types', () => success[2]) })); -jest.mock('./steps/content-clone-step', () => ({ ContentCloneStep: mockStep('Clone Content', () => success[3]) })); jest.mock('../../common/log-helpers', () => ({ ...jest.requireActual('../../common/log-helpers'), @@ -199,6 +210,12 @@ describe('hub clone command', () => { describe: 'Path to a log file to write to.', coerce: createLog }); + + expect(spyOption).toHaveBeenCalledWith('step', { + type: 'string', + describe: 'Start at a specific step. Steps after the one you specify will also run.', + choices: steps.map(step => step.getId()) + }); }); }); @@ -336,7 +353,7 @@ describe('hub clone command', () => { ...yargArgs, ...config, - step: i, + step: steps[i].getId(), dir: 'temp/clone/steps', @@ -372,6 +389,18 @@ describe('hub clone command', () => { await loadLog.loadFromFile('temp/clone/steps/step' + i + '.log'); } }); + + it('should only have one of each type of step', () => { + const stepsSoFar = new Set(); + + for (const step of steps) { + const id = step.getId(); + + expect(stepsSoFar.has(id)).toBeFalsy(); + + stepsSoFar.add(id); + } + }); }); describe('revert tests', function() { @@ -565,7 +594,7 @@ describe('hub clone command', () => { ...yargArgs, ...config, - step: i, + step: steps[i].getId(), dir: 'temp/clone-revert/step', diff --git a/src/commands/hub/clone.ts b/src/commands/hub/clone.ts index 168fa86a..8a31734c 100644 --- a/src/commands/hub/clone.ts +++ b/src/commands/hub/clone.ts @@ -39,6 +39,8 @@ export const desc = export const LOG_FILENAME = (platform: string = process.platform): string => getDefaultLogPath('hub', 'clone', platform); +export const steps = [new SettingsCloneStep(), new SchemaCloneStep(), new TypeCloneStep(), new ContentCloneStep()]; + export const builder = (yargs: Argv): void => { yargs .positional('dir', { @@ -134,8 +136,9 @@ export const builder = (yargs: Argv): void => { }) .option('step', { - type: 'number', - describe: 'Start at a numbered step. 1: Settings, 2: Schema, 3: Type, 4: Content' + type: 'string', + describe: 'Start at a specific step. Steps after the one you specify will also run.', + choices: steps.map(step => step.getId()) }) .option('logFile', { @@ -146,8 +149,6 @@ export const builder = (yargs: Argv): void => { }); }; -const steps = [new SettingsCloneStep(), new SchemaCloneStep(), new TypeCloneStep(), new ContentCloneStep()]; - export const handler = async (argv: Arguments): Promise => { const log = argv.logFile.open(); const tempFolder = argv.dir; @@ -192,6 +193,8 @@ export const handler = async (argv: Arguments step.getId() === argv.step)); + if (revertLog) { if (revertLog.errorLevel === LogErrorLevel.INVALID) { log.error('Could not read the revert log.'); @@ -201,7 +204,7 @@ export const handler = async (argv: Arguments; revert(state: CloneHubState): Promise; diff --git a/src/commands/hub/steps/content-clone-step.spec.ts b/src/commands/hub/steps/content-clone-step.spec.ts index f981f8f5..9ce9e8e7 100644 --- a/src/commands/hub/steps/content-clone-step.spec.ts +++ b/src/commands/hub/steps/content-clone-step.spec.ts @@ -9,6 +9,7 @@ import * as copy from '../../content-item/copy'; import { ContentCloneStep } from './content-clone-step'; import { CopyItemBuilderOptions } from '../../../interfaces/copy-item-builder-options.interface'; +import { CloneHubStepId } from '../model/clone-hub-step'; jest.mock('../../../services/dynamic-content-client-factory'); jest.mock('../../content-item/copy'); @@ -71,6 +72,11 @@ describe('content clone step', () => { }; } + it('should have the id "content"', () => { + const step = new ContentCloneStep(); + expect(step.getId()).toEqual(CloneHubStepId.Content); + }); + it('should have the name "Clone Content"', () => { const step = new ContentCloneStep(); expect(step.getName()).toEqual('Clone Content'); diff --git a/src/commands/hub/steps/content-clone-step.ts b/src/commands/hub/steps/content-clone-step.ts index ac0d5048..71c36141 100644 --- a/src/commands/hub/steps/content-clone-step.ts +++ b/src/commands/hub/steps/content-clone-step.ts @@ -1,10 +1,14 @@ -import { CloneHubStep } from '../model/clone-hub-step'; +import { CloneHubStep, CloneHubStepId } from '../model/clone-hub-step'; import { CloneHubState } from '../model/clone-hub-state'; import { join } from 'path'; import { handler as copyContent } from '../../content-item/copy'; export class ContentCloneStep implements CloneHubStep { + getId(): CloneHubStepId { + return CloneHubStepId.Content; + } + getName(): string { return 'Clone Content'; } diff --git a/src/commands/hub/steps/schema-clone-step.spec.ts b/src/commands/hub/steps/schema-clone-step.spec.ts index 619058a6..03a57f64 100644 --- a/src/commands/hub/steps/schema-clone-step.spec.ts +++ b/src/commands/hub/steps/schema-clone-step.spec.ts @@ -13,6 +13,7 @@ import * as schemaImport from '../../content-type-schema/import'; import * as schemaExport from '../../content-type-schema/export'; import { SchemaCloneStep } from './schema-clone-step'; +import { CloneHubStepId } from '../model/clone-hub-step'; jest.mock('../../../services/dynamic-content-client-factory'); jest.mock('../../content-type-schema/import'); @@ -93,6 +94,11 @@ describe('schema clone step', () => { }; } + it('should have the id "schema"', () => { + const step = new SchemaCloneStep(); + expect(step.getId()).toEqual(CloneHubStepId.Schema); + }); + it('should have the name "Clone Content Type Schemas"', () => { const step = new SchemaCloneStep(); expect(step.getName()).toEqual('Clone Content Type Schemas'); diff --git a/src/commands/hub/steps/schema-clone-step.ts b/src/commands/hub/steps/schema-clone-step.ts index 83e163cc..e46e34ff 100644 --- a/src/commands/hub/steps/schema-clone-step.ts +++ b/src/commands/hub/steps/schema-clone-step.ts @@ -1,4 +1,4 @@ -import { CloneHubStep } from '../model/clone-hub-step'; +import { CloneHubStep, CloneHubStepId } from '../model/clone-hub-step'; import { CloneHubState } from '../model/clone-hub-state'; import { join } from 'path'; @@ -10,6 +10,10 @@ import { FileLog } from '../../../common/file-log'; import { Status } from 'dc-management-sdk-js'; export class SchemaCloneStep implements CloneHubStep { + getId(): CloneHubStepId { + return CloneHubStepId.Schema; + } + getName(): string { return 'Clone Content Type Schemas'; } diff --git a/src/commands/hub/steps/settings-clone-step.spec.ts b/src/commands/hub/steps/settings-clone-step.spec.ts index fe8f15ba..43394ccd 100644 --- a/src/commands/hub/steps/settings-clone-step.spec.ts +++ b/src/commands/hub/steps/settings-clone-step.spec.ts @@ -10,6 +10,7 @@ import * as settingsImport from '../../settings/import'; import * as settingsExport from '../../settings/export'; import { SettingsCloneStep } from './settings-clone-step'; +import { CloneHubStepId } from '../model/clone-hub-step'; jest.mock('../../../services/dynamic-content-client-factory'); jest.mock('../../settings/import'); @@ -70,6 +71,11 @@ describe('settings clone step', () => { }; } + it('should have the id "settings"', () => { + const step = new SettingsCloneStep(); + expect(step.getId()).toEqual(CloneHubStepId.Settings); + }); + it('should have the name "Clone Settings"', () => { const step = new SettingsCloneStep(); expect(step.getName()).toEqual('Clone Settings'); diff --git a/src/commands/hub/steps/settings-clone-step.ts b/src/commands/hub/steps/settings-clone-step.ts index 2839f7a2..c04af375 100644 --- a/src/commands/hub/steps/settings-clone-step.ts +++ b/src/commands/hub/steps/settings-clone-step.ts @@ -1,4 +1,4 @@ -import { CloneHubStep } from '../model/clone-hub-step'; +import { CloneHubStep, CloneHubStepId } from '../model/clone-hub-step'; import { CloneHubState } from '../model/clone-hub-state'; import { join } from 'path'; import { readdirSync } from 'fs'; @@ -8,6 +8,10 @@ import { handler as importSettings } from '../../settings/import'; import { ensureDirectoryExists } from '../../../common/import/directory-utils'; export class SettingsCloneStep implements CloneHubStep { + getId(): CloneHubStepId { + return CloneHubStepId.Settings; + } + getName(): string { return 'Clone Settings'; } diff --git a/src/commands/hub/steps/type-clone-step.spec.ts b/src/commands/hub/steps/type-clone-step.spec.ts index c774111b..c3428769 100644 --- a/src/commands/hub/steps/type-clone-step.spec.ts +++ b/src/commands/hub/steps/type-clone-step.spec.ts @@ -13,6 +13,7 @@ import * as typeImport from '../../content-type/import'; import * as typeExport from '../../content-type/export'; import { TypeCloneStep } from './type-clone-step'; +import { CloneHubStepId } from '../model/clone-hub-step'; jest.mock('../../../services/dynamic-content-client-factory'); jest.mock('../../content-type/import'); @@ -93,6 +94,11 @@ describe('type clone step', () => { }; } + it('should have the id "type"', () => { + const step = new TypeCloneStep(); + expect(step.getId()).toEqual(CloneHubStepId.Type); + }); + it('should have the name "Clone Content Types"', () => { const step = new TypeCloneStep(); expect(step.getName()).toEqual('Clone Content Types'); diff --git a/src/commands/hub/steps/type-clone-step.ts b/src/commands/hub/steps/type-clone-step.ts index 24e7b289..1e06a0bc 100644 --- a/src/commands/hub/steps/type-clone-step.ts +++ b/src/commands/hub/steps/type-clone-step.ts @@ -1,4 +1,4 @@ -import { CloneHubStep } from '../model/clone-hub-step'; +import { CloneHubStep, CloneHubStepId } from '../model/clone-hub-step'; import { CloneHubState } from '../model/clone-hub-state'; import { join } from 'path'; @@ -9,6 +9,10 @@ import { FileLog } from '../../../common/file-log'; import { existsSync } from 'fs'; export class TypeCloneStep implements CloneHubStep { + getId(): CloneHubStepId { + return CloneHubStepId.Type; + } + getName(): string { return 'Clone Content Types'; } diff --git a/src/interfaces/clone-hub-builder-options.ts b/src/interfaces/clone-hub-builder-options.ts index 2ac64cd7..3358b4e7 100644 --- a/src/interfaces/clone-hub-builder-options.ts +++ b/src/interfaces/clone-hub-builder-options.ts @@ -1,3 +1,4 @@ +import { CloneHubStepId } from '../commands/hub/model/clone-hub-step'; import { CopyConfig } from '../common/content-item/copy-config'; import { FileLog } from '../common/file-log'; @@ -9,7 +10,7 @@ export interface CloneHubBuilderOptions { dstSecret?: string; revertLog: Promise; - step?: number; + step?: CloneHubStepId; mapFile?: string; force?: boolean;