From 4565d673cdb58b4bee6f1f4ef243325d9dd606da Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 20 Apr 2021 09:52:25 +0100 Subject: [PATCH 01/22] fix(content-item): fix event command --- src/common/archive/archive-event-options.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/common/archive/archive-event-options.ts b/src/common/archive/archive-event-options.ts index da836a4a..903a5302 100644 --- a/src/common/archive/archive-event-options.ts +++ b/src/common/archive/archive-event-options.ts @@ -1,11 +1,18 @@ +<<<<<<< HEAD import { FileLog } from '../file-log'; +======= +>>>>>>> fix(content-item): fix event command export default interface ArchiveOptions { id?: string | string[]; name?: string | string[]; +<<<<<<< HEAD logFile: FileLog; +======= + logFile?: string; +>>>>>>> fix(content-item): fix event command force?: boolean; silent?: boolean; ignoreError?: boolean; From 6fad802e705966b5a2e80058593add865dcc2ea8 Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 17 Aug 2021 17:13:16 +0100 Subject: [PATCH 02/22] fix(facet): fix rebase issues --- src/common/archive/archive-event-options.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/common/archive/archive-event-options.ts b/src/common/archive/archive-event-options.ts index 903a5302..da836a4a 100644 --- a/src/common/archive/archive-event-options.ts +++ b/src/common/archive/archive-event-options.ts @@ -1,18 +1,11 @@ -<<<<<<< HEAD import { FileLog } from '../file-log'; -======= ->>>>>>> fix(content-item): fix event command export default interface ArchiveOptions { id?: string | string[]; name?: string | string[]; -<<<<<<< HEAD logFile: FileLog; -======= - logFile?: string; ->>>>>>> fix(content-item): fix event command force?: boolean; silent?: boolean; ignoreError?: boolean; From 1875a4e1503b5e498d1485fd7e193722192f5915 Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 11 May 2021 09:19:18 +0100 Subject: [PATCH 03/22] feat(event): add event export command feat(events): export snapshots with events feat(event): add event clone step, update tests --- package-lock.json | 5 +- package.json | 2 +- src/commands/content-item/import.ts | 2 +- .../event/__snapshots__/export.spec.ts.snap | 464 +++++++++++++ src/commands/event/event-test-helpers.ts | 363 ++++++++++ src/commands/event/export.spec.ts | 591 ++++++++++++++++ src/commands/event/export.ts | 386 +++++++++++ src/commands/event/import.spec.ts | 648 ++++++++++++++++++ src/commands/event/import.ts | 333 +++++++++ src/commands/hub/clone.spec.ts | 32 +- src/commands/hub/clone.ts | 4 +- src/commands/hub/model/clone-hub-step.ts | 3 +- .../hub/steps/event-clone-step.spec.ts | 267 ++++++++ src/commands/hub/steps/event-clone-step.ts | 102 +++ .../content-item/content-dependancy-tree.ts | 2 +- src/common/content-mapping.ts | 60 +- .../export-event-builder-options.interface.ts | 10 + .../import-event-builder-options.interface.ts | 8 + 18 files changed, 3255 insertions(+), 27 deletions(-) create mode 100644 src/commands/event/__snapshots__/export.spec.ts.snap create mode 100644 src/commands/event/event-test-helpers.ts create mode 100644 src/commands/event/export.spec.ts create mode 100644 src/commands/event/export.ts create mode 100644 src/commands/event/import.spec.ts create mode 100644 src/commands/event/import.ts create mode 100644 src/commands/hub/steps/event-clone-step.spec.ts create mode 100644 src/commands/hub/steps/event-clone-step.ts create mode 100644 src/interfaces/export-event-builder-options.interface.ts create mode 100644 src/interfaces/import-event-builder-options.interface.ts diff --git a/package-lock.json b/package-lock.json index adbd50ab..aafdbe3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3378,9 +3378,8 @@ "dev": true }, "dc-management-sdk-js": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/dc-management-sdk-js/-/dc-management-sdk-js-1.14.0.tgz", - "integrity": "sha512-M92cWMkwq8EscX7zklxEVNFNYadoyZEbz5+rta8JpPOxMGW9ysDitJETZ+4BNJxlnzi377PNQdlHyHfgnjKprg==", + "version": "git+ssh://git@github.com/rs-amp/dc-management-sdk-js.git#d9562b67421dcf14a4f6c0c3b0133c9994c02f5f", + "from": "git+ssh://git@github.com/rs-amp/dc-management-sdk-js.git#feature/events", "requires": { "axios": "^0.21.1", "url-template": "^2.0.8" diff --git a/package.json b/package.json index b25d3750..e1d33cf6 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "ajv": "^6.12.3", "axios": "^0.21.1", "chalk": "^2.4.2", - "dc-management-sdk-js": "^1.14.0", + "dc-management-sdk-js": "git+ssh://git@github.com/rs-amp/dc-management-sdk-js.git#feature/events", "lodash": "^4.17.21", "node-fetch": "^2.6.1", "promise-retry": "^2.0.1", diff --git a/src/commands/content-item/import.ts b/src/commands/content-item/import.ts index c4fddadf..eb169843 100644 --- a/src/commands/content-item/import.ts +++ b/src/commands/content-item/import.ts @@ -920,7 +920,7 @@ export const handler = async ( mapFile = getDefaultMappingPath(importTitle); } - if (mapping.load(mapFile)) { + if (await mapping.load(mapFile)) { log.appendLine(`Existing mapping loaded from '${mapFile}', changes will be saved back to it.`); } else { log.appendLine(`Creating new mapping file at '${mapFile}'.`); diff --git a/src/commands/event/__snapshots__/export.spec.ts.snap b/src/commands/event/__snapshots__/export.spec.ts.snap new file mode 100644 index 00000000..c106c775 --- /dev/null +++ b/src/commands/event/__snapshots__/export.spec.ts.snap @@ -0,0 +1,464 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`event export command enrichEditions tests should request and populate slots for each of the provided editions 1`] = ` +Array [ + Object { + "id": "ed1", + "name": "ed1", + "publishingStatus": "DRAFT", + "slots": Array [ + Object { + "conflicts": false, + "content": Object { + "body": Object { + "_meta": Object { + "name": "example-slot-test", + "schema": "http://schema.com/test.json", + }, + "link": Array [ + Object { + "_meta": Object { + "locked": "true", + "rootContentItemId": "content-item-1", + "schema": "http://bigcontent.io/cms/schema/v1/core#/definitions/content-link", + }, + "contentType": "http://schema.com/test.json", + "id": "snapshot1", + }, + ], + }, + }, + "contentTypeId": "testType", + "createdDate": "2021-05-06T09:52:27.065Z", + "editionId": "ed1", + "empty": false, + "eventId": "test1", + "id": "slot1", + "lastModifiedDate": "2021-05-06T09:52:27.065Z", + "locale": null, + "slotId": "testSlotId", + "slotLabel": "example-slot-test", + "slotStatus": "ACTIVE", + "status": "VALID", + }, + ], + }, +] +`; + +exports[`event export command enrichEvents tests should request and populate editions for each of the provided events 1`] = ` +Array [ + Object { + "editions": Array [ + Object { + "id": "ed1", + "name": "ed1", + "publishingStatus": "DRAFT", + "slots": Array [ + Object { + "conflicts": false, + "content": Object { + "body": Object { + "_meta": Object { + "name": "example-slot-test", + "schema": "http://schema.com/test.json", + }, + "link": Array [ + Object { + "_meta": Object { + "locked": "true", + "rootContentItemId": "content-item-1", + "schema": "http://bigcontent.io/cms/schema/v1/core#/definitions/content-link", + }, + "contentType": "http://schema.com/test.json", + "id": "snapshot1", + }, + ], + }, + }, + "contentTypeId": "testType", + "createdDate": "2021-05-06T09:52:27.065Z", + "editionId": "ed1", + "empty": false, + "eventId": "test1", + "id": "slot1", + "lastModifiedDate": "2021-05-06T09:52:27.065Z", + "locale": null, + "slotId": "testSlotId", + "slotLabel": "example-slot-test", + "slotStatus": "ACTIVE", + "status": "VALID", + }, + ], + }, + ], + "end": "2021-05-06T12:00:00.000Z", + "id": "test1", + "name": "test1", + "start": "2021-05-05T12:00:00.000Z", + }, + Object { + "editions": Array [ + Object { + "id": "ed1", + "name": "ed1", + "publishingStatus": "DRAFT", + "slots": Array [ + Object { + "conflicts": false, + "content": Object { + "body": Object { + "_meta": Object { + "name": "example-slot-test", + "schema": "http://schema.com/test.json", + }, + "link": Array [ + Object { + "_meta": Object { + "locked": "true", + "rootContentItemId": "content-item-1", + "schema": "http://bigcontent.io/cms/schema/v1/core#/definitions/content-link", + }, + "contentType": "http://schema.com/test.json", + "id": "snapshot1", + }, + ], + }, + }, + "contentTypeId": "testType", + "createdDate": "2021-05-06T09:52:27.065Z", + "editionId": "ed1", + "empty": false, + "eventId": "test1", + "id": "slot1", + "lastModifiedDate": "2021-05-06T09:52:27.065Z", + "locale": null, + "slotId": "testSlotId", + "slotLabel": "example-slot-test", + "slotStatus": "ACTIVE", + "status": "VALID", + }, + ], + }, + ], + "end": "2021-05-08T12:00:00.000Z", + "id": "test2", + "name": "test2", + "start": "2021-05-07T12:00:00.000Z", + }, +] +`; + +exports[`event export command handler tests should exit early if getting the hub fails 1`] = ` +"// dc-cli test-ver - temp/exportEvent/noHub.log +ERROR +// ERROR: Couldn't get hub with id hub-id, aborting. +// Error: Error +FAILURE" +`; + +exports[`event export command handler tests should export snapshots when --snapshots is provided 1`] = ` +Array [ + "{ + \\"name\\": \\"test1\\", + \\"start\\": \\"2021-05-05T12:00:00.000Z\\", + \\"end\\": \\"2021-05-06T12:00:00.000Z\\", + \\"editions\\": [ + { + \\"name\\": \\"ed1\\", + \\"id\\": \\"ed1\\", + \\"publishingStatus\\": \\"DRAFT\\", + \\"slots\\": [ + { + \\"id\\": \\"slot1\\", + \\"eventId\\": \\"test1\\", + \\"editionId\\": \\"ed1\\", + \\"createdDate\\": \\"2021-05-06T09:52:27.065Z\\", + \\"lastModifiedDate\\": \\"2021-05-06T09:52:27.065Z\\", + \\"content\\": { + \\"body\\": { + \\"_meta\\": { + \\"schema\\": \\"http://schema.com/test.json\\", + \\"name\\": \\"example-slot-test\\" + }, + \\"link\\": [ + { + \\"_meta\\": { + \\"schema\\": \\"http://bigcontent.io/cms/schema/v1/core#/definitions/content-link\\", + \\"rootContentItemId\\": \\"content-item-1\\", + \\"locked\\": \\"true\\" + }, + \\"contentType\\": \\"http://schema.com/test.json\\", + \\"id\\": \\"snapshot1\\" + } + ] + } + }, + \\"status\\": \\"VALID\\", + \\"slotStatus\\": \\"ACTIVE\\", + \\"contentTypeId\\": \\"testType\\", + \\"slotId\\": \\"testSlotId\\", + \\"slotLabel\\": \\"example-slot-test\\", + \\"conflicts\\": false, + \\"locale\\": null, + \\"empty\\": false + } + ] + } + ] +}", + "{ + \\"id\\": \\"snapshot-1\\", + \\"comment\\": \\"\\", + \\"createdDate\\": \\"2018-04-04T16:00:06.945Z\\", + \\"createdBy\\": \\"user\\", + \\"createdFrom\\": \\"content-item\\", + \\"type\\": \\"USER\\", + \\"meta\\": [], + \\"taggedEditions\\": [ + { + \\"editionId\\": \\"ed1\\", + \\"createdDate\\": \\"2018-04-04T16:00:07Z\\", + \\"createdBy\\": \\"user\\" + } + ], + \\"locale\\": null, + \\"rootContentItem\\": { + \\"label\\": \\"Content Item\\", + \\"contentTypeUri\\": \\"http://schema.com/test.json\\", + \\"id\\": \\"content-item-1\\" + }, + \\"rootContentItems\\": [ + { + \\"label\\": \\"Content Item\\", + \\"contentTypeUri\\": \\"http://schema.com/test.json\\", + \\"id\\": \\"content-item-1\\" + } + ], + \\"content\\": [ + { + \\"id\\": \\"content-item-1\\", + \\"contentRepositoryId\\": \\"repo1\\", + \\"body\\": { + \\"_meta\\": { + \\"name\\": \\"test\\", + \\"schema\\": \\"http://schema.com/test.json\\" + }, + \\"simpleContent\\": \\"test\\" + }, + \\"version\\": 9, + \\"label\\": \\"Content Item\\", + \\"status\\": \\"ACTIVE\\" + } + ] +}", +] +`; + +exports[`event export command handler tests should export snapshots when --snapshots is provided 2`] = ` +"// dc-cli test-ver - temp/exportEvent/snapshots.log +// Exporting single event test1. +// Fetching test1 with editions. +// Scanning slots for snapshots. +// Saving 1 snapshots to './snapshots/'. +// Fetching snapshot snapshot1. +// Done. +SUCCESS" +`; + +exports[`event export command handler tests should list and export a single edition 1`] = ` +Array [ + "{ + \\"name\\": \\"test1\\", + \\"start\\": \\"2021-05-05T12:00:00.000Z\\", + \\"end\\": \\"2021-05-06T12:00:00.000Z\\", + \\"editions\\": [ + { + \\"name\\": \\"ed1\\", + \\"id\\": \\"ed1\\", + \\"publishingStatus\\": \\"DRAFT\\", + \\"slots\\": [ + { + \\"id\\": \\"slot1\\", + \\"eventId\\": \\"test1\\", + \\"editionId\\": \\"ed1\\", + \\"createdDate\\": \\"2021-05-06T09:52:27.065Z\\", + \\"lastModifiedDate\\": \\"2021-05-06T09:52:27.065Z\\", + \\"content\\": { + \\"body\\": { + \\"_meta\\": { + \\"schema\\": \\"http://schema.com/test.json\\", + \\"name\\": \\"example-slot-test\\" + }, + \\"link\\": [ + { + \\"_meta\\": { + \\"schema\\": \\"http://bigcontent.io/cms/schema/v1/core#/definitions/content-link\\", + \\"rootContentItemId\\": \\"content-item-1\\", + \\"locked\\": \\"true\\" + }, + \\"contentType\\": \\"http://schema.com/test.json\\", + \\"id\\": \\"snapshot1\\" + } + ] + } + }, + \\"status\\": \\"VALID\\", + \\"slotStatus\\": \\"ACTIVE\\", + \\"contentTypeId\\": \\"testType\\", + \\"slotId\\": \\"testSlotId\\", + \\"slotLabel\\": \\"example-slot-test\\", + \\"conflicts\\": false, + \\"locale\\": null, + \\"empty\\": false + } + ] + } + ] +}", +] +`; + +exports[`event export command handler tests should list and export a single edition 2`] = ` +"// dc-cli test-ver - temp/exportEvent/single.log +// Exporting single event test1. +// Fetching test1 with editions. +// Done. +SUCCESS" +`; + +exports[`event export command handler tests should list and export all editions 1`] = ` +Array [ + "{ + \\"name\\": \\"test1\\", + \\"start\\": \\"2021-05-05T12:00:00.000Z\\", + \\"end\\": \\"2021-05-06T12:00:00.000Z\\", + \\"editions\\": [ + { + \\"name\\": \\"ed1\\", + \\"id\\": \\"ed1\\", + \\"publishingStatus\\": \\"DRAFT\\", + \\"slots\\": [ + { + \\"id\\": \\"slot1\\", + \\"eventId\\": \\"test1\\", + \\"editionId\\": \\"ed1\\", + \\"createdDate\\": \\"2021-05-06T09:52:27.065Z\\", + \\"lastModifiedDate\\": \\"2021-05-06T09:52:27.065Z\\", + \\"content\\": { + \\"body\\": { + \\"_meta\\": { + \\"schema\\": \\"http://schema.com/test.json\\", + \\"name\\": \\"example-slot-test\\" + }, + \\"link\\": [ + { + \\"_meta\\": { + \\"schema\\": \\"http://bigcontent.io/cms/schema/v1/core#/definitions/content-link\\", + \\"rootContentItemId\\": \\"content-item-1\\", + \\"locked\\": \\"true\\" + }, + \\"contentType\\": \\"http://schema.com/test.json\\", + \\"id\\": \\"snapshot1\\" + } + ] + } + }, + \\"status\\": \\"VALID\\", + \\"slotStatus\\": \\"ACTIVE\\", + \\"contentTypeId\\": \\"testType\\", + \\"slotId\\": \\"testSlotId\\", + \\"slotLabel\\": \\"example-slot-test\\", + \\"conflicts\\": false, + \\"locale\\": null, + \\"empty\\": false + } + ] + } + ] +}", + "{ + \\"name\\": \\"test2\\", + \\"start\\": \\"2021-05-07T12:00:00.000Z\\", + \\"end\\": \\"2021-05-08T12:00:00.000Z\\", + \\"editions\\": [ + { + \\"name\\": \\"ed1\\", + \\"id\\": \\"ed1\\", + \\"publishingStatus\\": \\"DRAFT\\", + \\"slots\\": [ + { + \\"id\\": \\"slot1\\", + \\"eventId\\": \\"test1\\", + \\"editionId\\": \\"ed1\\", + \\"createdDate\\": \\"2021-05-06T09:52:27.065Z\\", + \\"lastModifiedDate\\": \\"2021-05-06T09:52:27.065Z\\", + \\"content\\": { + \\"body\\": { + \\"_meta\\": { + \\"schema\\": \\"http://schema.com/test.json\\", + \\"name\\": \\"example-slot-test\\" + }, + \\"link\\": [ + { + \\"_meta\\": { + \\"schema\\": \\"http://bigcontent.io/cms/schema/v1/core#/definitions/content-link\\", + \\"rootContentItemId\\": \\"content-item-1\\", + \\"locked\\": \\"true\\" + }, + \\"contentType\\": \\"http://schema.com/test.json\\", + \\"id\\": \\"snapshot1\\" + } + ] + } + }, + \\"status\\": \\"VALID\\", + \\"slotStatus\\": \\"ACTIVE\\", + \\"contentTypeId\\": \\"testType\\", + \\"slotId\\": \\"testSlotId\\", + \\"slotLabel\\": \\"example-slot-test\\", + \\"conflicts\\": false, + \\"locale\\": null, + \\"empty\\": false + } + ] + } + ] +}", +] +`; + +exports[`event export command handler tests should list and export all editions 2`] = ` +"// dc-cli test-ver - temp/exportEvent/all.log +// Exporting 2 of 2 events... +// Fetching test1 with editions. +// Fetching test2 with editions. +// Done. +SUCCESS" +`; + +exports[`event export command handler tests should log an error when getting a single event fails 1`] = ` +"// dc-cli test-ver - temp/exportEvent/singleError.log +ERROR +// ERROR: Failed to get event with id missing, aborting. +// Error: Error +FAILURE" +`; + +exports[`event export command handler tests should log an error when listing events fails 1`] = ` +"// dc-cli test-ver - temp/exportEvent/listError.log +ERROR +// ERROR: Failed to list events. +// Error: Error +// No events to export from this hub, exiting. +// Done. +FAILURE" +`; + +exports[`event export command handler tests should pass from and to date parameters to filterEvents 2`] = ` +"// dc-cli test-ver - temp/exportEvent/date.log +// Exporting 0 of 2 events... +// No events to export from this hub, exiting. +// Done. +SUCCESS" +`; diff --git a/src/commands/event/event-test-helpers.ts b/src/commands/event/event-test-helpers.ts new file mode 100644 index 00000000..bb27d0ef --- /dev/null +++ b/src/commands/event/event-test-helpers.ts @@ -0,0 +1,363 @@ +import { Hub, Event, Edition, EditionSlot, Snapshot, ContentItem } from 'dc-management-sdk-js'; +import MockPage from '../../common/dc-management-sdk-js/mock-page'; +import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; + +export const mockValues = ({ + status = 'DRAFT', + deleteResource = false, + mixedEditions = false, + getHubError = false, + getEventError = false, + listEventError = false, + listEditionError = false, + getSnapshotError = false +}): { + mockGet: () => void; + mockEditionsList: () => void; + mockEditionGet: () => void; + mockEditionUpdate: () => void; + getHubMock: () => void; + mockEventsList: () => void; + mockEventUpdate: () => void; + mockEventCreate: () => void; + mockSlotsList: () => void; + mockSlotContent: () => void; + mockSnapshotGet: () => void; + mockSnapshotItem: () => void; + mockSnapshotCreate: () => void; +} => { + const mockGet = jest.fn(); + const getHubMock = jest.fn(); + const mockEditionsList = jest.fn(); + const mockEditionGet = jest.fn(); + const mockEditionUpdate = jest.fn(); + const mockEventsList = jest.fn(); + const mockEventUpdate = jest.fn(); + const mockEventCreate = jest.fn(); + const mockSlotsList = jest.fn(); + const mockSlotContent = jest.fn(); + const mockSnapshotGet = jest.fn(); + const mockSnapshotItem = jest.fn(); + const mockSnapshotCreate = jest.fn(); + + (dynamicContentClientFactory as jest.Mock).mockReturnValue({ + hubs: { + get: getHubMock + }, + events: { + get: mockGet + }, + editions: { + get: mockEditionGet + }, + snapshots: { + get: mockSnapshotGet + } + }); + + const hub = new Hub({ + name: '1', + id: '1', + _links: { + events: { + href: 'https://api.amplience.net/v2/content/events', + templated: true + } + } + }); + + getHubMock.mockResolvedValue(hub); + + hub.related.events.list = mockEventsList; + hub.related.events.create = mockEventCreate; + hub.related.snapshots.create = mockSnapshotCreate; + + const events = [ + new Event({ + id: 'test1', + name: 'test1', + start: '2021-05-05T12:00:00.000Z', + end: '2021-05-06T12:00:00.000Z', + client: { + fetchLinkedResource: mockEditionsList + }, + _links: { + editions: { + href: 'https://api.amplience.net/v2/content/events/1/editions{?projection,page,size,sort}', + templated: true + }, + delete: { + href: 'https://api.amplience.net/v2/content/events/1' + }, + archive: { + href: 'https://api.amplience.net/v2/content/events/1/archive' + } + }, + related: { + editions: { + list: mockEditionsList + } + } + }), + new Event({ + id: 'test2', + name: 'test2', + start: '2021-05-07T12:00:00.000Z', + end: '2021-05-08T12:00:00.000Z', + client: { + fetchLinkedResource: mockEditionsList + }, + _links: { + editions: { + href: 'https://api.amplience.net/v2/content/events/2/editions{?projection,page,size,sort}', + templated: true + }, + delete: { + href: 'https://api.amplience.net/v2/content/events/2' + }, + archive: { + href: 'https://api.amplience.net/v2/content/events/2/archive' + } + }, + related: { + editions: { + list: mockEditionsList + } + } + }) + ]; + + mockEventsList.mockResolvedValue(new MockPage(Event, events)); + + const event = new Event({ + name: 'test1', + id: '1', + start: '2021-05-05T12:00:00.000Z', + end: '2021-05-06T12:00:00.000Z', + client: { + fetchLinkedResource: mockEditionsList + }, + _links: { + editions: { + href: 'https://api.amplience.net/v2/content/events/1/editions{?projection,page,size,sort}', + templated: true + }, + delete: !deleteResource && { + href: 'https://api.amplience.net/v2/content/events/1' + }, + archive: { + href: 'https://api.amplience.net/v2/content/events/1/archive' + } + }, + related: { + editions: { + list: mockEditionsList + } + } + }); + + mockGet.mockResolvedValue(event); + + event.related.update = mockEventUpdate; + + const editions = [ + new Edition({ + name: 'ed1', + id: 'ed1', + publishingStatus: status, + client: { + fetchLinkedResource: mockSlotsList + }, + _links: { + 'list-slots': { + href: 'https://api.amplience.net/v2/content/editions/ed1/slots{?includedSlots}', + templated: true + }, + archive: { + href: 'https://api.amplience.net/v2/content/editions/ed1/archive' + }, + delete: { + href: 'https://api.amplience.net/v2/content/editions/ed1' + }, + schedule: { + href: 'https://api.amplience.net/v2/content/editions/ed1/schedule' + } + } + }) + ]; + + const slots = [ + new EditionSlot({ + id: 'slot1', + eventId: 'test1', + editionId: 'ed1', + createdDate: '2021-05-06T09:52:27.065Z', + lastModifiedDate: '2021-05-06T09:52:27.065Z', + content: { + body: { + _meta: { schema: 'http://schema.com/test.json', name: 'example-slot-test' }, + link: [ + { + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-link', + rootContentItemId: 'content-item-1', + locked: 'true' + }, + contentType: 'http://schema.com/test.json', + id: 'snapshot1' + } + ] + } + }, + status: 'VALID', + slotStatus: 'ACTIVE', + contentTypeId: 'testType', + slotId: 'testSlotId', + slotLabel: 'example-slot-test', + conflicts: false, + locale: null, + empty: false, + _links: { + self: { + href: 'https://api.amplience.net/v2/content/editions/ed1/slots/slot1' + }, + 'edition-slot': { + href: 'https://api.amplience.net/v2/content/editions/ed1/slots/slot1' + }, + edition: { + href: 'https://api.amplience.net/v2/content/editions/ed1' + }, + slot: { + href: 'https://api.amplience.net/v2/content/content-items/testSlotId{?projection}', + templated: true + }, + content: { + href: 'https://api.amplience.net/v2/content/editions/ed1/slots/slot1/content' + }, + 'safe-update-content': { + href: + 'https://api.amplience.net/v2/content/editions/ed1/slots/slot1/content{?lastModifiedDate,page,size,sort}', + templated: true + } + } + }) + ]; + + if (mixedEditions) { + editions.push( + new Edition({ + name: 'ed2', + id: 'ed2', + publishingStatus: 'PUBLISHED', + client: { + fetchLinkedResource: mockEventsList + }, + _links: { + archive: { + href: 'https://api.amplience.net/v2/content/editions/ed2/archive' + }, + delete: { + href: 'https://api.amplience.net/v2/content/editions/ed2' + }, + schedule: { + href: 'https://api.amplience.net/v2/content/editions/ed2/schedule' + } + } + }) + ); + } + mockEditionsList.mockResolvedValue(new MockPage(Edition, editions)); + + mockSlotsList.mockResolvedValue(new MockPage(EditionSlot, slots)); + + const snapshot = new Snapshot({ + id: 'snapshot-1', + comment: '', + createdDate: '2018-04-04T16:00:06.945Z', + createdBy: 'user', + createdFrom: 'content-item', + type: 'USER', + meta: [], + taggedEditions: [ + { + editionId: 'ed1', + createdDate: '2018-04-04T16:00:07Z', + createdBy: 'user' + } + ], + locale: null, + rootContentItem: { + label: 'Content Item', + contentTypeUri: 'http://schema.com/test.json', + id: 'content-item-1' + }, + rootContentItems: [ + { + label: 'Content Item', + contentTypeUri: 'http://schema.com/test.json', + id: 'content-item-1' + } + ] + }); + + snapshot.related.snapshotContentItem = mockSnapshotItem; + + mockSnapshotGet.mockResolvedValue(snapshot); + + mockSnapshotItem.mockResolvedValue( + new ContentItem({ + id: 'content-item-1', + contentRepositoryId: 'repo1', + body: { + _meta: { + name: 'test', + schema: 'http://schema.com/test.json' + }, + simpleContent: 'test' + }, + version: 9, + label: 'Content Item', + status: 'ACTIVE' + }) + ); + + editions[0].related.update = mockEditionUpdate; + slots[0].related.content = mockSlotContent; + mockEditionGet.mockResolvedValue(editions[0]); + + if (getHubError) { + getHubMock.mockRejectedValue(new Error('Error')); + } + + if (getEventError) { + mockGet.mockRejectedValue(new Error('Error')); + } + + if (listEventError) { + mockEventsList.mockRejectedValue(new Error('Error')); + } + + if (listEditionError) { + mockEditionsList.mockRejectedValue(new Error('Error')); + } + + if (getSnapshotError) { + mockSnapshotGet.mockRejectedValue(new Error('Error')); + } + + return { + mockGet, + getHubMock, + mockEditionsList, + mockEditionGet, + mockEditionUpdate, + mockEventsList, + mockEventUpdate, + mockEventCreate, + mockSlotsList, + mockSlotContent, + mockSnapshotGet, + mockSnapshotItem, + mockSnapshotCreate + }; +}; diff --git a/src/commands/event/export.spec.ts b/src/commands/event/export.spec.ts new file mode 100644 index 00000000..750a0f02 --- /dev/null +++ b/src/commands/event/export.spec.ts @@ -0,0 +1,591 @@ +import { + builder, + command, + handler, + enrichEditions, + enrichEvents, + filterEvents, + LOG_FILENAME, + locateSnapshots, + exportSnapshots +} from './export'; +import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; +import { Event, Edition, EditionSlot, Page } from 'dc-management-sdk-js'; +import Yargs from 'yargs/yargs'; +import { promisify } from 'util'; +import { exists, readFile, readdirSync } from 'fs'; +import paginator from '../../common/dc-management-sdk-js/paginator'; + +import rmdir from 'rimraf'; +import * as facet from '../../common/filter/facet'; +import * as exportService from '../../services/export.service'; +import { FileLog, setVersion } from '../../common/file-log'; +import { LogErrorLevel } from '../../common/archive/archive-log'; + +import { mockValues } from './event-test-helpers'; +import { createLog } from '../../common/log-helpers'; + +setVersion('test-ver'); + +jest.mock('../../services/dynamic-content-client-factory'); + +jest.mock('../../common/filter/facet', () => ({ + relativeDate: jest + .fn() + .mockImplementation((relative: string) => jest.requireActual('../../common/filter/facet').relativeDate(relative)) +})); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +describe('event export command', () => { + afterEach((): void => { + jest.restoreAllMocks(); + }); + const yargArgs = { + $0: 'test', + _: ['test'], + json: true, + silent: true + }; + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + it('should command should defined', function() { + expect(command).toEqual('export '); + }); + + 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: 'Output directory for the exported Events.', + type: 'string' + }); + + expect(spyOption).toHaveBeenCalledWith('id', { + describe: 'Export a single event by ID, rather then fetching all of them.', + type: 'string' + }); + + expect(spyOption).toHaveBeenCalledWith('fromDate', { + describe: + 'Start date for filtering events. Either "NOW" or in the format ":", example: "-7:DAYS".', + type: 'string' + }); + + expect(spyOption).toHaveBeenCalledWith('toDate', { + describe: 'To date for filtering events. Either "NOW" or in the format ":", example: "-7:DAYS".', + type: 'string' + }); + + expect(spyOption).toHaveBeenCalledWith('snapshots', { + describe: 'Save content snapshots with events, in subfolder "snapshots/".', + type: 'boolean', + boolean: true + }); + + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); + }); + }); + + describe('handler tests', function() { + beforeAll(async () => { + await rimraf('temp/exportEvent/'); + }); + + afterAll(async () => { + await rimraf('temp/exportEvent/'); + }); + + it('should list and export all editions', async () => { + const { mockEventsList, mockEditionsList, mockSlotsList } = mockValues({}); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/exportEvent/all/', + logFile: new FileLog('temp/exportEvent/all.log'), + snapshots: false + }; + await handler(argv); + + expect(mockEventsList).toHaveBeenCalled(); + expect(mockEditionsList).toHaveBeenCalledTimes(2); + expect(mockSlotsList).toHaveBeenCalledTimes(2); + + const results = [ + await promisify(readFile)('temp/exportEvent/all/test1.json', { encoding: 'utf-8' }), + await promisify(readFile)('temp/exportEvent/all/test2.json', { encoding: 'utf-8' }) + ]; + + const log = await promisify(readFile)('temp/exportEvent/all.log', { encoding: 'utf-8' }); + + expect(results).toMatchSnapshot(); + expect(log).toMatchSnapshot(); + }); + + it('should list and export a single edition', async () => { + const { mockEventsList, mockEditionsList, mockSlotsList, mockGet } = mockValues({}); + + const argv = { + ...yargArgs, + ...config, + id: 'item1', + dir: 'temp/exportEvent/single/', + logFile: new FileLog('temp/exportEvent/single.log'), + snapshots: false + }; + await handler(argv); + + expect(mockGet).toHaveBeenCalledWith('item1'); + expect(mockEventsList).not.toHaveBeenCalled(); + expect(mockEditionsList).toHaveBeenCalledTimes(1); + expect(mockSlotsList).toHaveBeenCalledTimes(1); + + const results = [await promisify(readFile)('temp/exportEvent/single/test1.json', { encoding: 'utf-8' })]; + + const log = await promisify(readFile)('temp/exportEvent/single.log', { encoding: 'utf-8' }); + + expect(results).toMatchSnapshot(); + expect(log).toMatchSnapshot(); + }); + + it('should pass from and to date parameters to filterEvents', async () => { + const { mockEventsList, mockEditionsList, mockSlotsList } = mockValues({}); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/exportEvent/date/', + logFile: new FileLog('temp/exportEvent/date.log'), + fromDate: '-1:DAYS', + toDate: '1:DAYS', + snapshots: false + }; + await handler(argv); + + expect((facet.relativeDate as jest.Mock).mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "-1:DAYS", + ], + Array [ + "1:DAYS", + ], + ] + `); + + expect(mockEventsList).toHaveBeenCalled(); + expect(mockEditionsList).not.toHaveBeenCalled(); + expect(mockSlotsList).not.toHaveBeenCalled(); + + const dirExists = await promisify(exists)('temp/exportEvent/date/'); + const log = await promisify(readFile)('temp/exportEvent/date.log', { encoding: 'utf-8' }); + + expect(dirExists).toBeFalsy(); + expect(log).toMatchSnapshot(); + }); + + it('should exit early if getting the hub fails', async () => { + const { getHubMock, mockEventsList, mockEditionsList, mockSlotsList, mockGet } = mockValues({ + getEventError: true, + getHubError: true + }); + + const argv = { + ...yargArgs, + ...config, + id: 'missing', + dir: 'temp/exportEvent/noHub/', + logFile: new FileLog('temp/exportEvent/noHub.log'), + snapshots: false + }; + await handler(argv); + + expect(getHubMock).toHaveBeenCalled(); + expect(mockGet).not.toHaveBeenCalled(); + expect(mockEventsList).not.toHaveBeenCalled(); + expect(mockEditionsList).not.toHaveBeenCalled(); + expect(mockSlotsList).not.toHaveBeenCalled(); + + const dirExists = await promisify(exists)('temp/exportEvent/noHub/'); + const log = await promisify(readFile)('temp/exportEvent/noHub.log', { encoding: 'utf-8' }); + + expect(dirExists).toBeFalsy(); + expect(log).toMatchSnapshot(); + }); + + it('should log an error when getting a single event fails', async () => { + const { mockEventsList, mockEditionsList, mockSlotsList, mockGet } = mockValues({ getEventError: true }); + + const argv = { + ...yargArgs, + ...config, + id: 'missing', + dir: 'temp/exportEvent/singleError/', + logFile: new FileLog('temp/exportEvent/singleError.log'), + snapshots: false + }; + await handler(argv); + + expect(mockGet).toHaveBeenCalledWith('missing'); + expect(mockEventsList).not.toHaveBeenCalled(); + expect(mockEditionsList).not.toHaveBeenCalled(); + expect(mockSlotsList).not.toHaveBeenCalled(); + + const dirExists = await promisify(exists)('temp/exportEvent/singleError/'); + const log = await promisify(readFile)('temp/exportEvent/singleError.log', { encoding: 'utf-8' }); + + expect(dirExists).toBeFalsy(); + expect(log).toMatchSnapshot(); + }); + + it('should log an error when listing events fails', async () => { + const { mockEventsList, mockEditionsList, mockSlotsList, mockGet } = mockValues({ listEventError: true }); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/exportEvent/listError/', + logFile: new FileLog('temp/exportEvent/listError.log'), + snapshots: false + }; + await handler(argv); + + expect(mockGet).not.toHaveBeenCalled(); + expect(mockEventsList).toHaveBeenCalled(); + expect(mockEditionsList).not.toHaveBeenCalled(); + expect(mockSlotsList).not.toHaveBeenCalled(); + + const dirExists = await promisify(exists)('temp/exportEvent/listError/'); + const log = await promisify(readFile)('temp/exportEvent/listError.log', { encoding: 'utf-8' }); + + expect(dirExists).toBeFalsy(); + expect(log).toMatchSnapshot(); + }); + + it('should export snapshots when --snapshots is provided', async () => { + const { + mockEventsList, + mockEditionsList, + mockSlotsList, + mockGet, + mockSnapshotGet, + mockSnapshotItem + } = mockValues({}); + + const argv = { + ...yargArgs, + ...config, + id: 'item1', + dir: 'temp/exportEvent/snapshots/', + logFile: new FileLog('temp/exportEvent/snapshots.log'), + snapshots: true + }; + await handler(argv); + + expect(mockGet).toHaveBeenCalledWith('item1'); + expect(mockEventsList).not.toHaveBeenCalled(); + expect(mockEditionsList).toHaveBeenCalledTimes(1); + expect(mockSlotsList).toHaveBeenCalledTimes(1); + expect(mockSnapshotGet).toHaveBeenCalledTimes(1); + expect(mockSnapshotItem).toHaveBeenCalledTimes(1); + + const results = [ + await promisify(readFile)('temp/exportEvent/snapshots/test1.json', { encoding: 'utf-8' }), + await promisify(readFile)('temp/exportEvent/snapshots/snapshots/snapshot1.json', { encoding: 'utf-8' }) + ]; + + const log = await promisify(readFile)('temp/exportEvent/snapshots.log', { encoding: 'utf-8' }); + + expect(results).toMatchSnapshot(); + expect(log).toMatchSnapshot(); + }); + + it('should return event file name', async () => { + const logFile = LOG_FILENAME(); + + expect(logFile).toContain('event-export-.log'); + }); + }); + + describe('enrichEvents tests', () => { + it('should request and populate editions for each of the provided events', async () => { + const { mockEventsList, mockEditionsList } = mockValues({}); + + const events = await paginator((mockEventsList as unknown) as () => Promise>); + + await expect(enrichEvents(events)).resolves.toMatchSnapshot(); + + expect(mockEditionsList).toHaveBeenCalledTimes(2); + }); + + it('should omit events when fetching their editions failed', async () => { + const { mockEventsList, mockEditionsList } = mockValues({ listEditionError: true }); + + const events = await paginator((mockEventsList as unknown) as () => Promise>); + + const log = new FileLog(); + + await expect(enrichEvents(events, log)).resolves.toEqual([]); + + expect(log.errorLevel).toEqual(LogErrorLevel.WARNING); + expect(mockEditionsList).toHaveBeenCalledTimes(2); + }); + + it('should return empty array if no events provided', async () => { + expect(enrichEvents([])).resolves.toEqual([]); + }); + }); + + describe('enrichEditions tests', () => { + it('should request and populate slots for each of the provided editions', async () => { + const { mockEditionsList, mockSlotsList } = mockValues({}); + + const editions = await paginator((mockEditionsList as unknown) as () => Promise>); + + await expect(enrichEditions(editions)).resolves.toMatchSnapshot(); + + expect(mockSlotsList).toHaveBeenCalledTimes(1); + }); + + it('should return empty array if no editions provided', async () => { + expect(enrichEditions([])).resolves.toEqual([]); + }); + }); + + describe('filterEvents tests', () => { + const testEvents = [ + new Event({ start: '2021-01-01T12:00:00.000Z', end: '2021-05-05T12:00:00.000Z' }), + new Event({ start: '2021-04-04T12:00:00.000Z', end: '2021-06-06T12:00:00.000Z' }), + new Event({ start: '2021-08-08T12:00:00.000Z', end: '2021-09-09T12:00:00.000Z' }), + new Event({ start: '2021-01-01T12:00:00.000Z', end: '2021-10-10T12:00:00.000Z' }) + ]; + + it('should return the input events if from and to are undefined', async () => { + expect(filterEvents(testEvents, undefined, undefined)).toEqual(testEvents); + }); + + it('should filter out events from before the from date when provided', async () => { + expect(filterEvents(testEvents, new Date('2021-08-08T12:00:00.000Z'), undefined)).toEqual(testEvents.slice(2)); + }); + + it('should filter out events from after the to date when provided', async () => { + expect(filterEvents(testEvents, undefined, new Date('2021-07-07T12:00:00.000Z'))).toEqual([ + testEvents[0], + testEvents[1], + testEvents[3] + ]); + }); + + it('should filter out events outwith the from and to dates when both are provided', async () => { + expect( + filterEvents(testEvents, new Date('2021-05-06T12:00:00.000Z'), new Date('2021-07-07T12:00:00.000Z')) + ).toEqual([testEvents[1], testEvents[3]]); + }); + }); + + describe('locateSnapshots tests', () => { + it('should locate snapshots within the provided slots, alongside empty slots', async () => { + const slots = [ + new EditionSlot({ + id: 'emptySlot', + eventId: 'test1', + editionId: 'ed1', + content: { body: { _meta: { schema: 'http://schema.com/test.json', name: 'example-slot-test' } } }, + status: 'VALID', + slotStatus: 'ACTIVE', + contentTypeId: 'testType', + slotId: 'slot1', + slotLabel: 'example-slot-test', + empty: true + }), + new EditionSlot({ + id: 'referencesSlot', + eventId: 'test1', + editionId: 'ed1', + content: { + label: 'references', + body: { + _meta: { schema: 'http://schema.com/test.json', name: 'example-slot-test' }, + array: [ + { + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-link', + rootContentItemId: 'content-item-1', + locked: true + }, + contentType: 'http://schema.com/test.json', + id: 'snapshot1' + } + ], + property: { + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-reference', + rootContentItemId: 'content-item-2', + locked: true + }, + contentType: 'http://schema.com/test.json', + id: 'snapshot2' + }, + propertyNested: { + property: { + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-link', + rootContentItemId: 'content-item-3', + locked: true + }, + contentType: 'http://schema.com/test.json', + id: 'snapshot3' + } + } + } + }, + status: 'VALID', + slotStatus: 'ACTIVE', + contentTypeId: 'testType', + slotId: 'slot2', + slotLabel: 'example-slot-test2', + empty: false + }), + new EditionSlot({ + id: 'referencesSlot', + eventId: 'test1', + editionId: 'ed1', + content: { + label: 'references', + body: { + _meta: { schema: 'http://schema.com/test.json', name: 'example-slot-test' }, + noReferences: 'none!' + } + }, + status: 'VALID', + slotStatus: 'ACTIVE', + contentTypeId: 'testType', + slotId: 'slot3', + slotLabel: 'example-slot-test3', + empty: false + }), + new EditionSlot({ + id: 'referencesSlot', + eventId: 'test1', + editionId: 'ed1', + content: { + label: 'references', + body: { + _meta: { schema: 'http://schema.com/test.json', name: 'example-slot-test' }, + oneReference: { + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-reference', + rootContentItemId: 'content-item-2', + locked: true + }, + contentType: 'http://schema.com/test.json', + id: 'snapshot4' + } + } + }, + status: 'VALID', + slotStatus: 'ACTIVE', + contentTypeId: 'testType', + slotId: 'slot3', + slotLabel: 'example-slot-test3', + empty: false + }) + ]; + + const snapshots = new Set(); + locateSnapshots(slots, snapshots); + + expect(Array.from(snapshots)).toEqual(['snapshot1', 'snapshot2', 'snapshot3', 'snapshot4']); + }); + + it('should not add snapshots when no slots are provided', async () => { + const snapshots = new Set(); + locateSnapshots([], snapshots); + + expect(snapshots.size).toEqual(0); + }); + }); + + describe('exportSnapshots tests', () => { + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + it('should export snapshots to a "snapshots" subfolder in the provided directory', async () => { + const { mockSnapshotGet, mockSnapshotItem } = mockValues({}); + + const client = await dynamicContentClientFactory(config); + + const log = new FileLog(); + + await exportSnapshots(client, 'temp/exportSnapshot/snapshots', new Set(['snapshot1', 'snapshot2']), log); + + expect(mockSnapshotGet).toHaveBeenCalledTimes(2); + expect(mockSnapshotItem).toHaveBeenCalledTimes(2); + expect(log.errorLevel).toEqual(LogErrorLevel.NONE); + + const exportDir = readdirSync('temp/exportSnapshot/snapshots/snapshots'); + + expect(exportDir).toMatchInlineSnapshot(` + Array [ + "snapshot1.json", + "snapshot2.json", + ] + `); + }); + + it('should warn and skip when the snapshot cannot be fetched', async () => { + const { mockSnapshotGet, mockSnapshotItem } = mockValues({ getSnapshotError: true }); + + const client = await dynamicContentClientFactory(config); + + const log = new FileLog(); + + await exportSnapshots(client, 'temp/exportSnapshot/snapshotFail1', new Set(['snapshot1']), log); + + expect(mockSnapshotGet).toHaveBeenCalledWith('snapshot1'); + expect(mockSnapshotItem).not.toHaveBeenCalled(); + expect(readdirSync('temp/exportSnapshot/snapshotFail1/snapshots').length).toEqual(0); + expect(log.errorLevel).toEqual(LogErrorLevel.WARNING); + }); + + it('should warn and skip when the snapshot cannot be saved', async () => { + const { mockSnapshotGet, mockSnapshotItem } = mockValues({}); + + jest.spyOn(exportService, 'writeJsonToFile').mockImplementation(() => { + throw new Error('Error'); + }); + const client = await dynamicContentClientFactory(config); + + const log = new FileLog(); + + await exportSnapshots(client, 'temp/exportSnapshot/snapshotFail2', new Set(['snapshot1']), log); + + expect(mockSnapshotGet).toHaveBeenCalledWith('snapshot1'); + expect(mockSnapshotItem).toHaveBeenCalledWith('content-item-1'); + expect(readdirSync('temp/exportSnapshot/snapshotFail2/snapshots').length).toEqual(0); + expect(log.errorLevel).toEqual(LogErrorLevel.WARNING); + }); + }); +}); diff --git a/src/commands/event/export.ts b/src/commands/event/export.ts new file mode 100644 index 00000000..f08c5a35 --- /dev/null +++ b/src/commands/event/export.ts @@ -0,0 +1,386 @@ +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, DynamicContent, Edition, EditionSlot, Event, Hub, Snapshot } from 'dc-management-sdk-js'; +import { createStream } from 'table'; +import { streamTableOptions } from '../../common/table/table.consts'; +import { TableStream } from '../../interfaces/table.interface'; +import chalk from 'chalk'; +import { + ExportResult, + nothingExportedExit, + promptToOverwriteExports, + uniqueFilenamePath, + writeJsonToFile +} from '../../services/export.service'; +import { loadJsonFromDirectory } from '../../services/import.service'; +import { ExportEventBuilderOptions } from '../../interfaces/export-event-builder-options.interface'; +import { ensureDirectoryExists } from '../../common/import/directory-utils'; +import { relativeDate } from '../../common/filter/facet'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { FileLog } from '../../common/file-log'; +import { ContentDependancyTree } from '../../common/content-item/content-dependancy-tree'; +import { ContentMapping } from '../../common/content-mapping'; +import { join } from 'path'; + +export const command = 'export '; + +export const desc = 'Export Events'; + +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('event', 'export', platform); + +export const builder = (yargs: Argv): void => { + yargs + .positional('dir', { + describe: 'Output directory for the exported Events.', + type: 'string' + }) + .option('id', { + describe: 'Export a single event by ID, rather then fetching all of them.', + type: 'string' + }) + .option('fromDate', { + describe: 'Start date for filtering events. Either "NOW" or in the format ":", example: "-7:DAYS".', + type: 'string' + }) + .option('toDate', { + describe: 'To date for filtering events. Either "NOW" or in the format ":", example: "-7:DAYS".', + type: 'string' + }) + .option('snapshots', { + describe: 'Save content snapshots with events, in subfolder "snapshots/".', + type: 'boolean', + boolean: true + }) + .option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); +}; + +interface ExportRecord { + readonly filename: string; + readonly status: ExportResult; + readonly event: Event; +} + +export class EditionWithSlots extends Edition { + slots: EditionSlot[]; +} + +export class EventWithEditions extends Event { + editions: EditionWithSlots[]; +} + +export const exportSnapshots = async ( + client: DynamicContent, + outputDir: string, + snapshots: Set, + log: FileLog +): Promise => { + const baseDir = join(outputDir, 'snapshots/'); + await ensureDirectoryExists(baseDir); + + log.appendLine(`Saving ${snapshots.size} snapshots to './snapshots/'.`); + + for (const id of snapshots) { + log.appendLine(`Fetching snapshot ${id}.`); + + let snapshot: Snapshot; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let snapshotJson: any; + try { + snapshot = await client.snapshots.get(id); + snapshotJson = snapshot.toJSON(); + + const content = await Promise.all( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (snapshotJson.rootContentItems as any[]).map((item: { id: string }) => + snapshot.related.snapshotContentItem(item.id) + ) + ); + + for (const item of content) { + const itemTree = new ContentDependancyTree( + [{ repo: new ContentRepository(), content: item }], + new ContentMapping() + ); + + for (const subItem of itemTree.all[0].dependancies) { + log.appendLine('... scanning item ' + subItem.dependancy.id + ' ' + subItem.dependancy._meta.schema); + try { + await snapshot.related.snapshotContentItem(subItem.dependancy.id as string); + log.appendLine('yep'); + } catch { + log.appendLine('nope!'); + } + } + } + + snapshotJson.content = content; + } catch (e) { + log.warn(`Could not fetch snapshot ${id}, continuing: `, e); + continue; + } + + const filename = join(baseDir, id + '.json'); + + try { + writeJsonToFile(filename, snapshotJson); + } catch (e) { + log.warn(`Could not write snapshot ${id}, continuing: `, e); + } + } +}; + +export const locateSnapshots = (slots: EditionSlot[], snapshots: Set): void => { + for (const slot of slots) { + if (slot.content.body) { + const item = { repo: new ContentRepository(), content: slot.content }; + const tree = new ContentDependancyTree([item], new ContentMapping()); + + const dependencies = tree.all[0].dependancies; + + for (const link of dependencies) { + if (link.dependancy.id) { + snapshots.add(link.dependancy.id); + } + } + } + } +}; + +export const locateAndExportSnapshots = async ( + client: DynamicContent, + outputDir: string, + events: EventWithEditions[], + log: FileLog +): Promise => { + const snapshots = new Set(); + + log.appendLine(`Scanning slots for snapshots.`); + + for (const event of events) { + for (const edition of event.editions) { + locateSnapshots(edition.slots, snapshots); + } + } + + await exportSnapshots(client, outputDir, snapshots, log); +}; + +export const enrichEditions = async (editions: Edition[]): Promise => { + for (const edition of editions) { + const withEditions = edition as EditionWithSlots; + const slots = await paginator(edition.related.slots.list); + withEditions.slots = slots; + + // SLOT todo + // content.body contains the version of the slot that will be scheduled in its entirety. + // this is simply a _meta.schema and _meta.name if the slot is empty, + // but if it isn't, it contains the full slot contents (with no depth) + // any links and references, however, have additional fields: + // + // ._meta.rootContentItemId - the true content id that the reference/link is pointing to + // ._meta.locked - not sure + // ._meta.schema - link or reference + // .id - this is NOT a content id, it's a snapshot ID of the content at the time it was selected by the content chooser. + // .contentType - type schema for the referenced content type + } + + return editions as EditionWithSlots[]; +}; + +export const enrichEvents = async (events: Event[], log?: FileLog): Promise => { + for (const event of events) { + if (log) { + log.appendLine(`Fetching ${event.name} with editions.`); + } + + const withEditions = event as EventWithEditions; + + try { + const editions = await paginator(event.related.editions.list); + withEditions.editions = await enrichEditions(editions); + } catch (e) { + if (log) { + log.warn(`Failed to fetch editions for ${event.name}, skipping.`, e); + } + } + } + + const result = events as EventWithEditions[]; + + return result.filter(event => event.editions != undefined); +}; + +export const getExportRecordForEvent = ( + event: EventWithEditions, + outputDir: string, + previouslyExportedEvents: { [filename: string]: EventWithEditions } +): ExportRecord => { + const indexOfExportedEvent = Object.values(previouslyExportedEvents).findIndex(c => c.id === event.id); + if (indexOfExportedEvent < 0) { + const filename = uniqueFilenamePath(outputDir, event.name, 'json', Object.keys(previouslyExportedEvents)); + + // This filename is now used. + previouslyExportedEvents[filename] = event; + + return { + filename: filename, + status: 'CREATED', + event + }; + } + const filename = Object.keys(previouslyExportedEvents)[indexOfExportedEvent]; + /* + const previouslyExportedEvent = Object.values(previouslyExportedEvents)[indexOfExportedEvent]; + + if (equals(previouslyExportedEvent, event)) { + return { filename, status: 'UP-TO-DATE', event }; + } + */ + return { + filename, + status: 'UPDATED', + event + }; +}; + +type ExportsMap = { + uri: string; + filename: string; +}; + +export const getEventExports = ( + outputDir: string, + previouslyExportedEvents: { [filename: string]: EventWithEditions }, + eventsBeingExported: EventWithEditions[] +): [ExportRecord[], ExportsMap[]] => { + const allExports: ExportRecord[] = []; + const updatedExportsMap: ExportsMap[] = []; // uri x filename + for (const event of eventsBeingExported) { + if (!event.id) { + continue; + } + + const exportRecord = getExportRecordForEvent(event, outputDir, previouslyExportedEvents); + allExports.push(exportRecord); + if (exportRecord.status === 'UPDATED') { + updatedExportsMap.push({ uri: event.id, filename: exportRecord.filename }); + } + } + return [allExports, updatedExportsMap]; +}; + +export const processEvents = async ( + outputDir: string, + previouslyExportedEvents: { [filename: string]: EventWithEditions }, + enrichedEvents: EventWithEditions[], + log: FileLog +): Promise => { + if (enrichedEvents.length === 0) { + nothingExportedExit(log, 'No events to export from this hub, exiting.'); + return; + } + + const [allExports, updatedExportsMap] = getEventExports(outputDir, previouslyExportedEvents, enrichedEvents); + if ( + allExports.length === 0 || + (Object.keys(updatedExportsMap).length > 0 && !(await promptToOverwriteExports(updatedExportsMap, log))) + ) { + nothingExportedExit(log); + return; + } + + await ensureDirectoryExists(outputDir); + + const tableStream = (createStream(streamTableOptions) as unknown) as TableStream; + tableStream.write([chalk.bold('File'), chalk.bold('Schema ID'), chalk.bold('Result')]); + for (const { filename, status, event } of allExports) { + if (status !== 'UP-TO-DATE') { + delete event.id; // do not export id + writeJsonToFile(filename, event); + } + tableStream.write([filename, event.name || '', status]); + } + process.stdout.write('\n'); +}; + +export const filterEvents = (events: Event[], from: Date | undefined, to: Date | undefined): Event[] => { + return events.filter(event => { + const eventStart = new Date(event.start as string); + const eventEnd = new Date(event.end as string); + + if (from && eventEnd < from) { + return false; + } + + if (to && eventStart > to) { + return false; + } + + return true; + }); +}; + +export const handler = async (argv: Arguments): Promise => { + const { dir, fromDate, toDate, logFile, id, snapshots } = argv; + + const log = logFile.open(); + + const from = fromDate === undefined ? undefined : relativeDate(fromDate); + const to = toDate === undefined ? undefined : relativeDate(toDate); + + const previouslyExportedEvents = loadJsonFromDirectory(dir, EventWithEditions); + + const client = dynamicContentClientFactory(argv); + + let hub: Hub; + try { + hub = await client.hubs.get(argv.hubId); + } catch (e) { + log.error(`Couldn't get hub with id ${argv.hubId}, aborting.`, e); + await log.close(); + return; + } + + let filteredEvents: Event[]; + if (id) { + try { + filteredEvents = [await client.events.get(id)]; + log.appendLine(`Exporting single event ${filteredEvents[0].name}.`); + } catch (e) { + log.error(`Failed to get event with id ${id}, aborting.`, e); + await log.close(); + return; + } + } else { + try { + const storedEvents = await paginator(hub.related.events.list); + + filteredEvents = filterEvents(storedEvents, from, to); + + log.appendLine(`Exporting ${filteredEvents.length} of ${storedEvents.length} events...`); + } catch (e) { + log.error(`Failed to list events.`, e); + filteredEvents = []; + } + } + + const enrichedEvents = await enrichEvents(filteredEvents, log); + + await processEvents(dir, previouslyExportedEvents, enrichedEvents, log); + + if (snapshots) { + await locateAndExportSnapshots(client, dir, enrichedEvents, log); + } + + log.appendLine(`Done.`); + + await log.close(); +}; diff --git a/src/commands/event/import.spec.ts b/src/commands/event/import.spec.ts new file mode 100644 index 00000000..dae39136 --- /dev/null +++ b/src/commands/event/import.spec.ts @@ -0,0 +1,648 @@ +import { + builder, + command, + getDefaultMappingPath, + handler, + importEditions, + importEvents, + importSlots, + LOG_FILENAME, + trySaveMapping +} from './import'; +import * as importModule from './import'; +import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; +import { Event, Edition, Hub, EditionSlot, Snapshot, DynamicContent } from 'dc-management-sdk-js'; +import Yargs from 'yargs/yargs'; +import MockPage from '../../common/dc-management-sdk-js/mock-page'; + +import rmdir from 'rimraf'; +import { FileLog } from '../../common/file-log'; +import { ContentMapping } from '../../common/content-mapping'; +import { mockValues } from './event-test-helpers'; +import { EditionWithSlots, EventWithEditions } from './export'; +import { ImportEventBuilderOptions } from '../../interfaces/import-event-builder-options.interface'; +import { loadJsonFromDirectory } from '../../services/import.service'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; + +jest.mock('../../services/dynamic-content-client-factory'); +jest.mock('../../services/import.service'); + +jest.mock('../../common/log-helpers', () => ({ + ...jest.requireActual('../../common/log-helpers'), + getDefaultLogPath: jest.fn() +})); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +describe('event import command', () => { + afterEach((): void => { + jest.restoreAllMocks(); + }); + + const yargArgs = { + $0: 'test', + _: ['test'], + json: true, + silent: true + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + const commonMock = async ( + customArgs = {} + ): Promise<{ + client: DynamicContent; + hub: Hub; + argv: ImportEventBuilderOptions; + log: FileLog; + mapping: ContentMapping; + }> => { + const client = await dynamicContentClientFactory(config); + const log = new FileLog(); + return { + client, + hub: await client.hubs.get('hub-id'), + log: log, + mapping: new ContentMapping(), + argv: { + ...yargArgs, + ...config, + dir: '', + originalIds: false, + logFile: log, + ...customArgs + } + }; + }; + + it('should command should defined', function() { + expect(command).toEqual('import '); + }); + + it('should use getDefaultLogPath for LOG_FILENAME with process.platform as default', function() { + LOG_FILENAME(); + + expect(getDefaultLogPath).toHaveBeenCalledWith('event', 'import', 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 containing Events', + type: 'string' + }); + + 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 existing events, editions, slots and snapshots without asking.' + }); + + expect(spyOption).toHaveBeenCalledWith('originalIds', { + type: 'boolean', + boolean: true, + describe: 'Use original ids' + }); + + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); + }); + }); + + describe('handler tests', function() { + beforeAll(async () => { + await rimraf('temp/importEvent/'); + }); + + afterAll(async () => { + await rimraf('temp/importEvent/'); + }); + + it('should call importEvents with the loaded events, then save the mapping', async function() { + const { getHubMock } = mockValues({}); + + const logFile = new FileLog(); + const argv = { + ...yargArgs, + ...config, + logFile, + dir: 'temp/importEvent/', + originalIds: false + }; + const event = new EventWithEditions({ id: 'id-1' }); + + (loadJsonFromDirectory as jest.Mock).mockResolvedValue({ + 'event1.json': event + }); + + const importEvents = jest.spyOn(importModule, 'importEvents').mockResolvedValue(); + const trySaveMapping = jest.spyOn(importModule, 'trySaveMapping').mockResolvedValue(); + const getDefaultMappingPath = jest.spyOn(importModule, 'getDefaultMappingPath').mockReturnValue('mapping.json'); + + await handler(argv); + + expect(getHubMock).toHaveBeenCalledWith('hub-id'); //from returned hub + expect(loadJsonFromDirectory as jest.Mock).toHaveBeenCalledWith('temp/importEvent/', EventWithEditions); + + expect(importEvents).toHaveBeenCalledWith( + [event], + expect.any(ContentMapping), + expect.any(Object), + expect.any(Hub), + argv, + logFile + ); + expect(getDefaultMappingPath).toHaveBeenCalledWith('hub-1'); + expect(trySaveMapping).toHaveBeenCalledWith('mapping.json', expect.any(ContentMapping), logFile); + expect(logFile.closed).toBeTruthy(); + }); + + it('should load an existing mapping file', async function() { + const { getHubMock } = mockValues({}); + + const logFile = new FileLog(); + const argv = { + ...yargArgs, + ...config, + logFile, + mapFile: 'temp/importEvent/importEvent.json', + dir: 'temp/importEvent/', + originalIds: false + }; + const event = new EventWithEditions({ id: 'id-1' }); + + (loadJsonFromDirectory as jest.Mock).mockResolvedValue({ + 'event1.json': event + }); + + const importEvents = jest.spyOn(importModule, 'importEvents').mockResolvedValue(); + const trySaveMapping = jest.spyOn(importModule, 'trySaveMapping').mockResolvedValue(); + const getDefaultMappingPath = jest.spyOn(importModule, 'getDefaultMappingPath'); + + const existingMapping = new ContentMapping(); + await existingMapping.save(argv.mapFile); + + await handler(argv); + + expect(getHubMock).toHaveBeenCalledWith('hub-id'); //from returned hub + expect(loadJsonFromDirectory as jest.Mock).toHaveBeenCalledWith('temp/importEvent/', EventWithEditions); + + expect(importEvents).toHaveBeenCalledWith( + [event], + expect.any(ContentMapping), + expect.any(Object), + expect.any(Hub), + argv, + logFile + ); + expect(getDefaultMappingPath).not.toHaveBeenCalled(); + expect(trySaveMapping).toHaveBeenCalledWith(argv.mapFile, expect.any(ContentMapping), logFile); + expect(logFile.closed).toBeTruthy(); + }); + }); + + describe('rewriteSnapshots tests', function() { + it('should create new snapshots if no mapping is present, and use existing when it is', async function() { + const { mockSnapshotCreate } = mockValues({}); + + const { hub, log, mapping } = await commonMock(); + + mapping.registerContentItem('item1', 'realItem1'); + mapping.registerContentItem('item2', 'realItem2'); + + mapping.registerSnapshot('snap2', 'existingSnap'); + + const content = { + label: 'example', + body: { + _meta: { + name: 'example', + schema: 'https://amplience.com/example.json' + }, + chooser: [ + { + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-link', + locked: false, + rootContentItemId: 'item1' + }, + contentType: 'https://amplience.com/example.json', + id: 'snap1' + }, + { + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-link', + locked: false, + rootContentItemId: 'item2' + }, + contentType: 'https://amplience.com/example.json', + id: 'snap2' + } + ] + } + }; + + (mockSnapshotCreate as jest.Mock).mockResolvedValue({ snapshots: [new Snapshot({ id: 'newSnap' })] }); + + await importModule.rewriteSnapshots(content, mapping, hub, log); + + expect(mockSnapshotCreate).toHaveBeenCalledTimes(1); + expect((mockSnapshotCreate as jest.Mock).mock.calls[0][0]).toMatchInlineSnapshot(` + Array [ + Object { + "comment": "", + "contentRoot": "realItem1", + "createdFrom": "content-item", + "type": "GENERATED", + }, + ] + `); + expect(mapping.getSnapshot('snap1')).toEqual('newSnap'); + }); + + it('should not create snapshots when content has no references', async function() { + const { mockSnapshotCreate } = mockValues({}); + + const { hub, log, mapping } = await commonMock(); + + const content = { + label: 'example', + body: { + _meta: { + name: 'example', + schema: 'https://amplience.com/example.json' + } + } + }; + + await importModule.rewriteSnapshots(content, mapping, hub, log); + + expect(mockSnapshotCreate).toHaveBeenCalledTimes(0); + }); + }); + + describe('importSlots tests', function() { + it('should look up existing slot from mapping if present, and update it', async function() { + mockValues({}); + + const realSlot = new EditionSlot({ id: 'id-2', content: 'updated' }); + realSlot.related.content = jest.fn().mockResolvedValue(realSlot); + + const { hub, argv, log, mapping } = await commonMock(); + + mapping.registerSlot('id-1', 'id-2'); + + const rewriteSnapshots = jest.spyOn(importModule, 'rewriteSnapshots').mockResolvedValue(); + const importTest = [ + new EditionSlot({ + id: 'id-1', + content: '{ "content": "test" }' + }) + ]; + + const realEdition = new Edition(); + realEdition.related.slots.list = jest.fn().mockResolvedValue(new MockPage(EditionSlot, [realSlot])); + + await importSlots(importTest, mapping, hub, realEdition, argv, log); + + expect(realEdition.related.slots.list).toHaveBeenCalledTimes(1); + expect(realSlot.related.content).toHaveBeenCalledTimes(1); + + expect(rewriteSnapshots).toHaveBeenCalledWith('{ "content": "test" }', mapping, hub, log); + }); + + it('should look up original id if no mapping present and originalIds set', async function() { + mockValues({}); + + const realSlot = new EditionSlot({ id: 'id-1', content: 'updated' }); + realSlot.related.content = jest.fn().mockResolvedValue(realSlot); + + const { hub, argv, log, mapping } = await commonMock({ originalIds: true }); + + const rewriteSnapshots = jest.spyOn(importModule, 'rewriteSnapshots').mockResolvedValue(); + const importTest = [ + new EditionSlot({ + id: 'id-1', + content: '{ "content": "test" }' + }) + ]; + + const realEdition = new Edition(); + realEdition.related.slots.list = jest.fn().mockResolvedValue(new MockPage(EditionSlot, [realSlot])); + + await importSlots(importTest, mapping, hub, realEdition, argv, log); + + expect(realEdition.related.slots.list).toHaveBeenCalledTimes(1); + expect(realSlot.related.content).toHaveBeenCalledTimes(1); + + expect(rewriteSnapshots).toHaveBeenCalledWith('{ "content": "test" }', mapping, hub, log); + }); + + it('should create a new slot if no existing one is found', async function() { + mockValues({}); + + const realSlot = new EditionSlot({ id: 'id-new', content: 'updated' }); + realSlot.related.content = jest.fn().mockResolvedValue(realSlot); + + const { hub, argv, log, mapping } = await commonMock({ originalIds: false }); + + const rewriteSnapshots = jest.spyOn(importModule, 'rewriteSnapshots').mockResolvedValue(); + const importTest = [ + new EditionSlot({ + id: 'id-1', + content: '{ "content": "test" }' + }) + ]; + + const realEdition = new Edition(); + realEdition.related.slots.create = jest.fn().mockResolvedValue(new MockPage(EditionSlot, [realSlot])); + realEdition.related.slots.list = jest.fn().mockResolvedValue(new MockPage(EditionSlot, [])); + + await importSlots(importTest, mapping, hub, realEdition, argv, log); + + expect(realEdition.related.slots.list).toHaveBeenCalledTimes(1); + expect(realEdition.related.slots.create).toHaveBeenCalledTimes(1); + expect(realSlot.related.content).toHaveBeenCalledTimes(1); + + expect(mapping.getSlot('id-1')).toEqual('id-new'); + + expect(rewriteSnapshots).toHaveBeenCalledWith('{ "content": "test" }', mapping, hub, log); + }); + }); + + describe('importEditions tests', function() { + it('should look up existing edition from mapping if present, and update it', async function() { + const { mockEditionGet, mockEditionUpdate } = mockValues({}); + + const realEdition = new Edition({ id: 'id-2', name: 'updated' }); + (mockEditionUpdate as jest.Mock).mockResolvedValue(realEdition); + + const { client, hub, argv, log, mapping } = await commonMock(); + + mapping.registerEdition('id-1', 'id-2'); + + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; + const importTest = [ + new EditionWithSlots({ + id: 'id-1', + name: 'Edition', + start: '0', + end: '1', + comment: 'comment', + slots + }) + ]; + + const realEvent = new Event(); + + await importEditions(importTest, mapping, client, hub, realEvent, argv, log); + + expect(mockEditionGet).toHaveBeenCalledWith('id-2'); + expect(mockEditionUpdate).toHaveBeenCalledTimes(1); + + expect(importSlots).toHaveBeenCalledWith(slots, mapping, hub, realEdition, argv, log); + }); + + it('should look up original id if no mapping present and originalIds set', async function() { + const { mockEditionGet, mockEditionUpdate } = mockValues({}); + + const realEdition = new Edition({ id: 'id-1', name: 'updated' }); + (mockEditionUpdate as jest.Mock).mockResolvedValue(realEdition); + + const { client, hub, argv, log, mapping } = await commonMock({ originalIds: true }); + + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; + const importTest = [ + new EditionWithSlots({ + id: 'id-1', + name: 'Edition', + start: '0', + end: '1', + comment: 'comment', + slots + }) + ]; + + const realEvent = new Event(); + + await importEditions(importTest, mapping, client, hub, realEvent, argv, log); + + expect(mockEditionGet).toHaveBeenCalledWith('id-1'); + expect(mockEditionUpdate).toHaveBeenCalledTimes(1); + + expect(importSlots).toHaveBeenCalledWith(slots, mapping, hub, realEdition, argv, log); + }); + + it('should create a new edition if no existing one is found', async function() { + const { mockEditionGet, mockEditionUpdate } = mockValues({}); + + const realEdition = new Edition({ id: 'id-1', name: 'updated' }); + const realEvent = new Event(); + realEvent.related.editions.create = jest.fn().mockResolvedValue(realEdition); + + const { client, hub, argv, log, mapping } = await commonMock({ originalIds: false }); + + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; + const importTest = [ + new EditionWithSlots({ + id: 'id-1', + name: 'Edition', + start: '0', + end: '1', + comment: 'comment', + slots + }) + ]; + + await importEditions(importTest, mapping, client, hub, realEvent, argv, log); + + expect(mockEditionGet).not.toHaveBeenCalled(); + expect(mockEditionUpdate).not.toHaveBeenCalled(); + expect(realEvent.related.editions.create).toHaveBeenCalledTimes(1); + + expect(importSlots).toHaveBeenCalledWith(slots, mapping, hub, realEdition, argv, log); + }); + }); + + describe('importEvents tests', function() { + it('should look up existing event from mapping if present, and update it', async function() { + const { mockGet, mockEventUpdate } = mockValues({}); + + const realEvent = new Event({ id: 'id-2', name: 'updated' }); + (mockEventUpdate as jest.Mock).mockResolvedValue(realEvent); + + const { client, hub, argv, log, mapping } = await commonMock(); + + mapping.registerEvent('id-1', 'id-2'); + + const importEditions = jest.spyOn(importModule, 'importEditions').mockResolvedValue(); + const editions = [new EditionWithSlots({ id: 'edition1' }), new EditionWithSlots({ id: 'edition2' })]; + const importTest = [ + new EventWithEditions({ + id: 'id-1', + name: 'event', + start: '0', + end: '1', + comment: 'comment', + brief: 'brief', + editions + }) + ]; + + await importEvents(importTest, mapping, client, hub, argv, log); + + expect(mockGet).toHaveBeenCalledWith('id-2'); + expect(mockEventUpdate).toHaveBeenCalledTimes(1); + + expect(importEditions).toHaveBeenCalledWith(editions, mapping, client, hub, realEvent, argv, log); + }); + + it('should look up original id if no mapping present and originalIds set', async function() { + const { mockGet, mockEventUpdate } = mockValues({}); + + const realEvent = new Event({ id: 'id-1', name: 'updated' }); + (mockEventUpdate as jest.Mock).mockResolvedValue(realEvent); + + const { client, hub, argv, log, mapping } = await commonMock({ originalIds: true }); + + const importEditions = jest.spyOn(importModule, 'importEditions').mockResolvedValue(); + const editions = [new EditionWithSlots({ id: 'edition1' }), new EditionWithSlots({ id: 'edition2' })]; + const importTest = [ + new EventWithEditions({ + id: 'id-1', + name: 'event', + start: '0', + end: '1', + comment: 'comment', + brief: 'brief', + editions + }) + ]; + + await importEvents(importTest, mapping, client, hub, argv, log); + + expect(mockGet).toHaveBeenCalledWith('id-1'); + expect(mockEventUpdate).toHaveBeenCalledTimes(1); + + expect(importEditions).toHaveBeenCalledWith(editions, mapping, client, hub, realEvent, argv, log); + }); + + it('should create a new event if no existing one is found', async function() { + const { mockGet, mockEventCreate, mockEventUpdate } = mockValues({}); + + const realEvent = new Event({ id: 'new-id', name: 'updated' }); + (mockEventCreate as jest.Mock).mockResolvedValue(realEvent); + + const { client, hub, argv, log, mapping } = await commonMock({ originalIds: false }); + + const importEditions = jest.spyOn(importModule, 'importEditions').mockResolvedValue(); + const editions = [new EditionWithSlots({ id: 'edition1' }), new EditionWithSlots({ id: 'edition2' })]; + const importTest = [ + new EventWithEditions({ + id: 'id-1', + name: 'event', + start: '0', + end: '1', + comment: 'comment', + brief: 'brief', + editions + }) + ]; + + await importEvents(importTest, mapping, client, hub, argv, log); + + expect(mockGet).not.toHaveBeenCalled(); + expect(mockEventUpdate).toHaveBeenCalledTimes(0); + expect(mockEventCreate).toHaveBeenCalledTimes(1); + + expect(mapping.getEvent('id-1')).toEqual('new-id'); + + expect(importEditions).toHaveBeenCalledWith(editions, mapping, client, hub, realEvent, argv, log); + }); + }); + + describe('trySaveMapping tests', function() { + it('should save a given mapping file', async function() { + const log = new FileLog(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fakeMapping: any = { + save: jest.fn().mockResolvedValue(true) + }; + + await trySaveMapping('file.txt', fakeMapping as ContentMapping, log); + + expect(fakeMapping.save).toHaveBeenCalledWith('file.txt'); + expect(log.accessGroup.length).toEqual(0); + }); + + it('should log an error if mapping save fails', async function() { + const log = new FileLog(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fakeMapping: any = { + save: jest.fn().mockRejectedValue('error') + }; + + await trySaveMapping('file.txt', fakeMapping as ContentMapping, log); + + expect(fakeMapping.save).toHaveBeenCalledWith('file.txt'); + expect(log.accessGroup.length).toEqual(1); + expect(log.accessGroup[0]).toMatchInlineSnapshot(` + Object { + "comment": true, + "data": "Failed to save the mapping. error", + } + `); + }); + + it('should do nothing for an undefined mapFile', async function() { + const log = new FileLog(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fakeMapping: any = { + save: jest.fn().mockRejectedValue('error') + }; + + await trySaveMapping(undefined, fakeMapping, log); + + expect(log.accessGroup.length).toEqual(0); + expect(fakeMapping.save).not.toHaveBeenCalled(); + }); + }); + + /* + describe('handler tests', function() {}); + */ +}); diff --git a/src/commands/event/import.ts b/src/commands/event/import.ts new file mode 100644 index 00000000..d7071807 --- /dev/null +++ b/src/commands/event/import.ts @@ -0,0 +1,333 @@ +import { Arguments, Argv } from 'yargs'; +import { ConfigurationParameters } from '../configure'; +import { + DynamicContent, + Event, + Edition, + EditionSlot, + Hub, + ContentRepository, + Snapshot, + SnapshotType +} from 'dc-management-sdk-js'; +import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; +import paginator from '../../common/dc-management-sdk-js/paginator'; +import { loadJsonFromDirectory } from '../../services/import.service'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { ImportEventBuilderOptions } from '../../interfaces/import-event-builder-options.interface'; +import { EditionWithSlots, EventWithEditions } from './export'; +import { ContentMapping } from '../../common/content-mapping'; +import { join } from 'path'; +import { FileLog } from '../../common/file-log'; +import { + ContentDependancy, + ContentDependancyTree, + DependancyContentTypeSchema +} from '../../common/content-item/content-dependancy-tree'; +import { SnapshotCreator } from 'dc-management-sdk-js/build/main/lib/model/SnapshotCreator'; + +export const command = 'import '; + +export const desc = 'Import Events'; + +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('event', 'import', platform); + +export const getDefaultMappingPath = (name: string, platform: string = process.platform): string => { + return join( + process.env[platform == 'win32' ? 'USERPROFILE' : 'HOME'] || __dirname, + '.amplience', + `imports/`, + `${name}.json` + ); +}; + +export const builder = (yargs: Argv): void => { + yargs + .positional('dir', { + describe: 'Directory containing Events', + type: 'string' + }) + + .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 existing events, editions, slots and snapshots without asking.' + }) + + .option('originalIds', { + type: 'boolean', + boolean: true, + describe: 'Use original ids' + }) + + .option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); +}; + +interface SlotDependencyMeta { + name: string; + rootContentItemId: string; + locked: boolean; + schema: DependancyContentTypeSchema; +} + +interface SlotDependency extends ContentDependancy { + _meta: SlotDependencyMeta; +} + +export const rewriteSnapshots = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + content: any, + mapping: ContentMapping, + hub: Hub, + log: FileLog +): Promise => { + // Search for links/references in the slot content. + const dummyRepo = new ContentRepository(); + const tree = new ContentDependancyTree([{ repo: dummyRepo, content }], new ContentMapping()); + + const dependencies = tree.all[0].dependancies; + + for (const dep of dependencies) { + const entry = dep.dependancy as SlotDependency; + + // Try find the snapshot in the mapping + let snapshotId = mapping.getSnapshot(entry.id); + + if (snapshotId == null) { + // Create a new snapshot based off of the current content state + const result = await hub.related.snapshots.create([ + new Snapshot({ + contentRoot: mapping.getContentItem(entry._meta.rootContentItemId) || entry._meta.rootContentItemId, + comment: '', + createdFrom: SnapshotCreator.ContentItem, + type: SnapshotType.GENERATED + }) + ]); + + const snapshot = result.snapshots[0]; + + snapshotId = snapshot.id as string; + + mapping.registerSnapshot(entry.id as string, snapshotId); + + log.addAction('SNAPSHOT-CREATE', snapshotId); + } + + dep.dependancy.id = snapshotId; + } +}; + +export const importSlots = async ( + slots: EditionSlot[], + mapping: ContentMapping, + hub: Hub, + edition: Edition, + argv: ImportEventBuilderOptions, + log: FileLog +): Promise => { + const editionSlots = await paginator(edition.related.slots.list); + + for (const slot of slots) { + let realSlot: EditionSlot | undefined = undefined; + + // Attempt to get the existing edition, if present. + const slotId = mapping.getSlot(slot.id); + + if (slotId == null) { + if (argv.originalIds && slot.id) { + // Look up the original ID. + realSlot = editionSlots.find(editionSlot => editionSlot.id === slot.id); + } + } else { + // Look up the mapped ID. + realSlot = editionSlots.find(editionSlot => editionSlot.id === slotId); + } + + // Attempt to link to existing content item + const itemId = mapping.getContentItem(slot.slotId) || (slot.slotId as string); + + const updated = realSlot != null; + + if (realSlot == null) { + // Create a new slot based off of the file. + const slotPage = await edition.related.slots.create([{ slot: itemId }]); + + const items = slotPage.getItems(); + + realSlot = items[0]; + + mapping.registerSlot(slot.id as string, realSlot.id as string); + } + + // Update the existing slot based off of the file. + await rewriteSnapshots(slot.content, mapping, hub, log); + + realSlot = await realSlot.related.content(slot.content); + + log.addComment(`${updated ? 'Updated' : 'Created'} slot ${realSlot.slotId}.`); + log.addAction(`SLOT-${updated ? 'UPDATE' : 'CREATE'}`, realSlot.id as string); + } +}; + +export const importEditions = async ( + editions: EditionWithSlots[], + mapping: ContentMapping, + client: DynamicContent, + hub: Hub, + event: Event, + argv: ImportEventBuilderOptions, + log: FileLog +): Promise => { + for (const edition of editions) { + let realEdition: Edition | null = null; + + // Attempt to get the existing edition, if present. + const editionId = mapping.getEdition(edition.id); + + if (editionId == null) { + if (argv.originalIds && edition.id) { + // Look up the original ID. + realEdition = await client.editions.get(edition.id); + } + } else { + // Look up the mapped ID. + realEdition = await client.editions.get(editionId); + } + + const filteredEdition = new Edition({ + name: edition.name, + start: edition.start, + end: edition.end, + comment: edition.comment, + activeEndDate: edition.activeEndDate + }); + + if (realEdition == null) { + // Create a new edition based off of the file. + realEdition = await event.related.editions.create(filteredEdition); + + log.addComment(`Created edition ${realEdition.name}.`); + log.addAction('EDITION-CREATE', realEdition.id as string); + + mapping.registerEdition(edition.id as string, realEdition.id as string); + } else { + // Update the existing edition based off of the file. + realEdition = await realEdition.related.update(filteredEdition); + + log.addComment(`Updated edition ${realEdition.name}.`); + log.addAction('EDITION-UPDATE', realEdition.id as string); + } + + // Attempt to create slots + await importSlots(edition.slots, mapping, hub, realEdition, argv, log); + } +}; + +export const importEvents = async ( + events: EventWithEditions[], + mapping: ContentMapping, + client: DynamicContent, + hub: Hub, + argv: ImportEventBuilderOptions, + log: FileLog +): Promise => { + for (const event of events) { + let realEvent: Event | null = null; + // Attempt to get the existing event, if present. + + const eventId = mapping.getEvent(event.id); + + if (eventId == null) { + if (argv.originalIds && event.id) { + // Look up the original ID. + realEvent = await client.events.get(event.id); + } + } else { + // Look up the mapped ID. + realEvent = await client.events.get(eventId); + } + + const filteredEvent = new Event({ + name: event.name, + start: event.start, + end: event.end, + comment: event.comment, + brief: event.brief + }); + + if (realEvent == null) { + // Create a new event based off of the file. + realEvent = await hub.related.events.create(filteredEvent); + + log.addComment(`Created event ${realEvent.name}.`); + log.addAction('EVENT-CREATE', realEvent.id as string); + + mapping.registerEvent(event.id as string, realEvent.id as string); + } else { + // Update the existing event based off of the file. + realEvent = await realEvent.related.update(filteredEvent); + + log.addComment(`Updated event ${realEvent.name}.`); + log.addAction('EVENT-UPDATE', realEvent.id as string); + } + + // Attempt to create editions + await importEditions(event.editions, mapping, client, hub, realEvent, argv, log); + } +}; + +export const trySaveMapping = async ( + mapFile: string | undefined, + mapping: ContentMapping, + log: FileLog +): Promise => { + if (mapFile != null) { + try { + await mapping.save(mapFile); + } catch (e) { + log.appendLine(`Failed to save the mapping. ${e.toString()}`); + } + } +}; + +export const handler = async (argv: Arguments): Promise => { + const { dir, logFile } = argv; + + const client = dynamicContentClientFactory(argv); + const log = logFile.open(); + + const hub = await client.hubs.get(argv.hubId); + + const events = await loadJsonFromDirectory(dir, EventWithEditions); + + const importTitle = `hub-${hub.id}`; + const mapFile = argv.mapFile || getDefaultMappingPath(importTitle); + + const mapping = new ContentMapping(); + if (await mapping.load(mapFile)) { + log.appendLine(`Existing mapping loaded from '${mapFile}', changes will be saved back to it.`); + } else { + log.appendLine(`Creating new mapping file at '${mapFile}'.`); + } + + await importEvents(Object.values(events), mapping, client, hub, argv, log); + + await trySaveMapping(mapFile, mapping, log); + + log.appendLine('Done.'); + + await log.close(); +}; diff --git a/src/commands/hub/clone.spec.ts b/src/commands/hub/clone.spec.ts index 6a0e69e8..5d06100a 100644 --- a/src/commands/hub/clone.spec.ts +++ b/src/commands/hub/clone.spec.ts @@ -9,6 +9,7 @@ import * as schema from './steps/schema-clone-step'; import * as type from './steps/type-clone-step'; import * as extension from './steps/extension-clone-step'; import * as index from './steps/index-clone-step'; +import * as event from './steps/event-clone-step'; import rmdir from 'rimraf'; import { CloneHubBuilderOptions } from '../../interfaces/clone-hub-builder-options'; @@ -22,7 +23,7 @@ jest.mock('readline'); jest.mock('../../services/dynamic-content-client-factory'); -let success = [true, true, true, true, true, true]; +let success = [true, true, true, true, true, true, true]; // eslint-disable-next-line @typescript-eslint/no-explicit-any function succeedOrFail(mock: any, succeed: () => boolean): jest.Mock { @@ -64,6 +65,10 @@ jest.mock('./steps/content-clone-step', () => ({ ContentCloneStep: mockStep('Clone Content', 'content', () => success[5]) })); +jest.mock('./steps/event-clone-step', () => ({ + EventCloneStep: mockStep('Clone Event', 'event', () => success[5]) +})); + jest.mock('../../common/log-helpers', () => ({ ...jest.requireActual('../../common/log-helpers'), getDefaultLogPath: jest.fn() @@ -82,7 +87,8 @@ function getMocks(): jest.Mock[] { schema.SchemaCloneStep as jest.Mock, type.TypeCloneStep as jest.Mock, index.IndexCloneStep as jest.Mock, - content.ContentCloneStep as jest.Mock + content.ContentCloneStep as jest.Mock, + event.EventCloneStep as jest.Mock ]; } @@ -270,7 +276,7 @@ describe('hub clone command', () => { it('should call all steps in order with given parameters', async () => { clearMocks(); - success = [true, true, true, true, true, true]; + success = [true, true, true, true, true, true, true]; const argv: Arguments = { ...yargArgs, @@ -308,9 +314,9 @@ describe('hub clone command', () => { }); it('should handle false returns from each of the steps by stopping the process', async () => { - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 7; i++) { clearMocks(); - success = [i != 0, i != 1, i != 2, i != 3, i != 4, i != 5]; + success = [i != 0, i != 1, i != 2, i != 3, i != 4, i != 5, i != 6]; const argv: Arguments = { ...yargArgs, @@ -352,9 +358,9 @@ describe('hub clone command', () => { }); it('should start from the step given as a parameter', async () => { - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 7; i++) { clearMocks(); - success = [true, true, true, true, true, true]; + success = [true, true, true, true, true, true, true]; const argv: Arguments = { ...yargArgs, @@ -464,7 +470,7 @@ describe('hub clone command', () => { it('should revert all steps in order with given parameters', async () => { clearMocks(); - success = [true, true, true, true, true, true]; + success = [true, true, true, true, true, true, true]; await ensureDirectoryExists(`temp_${process.env.JEST_WORKER_ID}/clone-revert/`); await prepareFakeLog(`temp_${process.env.JEST_WORKER_ID}/clone-revert/steps.log`); @@ -504,9 +510,9 @@ describe('hub clone command', () => { }); it('should handle exceptions from each of the revert steps by stopping the process', async () => { - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 7; i++) { clearMocks(); - success = [i != 0, i != 1, i != 2, i != 3, i != 4, i != 5]; + success = [i != 0, i != 1, i != 2, i != 3, i != 4, i != 5, i != 6]; await ensureDirectoryExists(`temp_${process.env.JEST_WORKER_ID}/clone-revert/`); await prepareFakeLog(`temp_${process.env.JEST_WORKER_ID}/clone-revert/fail.log`); @@ -553,7 +559,7 @@ describe('hub clone command', () => { it('should exit early if revert log cannot be read', async () => { clearMocks(); - success = [true, true, true, true, true, true]; + success = [true, true, true, true, true, true, true]; await ensureDirectoryExists(`temp_${process.env.JEST_WORKER_ID}/clone-revert/`); const argv: Arguments = { @@ -590,9 +596,9 @@ describe('hub clone command', () => { }); it('should start reverting from the step given as a parameter (steps in decreasing order)', async () => { - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 7; i++) { clearMocks(); - success = [true, true, true, true, true, true]; + success = [true, true, true, true, true, true, true]; await ensureDirectoryExists(`temp_${process.env.JEST_WORKER_ID}/clone-revert/`); await prepareFakeLog(`temp_${process.env.JEST_WORKER_ID}/clone-revert/step.log`); diff --git a/src/commands/hub/clone.ts b/src/commands/hub/clone.ts index 23b88375..82f72020 100644 --- a/src/commands/hub/clone.ts +++ b/src/commands/hub/clone.ts @@ -14,6 +14,7 @@ import { IndexCloneStep } from './steps/index-clone-step'; import { CloneHubState } from './model/clone-hub-state'; import { LogErrorLevel } from '../../common/archive/archive-log'; import { ExtensionCloneStep } from './steps/extension-clone-step'; +import { EventCloneStep } from './steps/event-clone-step'; export function getDefaultMappingPath(name: string, platform: string = process.platform): string { return join( @@ -47,7 +48,8 @@ export const steps = [ new SchemaCloneStep(), new TypeCloneStep(), new IndexCloneStep(), - new ContentCloneStep() + new ContentCloneStep(), + new EventCloneStep() ]; export const builder = (yargs: Argv): void => { diff --git a/src/commands/hub/model/clone-hub-step.ts b/src/commands/hub/model/clone-hub-step.ts index b81cc9b0..1d48cb11 100644 --- a/src/commands/hub/model/clone-hub-step.ts +++ b/src/commands/hub/model/clone-hub-step.ts @@ -6,7 +6,8 @@ export enum CloneHubStepId { Schema = 'schema', Type = 'type', Index = 'index', - Content = 'content' + Content = 'content', + Event = 'event' } export interface CloneHubStep { diff --git a/src/commands/hub/steps/event-clone-step.spec.ts b/src/commands/hub/steps/event-clone-step.spec.ts new file mode 100644 index 00000000..33eac5f3 --- /dev/null +++ b/src/commands/hub/steps/event-clone-step.spec.ts @@ -0,0 +1,267 @@ +import { Arguments } from 'yargs'; +import { Event } from 'dc-management-sdk-js'; +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 eventImport from '../../event/import'; +import * as eventExport from '../../event/export'; + +import { EventCloneStep } from './event-clone-step'; +import { CloneHubStepId } from '../model/clone-hub-step'; + +jest.mock('../../../services/dynamic-content-client-factory'); +jest.mock('../../event/import'); +jest.mock('../../event/export'); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +describe('event clone step', () => { + let mockGetEvent: jest.Mock; + let mockArchiveEvent: jest.Mock; + let mockFailArchiveEvent: jest.Mock; + + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + function reset(): void { + jest.resetAllMocks(); + + mockGetEvent = jest.fn(); + mockArchiveEvent = jest.fn(); + mockFailArchiveEvent = jest.fn().mockRejectedValue(new Error('Already archived')); + + (dynamicContentClientFactory as jest.Mock).mockReturnValue({ + events: { + get: mockGetEvent + } + }); + + const event = new Event({ + id: 'event1' + }); + + event.related.archive = mockArchiveEvent; + + const event3 = new Event({ + id: 'event3' + }); + + event3.related.archive = mockFailArchiveEvent; + + mockGetEvent.mockImplementation(id => { + return Promise.resolve(id === 'event3' ? event3 : event); + }); + mockArchiveEvent.mockResolvedValue(event); + } + + beforeEach(async () => { + reset(); + }); + + beforeAll(async () => { + await rimraf(`temp_${process.env.JEST_WORKER_ID}/clone-event/`); + }); + + afterAll(async () => { + await rimraf(`temp_${process.env.JEST_WORKER_ID}/clone-event/`); + }); + + function generateState(directory: string, logName: string): CloneHubState { + const argv: Arguments = { + ...yargArgs, + ...config, + logFile: new FileLog(), + + dir: directory, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + revertLog: Promise.resolve(new FileLog()) + }; + + 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 id "event"', () => { + const step = new EventCloneStep(); + expect(step.getId()).toEqual(CloneHubStepId.Event); + }); + + it('should have the name "Clone Content Events"', () => { + const step = new EventCloneStep(); + expect(step.getName()).toEqual('Clone Events'); + }); + + it('should call export on the source, backup and import to the destination', async () => { + const state = generateState(`temp_${process.env.JEST_WORKER_ID}/clone-event/run/`, 'run'); + + (eventImport.handler as jest.Mock).mockResolvedValue(true); + (eventExport.handler as jest.Mock).mockResolvedValue(true); + + const step = new EventCloneStep(); + const result = await step.run(state); + // Backup + expect(eventExport.handler).toHaveBeenNthCalledWith(1, { + dir: join(state.path, 'oldEvent'), + force: true, + snapshots: false, + logFile: state.logFile, + ...state.to + }); + + // Export + expect(eventExport.handler).toHaveBeenNthCalledWith(2, { + dir: join(state.path, 'event'), + force: true, + snapshots: false, + logFile: state.logFile, + ...state.from + }); + + expect(eventImport.handler).toBeCalledWith({ + dir: join(state.path, 'event'), + originalIds: false, + 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_${process.env.JEST_WORKER_ID}/clone-event/run/`, 'run'); + + (eventExport.handler as jest.Mock).mockRejectedValue(false); + + const step = new EventCloneStep(); + const backupFail = await step.run(state); + + expect(backupFail).toBeFalsy(); + expect(eventExport.handler).toBeCalledTimes(1); + expect(eventImport.handler).not.toBeCalled(); + + reset(); + + (eventExport.handler as jest.Mock).mockResolvedValueOnce(true); + (eventExport.handler as jest.Mock).mockRejectedValueOnce(false); + + const exportFail = await step.run(state); + + expect(exportFail).toBeFalsy(); + expect(eventExport.handler).toBeCalledTimes(2); + expect(eventImport.handler).not.toBeCalled(); + + reset(); + + (eventExport.handler as jest.Mock).mockResolvedValue(true); + (eventImport.handler as jest.Mock).mockRejectedValue(false); + + const importFail = await step.run(state); + + expect(importFail).toBeFalsy(); + expect(eventExport.handler).toBeCalledTimes(2); + expect(eventImport.handler).toBeCalled(); + }); + + it('should attempt to archive events with the CREATE action on revert, skipping archived events', async () => { + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Events'); + fakeLog.addAction('EVENT-CREATE', 'event'); + fakeLog.addAction('EVENT-CREATE', 'event3'); // is archived + + const state = generateState(`temp_${process.env.JEST_WORKER_ID}/clone-event/revert-create/`, 'revert-create'); + + await ensureDirectoryExists(`temp_${process.env.JEST_WORKER_ID}/clone-event/revert-create/oldEvent`); + + state.revertLog = fakeLog; + + const step = new EventCloneStep(); + await step.revert(state); + + expect(mockArchiveEvent).toHaveBeenCalledTimes(1); + expect(mockFailArchiveEvent).toHaveBeenCalledTimes(1); + expect(state.logFile.getData('ARCHIVE').length).toEqual(1); + expect(eventImport.handler).not.toBeCalled(); + }); + + it('should pass events with the UPDATE action to the event import command on revert, in the oldEvent folder', async () => { + const state = generateState(`temp_${process.env.JEST_WORKER_ID}/clone-event/revert-update/`, 'revert-update'); + + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Events'); + fakeLog.addAction('EVENT-CREATE', 'event'); + fakeLog.addAction('EVENT-UPDATE', 'event2 0 1'); + + await ensureDirectoryExists(`temp_${process.env.JEST_WORKER_ID}/clone-event/revert-update/oldEvent`); + + state.revertLog = fakeLog; + + const step = new EventCloneStep(); + const result = await step.revert(state); + + expect(mockArchiveEvent).toHaveBeenCalledTimes(1); + expect(eventImport.handler).toBeCalledWith({ + dir: join(state.path, 'oldEvent'), + originalIds: true, + logFile: state.logFile, + ...state.to + }); + + expect(result).toBeTruthy(); + }); + + it('should return false when importing events for revert fails', async () => { + const state = generateState(`temp_${process.env.JEST_WORKER_ID}/clone-event/revert-update/`, 'revert-update'); + + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Events'); + fakeLog.addAction('EVENT-CREATE', 'event'); + fakeLog.addAction('EVENT-UPDATE', 'event2 0 1'); + + await ensureDirectoryExists(`temp_${process.env.JEST_WORKER_ID}/clone-event/revert-update/oldEvent`); + + state.revertLog = fakeLog; + (eventImport.handler as jest.Mock).mockRejectedValue(false); + + const step = new EventCloneStep(); + const result = await step.revert(state); + + expect(mockArchiveEvent).toHaveBeenCalledTimes(1); + expect(result).toBeFalsy(); + }); +}); diff --git a/src/commands/hub/steps/event-clone-step.ts b/src/commands/hub/steps/event-clone-step.ts new file mode 100644 index 00000000..a0df30e8 --- /dev/null +++ b/src/commands/hub/steps/event-clone-step.ts @@ -0,0 +1,102 @@ +import { CloneHubStep, CloneHubStepId } from '../model/clone-hub-step'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; + +import { handler as exportEvent } from '../../event/export'; +import { handler as importEvent } from '../../event/import'; +import { FileLog } from '../../../common/file-log'; +import { existsSync } from 'fs'; +import dynamicContentClientFactory from '../../../services/dynamic-content-client-factory'; + +export class EventCloneStep implements CloneHubStep { + getId(): CloneHubStepId { + return CloneHubStepId.Event; + } + + getName(): string { + return 'Clone Events'; + } + + async run(state: CloneHubState): Promise { + try { + state.logFile.appendLine(`Exporting existing events from destination.`); + await exportEvent({ + dir: join(state.path, 'oldEvent'), + force: true, + snapshots: false, + logFile: state.logFile, + ...state.to + }); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not export existing events. \n${e}`); + return false; + } + + try { + state.logFile.appendLine(`Exporting events from source.`); + await exportEvent({ + dir: join(state.path, 'event'), + force: true, + snapshots: false, + logFile: state.logFile, + ...state.from + }); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not export events. \n${e}`); + return false; + } + + try { + await importEvent({ + dir: join(state.path, 'event'), + logFile: state.logFile, + originalIds: false, + ...state.to + }); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not import events. \n${e}`); + return false; + } + + return true; + } + + async revert(state: CloneHubState): Promise { + const client = dynamicContentClientFactory(state.to); + + const toArchive = (state.revertLog as FileLog).getData('EVENT-CREATE', this.getName()); + const toUpdate = (state.revertLog as FileLog).getData('EVENT-UPDATE', this.getName()); + + for (let i = 0; i < toArchive.length; i++) { + try { + const event = await client.events.get(toArchive[i]); + await event.related.archive(); + state.logFile.addAction('ARCHIVE', toArchive[i]); + } catch (e) { + state.logFile.appendLine(`Couldn't archive event ${toArchive[i]}. Continuing...`); + } + } + + // Update using the oldEvent folder. + if (toUpdate.length > 0 && existsSync(join(state.path, 'oldEvent'))) { + try { + await importEvent( + { + dir: join(state.path, 'oldEvent'), + logFile: state.logFile, + originalIds: true, + ...state.to + } + /*, + toUpdate.map(item => item.split(' ')[0]) + */ + ); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not import old events. \n${e}`); + return false; + } + } + + return true; + } +} diff --git a/src/common/content-item/content-dependancy-tree.ts b/src/common/content-item/content-dependancy-tree.ts index 78150eb0..433788db 100644 --- a/src/common/content-item/content-dependancy-tree.ts +++ b/src/common/content-item/content-dependancy-tree.ts @@ -2,7 +2,7 @@ import { ContentItem, ContentRepository } from 'dc-management-sdk-js'; import { ContentMapping } from '../content-mapping'; import { Body } from './body'; -type DependancyContentTypeSchema = +export type DependancyContentTypeSchema = | 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-link' | 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-reference' | '_hierarchy'; // Used internally for parent dependancies. diff --git a/src/common/content-mapping.ts b/src/common/content-mapping.ts index bd5c1058..50e09711 100644 --- a/src/common/content-mapping.ts +++ b/src/common/content-mapping.ts @@ -5,18 +5,22 @@ import { promisify } from 'util'; export class ContentMapping { contentItems: Map; workflowStates: Map; + events: Map; + editions: Map; + slots: Map; + snapshots: Map; constructor() { this.contentItems = new Map(); this.workflowStates = new Map(); + this.events = new Map(); + this.editions = new Map(); + this.slots = new Map(); + this.snapshots = new Map(); } getContentItem(id: string | undefined): string | undefined { - if (id === undefined) { - return undefined; - } - - return this.contentItems.get(id); + return id === undefined ? undefined : this.contentItems.get(id); } registerContentItem(fromId: string, toId: string): void { @@ -35,10 +39,46 @@ export class ContentMapping { this.workflowStates.set(fromId, toId); } + getEvent(id: string | undefined): string | undefined { + return id === undefined ? undefined : this.events.get(id); + } + + registerEvent(fromId: string, toId: string): void { + this.events.set(fromId, toId); + } + + getEdition(id: string | undefined): string | undefined { + return id === undefined ? undefined : this.editions.get(id); + } + + registerEdition(fromId: string, toId: string): void { + this.editions.set(fromId, toId); + } + + getSlot(id: string | undefined): string | undefined { + return id === undefined ? undefined : this.slots.get(id); + } + + registerSlot(fromId: string, toId: string): void { + this.slots.set(fromId, toId); + } + + getSnapshot(id: string | undefined): string | undefined { + return id === undefined ? undefined : this.snapshots.get(id); + } + + registerSnapshot(fromId: string, toId: string): void { + this.snapshots.set(fromId, toId); + } + async save(filename: string): Promise { const obj: SerializedContentMapping = { contentItems: Array.from(this.contentItems), - workflowStates: Array.from(this.workflowStates) + workflowStates: Array.from(this.workflowStates), + events: Array.from(this.events), + editions: Array.from(this.editions), + slots: Array.from(this.slots), + snapshots: Array.from(this.snapshots) }; const text = JSON.stringify(obj); @@ -57,6 +97,10 @@ export class ContentMapping { this.contentItems = new Map(obj.contentItems); this.workflowStates = new Map(obj.workflowStates); + this.events = obj.events ? new Map(obj.events) : new Map(); + this.editions = obj.editions ? new Map(obj.editions) : new Map(); + this.slots = obj.slots ? new Map(obj.slots) : new Map(); + this.snapshots = obj.snapshots ? new Map(obj.snapshots) : new Map(); return true; } catch (e) { return false; @@ -67,4 +111,8 @@ export class ContentMapping { interface SerializedContentMapping { contentItems: [string, string][]; workflowStates: [string, string][]; + events?: [string, string][]; + editions?: [string, string][]; + slots?: [string, string][]; + snapshots?: [string, string][]; } diff --git a/src/interfaces/export-event-builder-options.interface.ts b/src/interfaces/export-event-builder-options.interface.ts new file mode 100644 index 00000000..ea9af3d3 --- /dev/null +++ b/src/interfaces/export-event-builder-options.interface.ts @@ -0,0 +1,10 @@ +import { FileLog } from '../common/file-log'; + +export interface ExportEventBuilderOptions { + dir: string; + id?: string; + fromDate?: string; + toDate?: string; + logFile: FileLog; + snapshots: boolean; +} diff --git a/src/interfaces/import-event-builder-options.interface.ts b/src/interfaces/import-event-builder-options.interface.ts new file mode 100644 index 00000000..c0e58e22 --- /dev/null +++ b/src/interfaces/import-event-builder-options.interface.ts @@ -0,0 +1,8 @@ +import { FileLog } from '../common/file-log'; + +export interface ImportEventBuilderOptions { + dir: string; + mapFile?: string; + originalIds: boolean; + logFile: FileLog; +} From e8aee7b150aca09a69ec88d8a50567dda9fc2c26 Mon Sep 17 00:00:00 2001 From: Rhys Date: Thu, 25 Nov 2021 10:02:05 +0000 Subject: [PATCH 04/22] fix(events): rewrite root content item id on event import, tests --- src/commands/event/import.spec.ts | 4 ++++ src/commands/event/import.ts | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/commands/event/import.spec.ts b/src/commands/event/import.spec.ts index dae39136..4502f296 100644 --- a/src/commands/event/import.spec.ts +++ b/src/commands/event/import.spec.ts @@ -286,6 +286,10 @@ describe('event import command', () => { ] `); expect(mapping.getSnapshot('snap1')).toEqual('newSnap'); + expect(content.body.chooser[0].id).toEqual('newSnap'); + expect(content.body.chooser[0]._meta.rootContentItemId).toEqual('realItem1'); + expect(content.body.chooser[1].id).toEqual('existingSnap'); + expect(content.body.chooser[1]._meta.rootContentItemId).toEqual('realItem2'); }); it('should not create snapshots when content has no references', async function() { diff --git a/src/commands/event/import.ts b/src/commands/event/import.ts index d7071807..817e7621 100644 --- a/src/commands/event/import.ts +++ b/src/commands/event/import.ts @@ -105,12 +105,13 @@ export const rewriteSnapshots = async ( // Try find the snapshot in the mapping let snapshotId = mapping.getSnapshot(entry.id); + const itemId = mapping.getContentItem(entry._meta.rootContentItemId) || entry._meta.rootContentItemId; if (snapshotId == null) { // Create a new snapshot based off of the current content state const result = await hub.related.snapshots.create([ new Snapshot({ - contentRoot: mapping.getContentItem(entry._meta.rootContentItemId) || entry._meta.rootContentItemId, + contentRoot: itemId, comment: '', createdFrom: SnapshotCreator.ContentItem, type: SnapshotType.GENERATED @@ -127,6 +128,7 @@ export const rewriteSnapshots = async ( } dep.dependancy.id = snapshotId; + entry._meta.rootContentItemId = itemId; } }; From 8554645cdcfd7b3b265bd03631e533ab437f705c Mon Sep 17 00:00:00 2001 From: Rhys Date: Thu, 25 Nov 2021 16:09:46 +0000 Subject: [PATCH 05/22] fix(events): export id with events --- src/commands/event/__snapshots__/export.spec.ts.snap | 4 ++++ src/commands/event/export.ts | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/event/__snapshots__/export.spec.ts.snap b/src/commands/event/__snapshots__/export.spec.ts.snap index c106c775..00814a39 100644 --- a/src/commands/event/__snapshots__/export.spec.ts.snap +++ b/src/commands/event/__snapshots__/export.spec.ts.snap @@ -161,6 +161,7 @@ exports[`event export command handler tests should export snapshots when --snaps Array [ "{ \\"name\\": \\"test1\\", + \\"id\\": \\"1\\", \\"start\\": \\"2021-05-05T12:00:00.000Z\\", \\"end\\": \\"2021-05-06T12:00:00.000Z\\", \\"editions\\": [ @@ -270,6 +271,7 @@ exports[`event export command handler tests should list and export a single edit Array [ "{ \\"name\\": \\"test1\\", + \\"id\\": \\"1\\", \\"start\\": \\"2021-05-05T12:00:00.000Z\\", \\"end\\": \\"2021-05-06T12:00:00.000Z\\", \\"editions\\": [ @@ -330,6 +332,7 @@ SUCCESS" exports[`event export command handler tests should list and export all editions 1`] = ` Array [ "{ + \\"id\\": \\"test1\\", \\"name\\": \\"test1\\", \\"start\\": \\"2021-05-05T12:00:00.000Z\\", \\"end\\": \\"2021-05-06T12:00:00.000Z\\", @@ -378,6 +381,7 @@ Array [ ] }", "{ + \\"id\\": \\"test2\\", \\"name\\": \\"test2\\", \\"start\\": \\"2021-05-07T12:00:00.000Z\\", \\"end\\": \\"2021-05-08T12:00:00.000Z\\", diff --git a/src/commands/event/export.ts b/src/commands/event/export.ts index f08c5a35..b3bc2a1a 100644 --- a/src/commands/event/export.ts +++ b/src/commands/event/export.ts @@ -303,7 +303,6 @@ export const processEvents = async ( tableStream.write([chalk.bold('File'), chalk.bold('Schema ID'), chalk.bold('Result')]); for (const { filename, status, event } of allExports) { if (status !== 'UP-TO-DATE') { - delete event.id; // do not export id writeJsonToFile(filename, event); } tableStream.write([filename, event.name || '', status]); From 0225b668ecfd3b794b7713912b540f28ceb683b5 Mon Sep 17 00:00:00 2001 From: Rhys Date: Wed, 12 Jan 2022 12:37:01 +0000 Subject: [PATCH 06/22] feat(event): add --schedule option to event import, require --experimental flag --- package-lock.json | 2 +- src/commands/event/import.spec.ts | 52 +++- src/commands/event/import.ts | 224 +++++++++++++++++- .../hub/steps/event-clone-step.spec.ts | 4 + src/commands/hub/steps/event-clone-step.ts | 4 + .../import-event-builder-options.interface.ts | 2 + 6 files changed, 275 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index aafdbe3d..72c70d89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3378,7 +3378,7 @@ "dev": true }, "dc-management-sdk-js": { - "version": "git+ssh://git@github.com/rs-amp/dc-management-sdk-js.git#d9562b67421dcf14a4f6c0c3b0133c9994c02f5f", + "version": "git+ssh://git@github.com/rs-amp/dc-management-sdk-js.git#07b1d31cc6dab72593bade5ecbe734fa20cbe7d5", "from": "git+ssh://git@github.com/rs-amp/dc-management-sdk-js.git#feature/events", "requires": { "axios": "^0.21.1", diff --git a/src/commands/event/import.spec.ts b/src/commands/event/import.spec.ts index 4502f296..872cabe8 100644 --- a/src/commands/event/import.spec.ts +++ b/src/commands/event/import.spec.ts @@ -53,7 +53,9 @@ describe('event import command', () => { const config = { clientId: 'client-id', clientSecret: 'client-id', - hubId: 'hub-id' + hubId: 'hub-id', + schedule: false, + experimental: true }; const commonMock = async ( @@ -77,6 +79,7 @@ describe('event import command', () => { ...config, dir: '', originalIds: false, + experimental: true, logFile: log, ...customArgs } @@ -110,6 +113,13 @@ describe('event import command', () => { type: 'string' }); + expect(spyOption).toHaveBeenCalledWith('experimental', { + type: 'boolean', + boolean: true, + describe: + 'Must be passed to use the event import command. Only use this command if you fully understand its limitations.' + }); + expect(spyOption).toHaveBeenCalledWith('mapFile', { type: 'string', describe: @@ -128,6 +138,13 @@ describe('event import command', () => { describe: 'Use original ids' }); + expect(spyOption).toHaveBeenCalledWith('schedule', { + type: 'boolean', + boolean: true, + describe: + 'Schedule events in the destination repo if they are scheduled in the source. If any new or updated imported events are scheduled in the past, they will be moved to happen at the time of import.' + }); + expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, @@ -146,6 +163,39 @@ describe('event import command', () => { await rimraf('temp/importEvent/'); }); + it('should return immediately if experimental is false', async function() { + const { getHubMock } = mockValues({}); + + const logFile = new FileLog(); + const argv = { + ...yargArgs, + ...config, + logFile, + dir: 'temp/importEvent/', + experimental: false, + originalIds: false + }; + const event = new EventWithEditions({ id: 'id-1' }); + + (loadJsonFromDirectory as jest.Mock).mockResolvedValue({ + 'event1.json': event + }); + + const importEvents = jest.spyOn(importModule, 'importEvents').mockResolvedValue(); + const trySaveMapping = jest.spyOn(importModule, 'trySaveMapping').mockResolvedValue(); + const getDefaultMappingPath = jest.spyOn(importModule, 'getDefaultMappingPath').mockReturnValue('mapping.json'); + + await handler(argv); + + expect(getHubMock).not.toHaveBeenCalled(); + expect(loadJsonFromDirectory as jest.Mock).not.toHaveBeenCalled(); + + expect(importEvents).not.toHaveBeenCalled(); + expect(getDefaultMappingPath).not.toHaveBeenCalled(); + expect(trySaveMapping).not.toHaveBeenCalled(); + expect(logFile.closed).toBeFalsy(); + }); + it('should call importEvents with the loaded events, then save the mapping', async function() { const { getHubMock } = mockValues({}); diff --git a/src/commands/event/import.ts b/src/commands/event/import.ts index 817e7621..2dff658c 100644 --- a/src/commands/event/import.ts +++ b/src/commands/event/import.ts @@ -8,11 +8,12 @@ import { Hub, ContentRepository, Snapshot, - SnapshotType + SnapshotType, + PublishingStatus } from 'dc-management-sdk-js'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; -import { loadJsonFromDirectory } from '../../services/import.service'; +import { loadFileFromDirectory, loadJsonFromDirectory } from '../../services/import.service'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { ImportEventBuilderOptions } from '../../interfaces/import-event-builder-options.interface'; import { EditionWithSlots, EventWithEditions } from './export'; @@ -25,6 +26,12 @@ import { DependancyContentTypeSchema } from '../../common/content-item/content-dependancy-tree'; import { SnapshotCreator } from 'dc-management-sdk-js/build/main/lib/model/SnapshotCreator'; +import { isEqual } from 'lodash'; + +const InstantSecondsAllowance = 5; +const EditionSecondsAllowance = 5; +const EventSecondsAllowance = 60; +const ScheduleSecondsAllowance = 5; export const command = 'import '; @@ -49,6 +56,13 @@ export const builder = (yargs: Argv): void => { type: 'string' }) + .option('experimental', { + type: 'boolean', + boolean: true, + describe: + 'Must be passed to use the event import command. Only use this command if you fully understand its limitations.' + }) + .option('mapFile', { type: 'string', describe: @@ -62,6 +76,13 @@ export const builder = (yargs: Argv): void => { describe: 'Overwrite existing events, editions, slots and snapshots without asking.' }) + .option('schedule', { + type: 'boolean', + boolean: true, + describe: + 'Schedule events in the destination repo if they are scheduled in the source. If any new or updated imported events are scheduled in the past, they will be moved to happen at the time of import.' + }) + .option('originalIds', { type: 'boolean', boolean: true, @@ -87,6 +108,76 @@ interface SlotDependency extends ContentDependancy { _meta: SlotDependencyMeta; } +export const dateOffset = (seconds: number): Date => { + const date = new Date(); + date.setSeconds(date.getSeconds() + seconds); + + return date; +}; + +export const dateMax = (date1: Date, date2: Date): Date => { + return date1 > date2 ? date1 : date2; +}; + +export const dateMin = (date1: Date, date2: Date): Date => { + return date1 <= date2 ? date1 : date2; +}; + +interface TimeRange { + start?: string; + end?: string; +} + +export const boundTimeRange = (realRange: TimeRange, range: TimeRange): void => { + // Only update the resource start time if it is in the future, and less than existing. + // Only update the resource end time if it is greater than existing. + const eventStart = new Date(range.start as string); + const realEventStart = new Date(range.start as string); + const nowOffset = dateOffset(InstantSecondsAllowance); + + if (new Date(range.end as string) < new Date(realRange.end as string)) { + range.end = realRange.end; + } + + if (eventStart > realEventStart || realEventStart < nowOffset) { + range.start = realRange.start; + } +}; + +export const shouldUpdateSlot = (realSlot: EditionSlot, slot: EditionSlot): boolean => { + return !isEqual(slot.content, realSlot.content); +}; + +export const shouldUpdateEvent = (realEvent: Event, event: Event): boolean => { + boundTimeRange(realEvent, event); + + return ( + event.name !== realEvent.name || + event.brief !== realEvent.brief || + event.comment !== realEvent.comment || + event.start !== realEvent.start || + event.end !== realEvent.end + ); +}; + +export const shouldUpdateEdition = ( + realEdition: Edition, + realSlots: EditionSlot[], + edition: EditionWithSlots +): boolean => { + boundTimeRange(realEdition, edition); + + return ( + edition.name !== realEdition.name || + edition.start !== realEdition.start || + edition.end !== realEdition.end || + edition.comment !== realEdition.comment || + edition.activeEndDate !== realEdition.activeEndDate || + edition.slots.length != realSlots.length || + edition.slots.map((x, i) => shouldUpdateSlot(x, realSlots[i])).reduce((a, b) => a && b, true) + ); +}; + export const rewriteSnapshots = async ( // eslint-disable-next-line @typescript-eslint/no-explicit-any content: any, @@ -184,6 +275,56 @@ export const importSlots = async ( } }; +export const isScheduled = (edition: Edition) => + edition.publishingStatus === PublishingStatus.PUBLISHED || + edition.publishingStatus === PublishingStatus.PUBLISHING || + edition.publishingStatus === PublishingStatus.SCHEDULING || + edition.publishingStatus === PublishingStatus.SCHEDULED; + +export const moveDateToFuture = async (date: string, event: Event, offset: number): Promise => { + const newDate = dateMax(new Date(date as string), dateOffset(offset)); + + if (newDate > new Date(event.end as string)) { + event.end = dateOffset(EventSecondsAllowance).toISOString(); + + await event.related.update(event); + } + + return newDate.toISOString(); +}; + +export const prepareEditionForSchedule = async (edition: Edition, event: Event): Promise => { + if (isScheduled(edition)) { + // This edition must start in the future for it to be scheduled. + edition.start = await moveDateToFuture(edition.start as string, event, EditionSecondsAllowance); + edition.end = await moveDateToFuture(edition.end as string, event, ScheduleSecondsAllowance); + } +}; + +export const scheduleEdition = async (edition: Edition, log: FileLog) => { + const warning = await edition.related.schedule(false); + + if (warning.errors) { + for (const error of warning.errors) { + if (error.level === 'WARNING') { + let message = `${error.code}: ${error.message}`; + + if (error.overlaps) { + message += ` (${error.overlaps + .map(overlap => `${overlap.name} - ${overlap.editionId} ${overlap.start}`) + .join(', ')})`; + } + + log.warn(message); + } else { + log.error(`${error.code}: ${error.message}`); + } + } + + await edition.related.schedule(true); + } +}; + export const importEditions = async ( editions: EditionWithSlots[], mapping: ContentMapping, @@ -214,11 +355,17 @@ export const importEditions = async ( start: edition.start, end: edition.end, comment: edition.comment, - activeEndDate: edition.activeEndDate + activeEndDate: edition.activeEndDate, + publishingStatus: edition.publishingStatus }); + let update = true; + let schedule = argv.schedule; + if (realEdition == null) { // Create a new edition based off of the file. + await prepareEditionForSchedule(filteredEdition, event); + realEdition = await event.related.editions.create(filteredEdition); log.addComment(`Created edition ${realEdition.name}.`); @@ -226,15 +373,63 @@ export const importEditions = async ( mapping.registerEdition(edition.id as string, realEdition.id as string); } else { - // Update the existing edition based off of the file. - realEdition = await realEdition.related.update(filteredEdition); + const slots = await paginator(realEdition.related.slots.list); + + if ( + shouldUpdateEdition(realEdition, slots, edition) || + (schedule && !isScheduled(realEdition) && isScheduled(edition)) + ) { + // If the edition has already published, it cannot be modified. + // Copy back start/end in case they were modified above. + filteredEdition.start = edition.start; + filteredEdition.end = edition.end; + + if ( + realEdition.publishingStatus == PublishingStatus.SCHEDULED || + realEdition.publishingStatus == PublishingStatus.SCHEDULING + ) { + // If the edition is scheduled, it must first be unscheduled. + try { + await realEdition.related.unschedule(); + realEdition.publishingStatus = PublishingStatus.UNSCHEDULING; + schedule = true; // Must reschedule after update. + + // Must fetch the edition again to get the update action + while (realEdition.publishingStatus === PublishingStatus.UNSCHEDULING) { + realEdition = await client.editions.get(realEdition.id as string); + } + } catch { + update = false; // Can't update, as we weren't able to unschedule. + } + } else if (isScheduled(realEdition)) { + update = false; // Can't update, as the edition was already published. + } + + if (update) { + await prepareEditionForSchedule(filteredEdition, event); + + // Update the existing edition based off of the file. + realEdition = await realEdition.related.update(filteredEdition); + + log.addComment(`Updated edition ${realEdition.name}.`); + log.addAction('EDITION-UPDATE', realEdition.id as string); + } else { + log.appendLine(`Skipped updating ${realEdition.name}, as it has already published.`); + } + } else { + update = false; + } + } - log.addComment(`Updated edition ${realEdition.name}.`); - log.addAction('EDITION-UPDATE', realEdition.id as string); + if (update) { + // Attempt to create/update slots. + await importSlots(edition.slots, mapping, hub, realEdition, argv, log); } - // Attempt to create slots - await importSlots(edition.slots, mapping, hub, realEdition, argv, log); + // If the original edition was scheduled, attempt to schedule the new one. + if (schedule && !isScheduled(realEdition) && isScheduled(edition)) { + await scheduleEdition(realEdition, log); + } } }; @@ -278,7 +473,7 @@ export const importEvents = async ( log.addAction('EVENT-CREATE', realEvent.id as string); mapping.registerEvent(event.id as string, realEvent.id as string); - } else { + } else if (shouldUpdateEvent(realEvent, event)) { // Update the existing event based off of the file. realEvent = await realEvent.related.update(filteredEvent); @@ -306,7 +501,14 @@ export const trySaveMapping = async ( }; export const handler = async (argv: Arguments): Promise => { - const { dir, logFile } = argv; + const { dir, logFile, experimental } = argv; + + if (!experimental) { + console.log( + 'Event import is an experimental feature, only use it if you fully understand its limitations. To use this command, pass the --experimental flag.' + ); + return; + } const client = dynamicContentClientFactory(argv); const log = logFile.open(); diff --git a/src/commands/hub/steps/event-clone-step.spec.ts b/src/commands/hub/steps/event-clone-step.spec.ts index 33eac5f3..fc287048 100644 --- a/src/commands/hub/steps/event-clone-step.spec.ts +++ b/src/commands/hub/steps/event-clone-step.spec.ts @@ -156,6 +156,8 @@ describe('event clone step', () => { expect(eventImport.handler).toBeCalledWith({ dir: join(state.path, 'event'), originalIds: false, + schedule: true, + experimental: true, logFile: state.logFile, ...state.to }); @@ -238,6 +240,8 @@ describe('event clone step', () => { expect(eventImport.handler).toBeCalledWith({ dir: join(state.path, 'oldEvent'), originalIds: true, + schedule: true, + experimental: true, logFile: state.logFile, ...state.to }); diff --git a/src/commands/hub/steps/event-clone-step.ts b/src/commands/hub/steps/event-clone-step.ts index a0df30e8..24173532 100644 --- a/src/commands/hub/steps/event-clone-step.ts +++ b/src/commands/hub/steps/event-clone-step.ts @@ -51,6 +51,8 @@ export class EventCloneStep implements CloneHubStep { dir: join(state.path, 'event'), logFile: state.logFile, originalIds: false, + schedule: true, + experimental: true, ...state.to }); } catch (e) { @@ -85,6 +87,8 @@ export class EventCloneStep implements CloneHubStep { dir: join(state.path, 'oldEvent'), logFile: state.logFile, originalIds: true, + schedule: true, + experimental: true, ...state.to } /*, diff --git a/src/interfaces/import-event-builder-options.interface.ts b/src/interfaces/import-event-builder-options.interface.ts index c0e58e22..dfa6b46a 100644 --- a/src/interfaces/import-event-builder-options.interface.ts +++ b/src/interfaces/import-event-builder-options.interface.ts @@ -4,5 +4,7 @@ export interface ImportEventBuilderOptions { dir: string; mapFile?: string; originalIds: boolean; + schedule: boolean; + experimental: boolean; logFile: FileLog; } From ed696fadff6aae390bfce0b657c9392642c8d14b Mon Sep 17 00:00:00 2001 From: Rhys Date: Mon, 17 Jan 2022 20:42:28 +0000 Subject: [PATCH 07/22] feat(event): add import schedule, catchup, experimental flags, testing for scheduling --- src/commands/event/event-test-helpers.ts | 8 +- src/commands/event/import.spec.ts | 646 +++++++++++++++++- src/commands/event/import.ts | 54 +- .../hub/steps/event-clone-step.spec.ts | 2 + src/commands/hub/steps/event-clone-step.ts | 2 + src/common/import/date-helpers.spec.ts | 64 ++ src/common/import/date-helpers.ts | 23 + .../import-event-builder-options.interface.ts | 1 + 8 files changed, 767 insertions(+), 33 deletions(-) create mode 100644 src/common/import/date-helpers.spec.ts create mode 100644 src/common/import/date-helpers.ts diff --git a/src/commands/event/event-test-helpers.ts b/src/commands/event/event-test-helpers.ts index bb27d0ef..be2fb229 100644 --- a/src/commands/event/event-test-helpers.ts +++ b/src/commands/event/event-test-helpers.ts @@ -25,6 +25,8 @@ export const mockValues = ({ mockSnapshotGet: () => void; mockSnapshotItem: () => void; mockSnapshotCreate: () => void; + mockEditionUnschedule: () => void; + mockEdition: Edition; } => { const mockGet = jest.fn(); const getHubMock = jest.fn(); @@ -39,6 +41,7 @@ export const mockValues = ({ const mockSnapshotGet = jest.fn(); const mockSnapshotItem = jest.fn(); const mockSnapshotCreate = jest.fn(); + const mockEditionUnschedule = jest.fn(); (dynamicContentClientFactory as jest.Mock).mockReturnValue({ hubs: { @@ -322,6 +325,7 @@ export const mockValues = ({ ); editions[0].related.update = mockEditionUpdate; + editions[0].related.unschedule = mockEditionUnschedule; slots[0].related.content = mockSlotContent; mockEditionGet.mockResolvedValue(editions[0]); @@ -351,6 +355,7 @@ export const mockValues = ({ mockEditionsList, mockEditionGet, mockEditionUpdate, + mockEditionUnschedule, mockEventsList, mockEventUpdate, mockEventCreate, @@ -358,6 +363,7 @@ export const mockValues = ({ mockSlotContent, mockSnapshotGet, mockSnapshotItem, - mockSnapshotCreate + mockSnapshotCreate, + mockEdition: editions[0] }; }; diff --git a/src/commands/event/import.spec.ts b/src/commands/event/import.spec.ts index 872cabe8..03ae5d59 100644 --- a/src/commands/event/import.spec.ts +++ b/src/commands/event/import.spec.ts @@ -11,7 +11,7 @@ import { } from './import'; import * as importModule from './import'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; -import { Event, Edition, Hub, EditionSlot, Snapshot, DynamicContent } from 'dc-management-sdk-js'; +import { Event, Edition, Hub, EditionSlot, Snapshot, DynamicContent, PublishingStatus } from 'dc-management-sdk-js'; import Yargs from 'yargs/yargs'; import MockPage from '../../common/dc-management-sdk-js/mock-page'; @@ -23,6 +23,7 @@ import { EditionWithSlots, EventWithEditions } from './export'; import { ImportEventBuilderOptions } from '../../interfaces/import-event-builder-options.interface'; import { loadJsonFromDirectory } from '../../services/import.service'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { dateOffset } from '../../common/import/date-helpers'; jest.mock('../../services/dynamic-content-client-factory'); jest.mock('../../services/import.service'); @@ -55,7 +56,8 @@ describe('event import command', () => { clientSecret: 'client-id', hubId: 'hub-id', schedule: false, - experimental: true + experimental: true, + catchup: true }; const commonMock = async ( @@ -142,7 +144,13 @@ describe('event import command', () => { type: 'boolean', boolean: true, describe: - 'Schedule events in the destination repo if they are scheduled in the source. If any new or updated imported events are scheduled in the past, they will be moved to happen at the time of import.' + 'Schedule events in the destination repo if they are scheduled in the source. If any new or updated scheduled events started in the past, they will be moved to happen at the time of import. If they ended in the past, they will be skipped by default.' + }); + + expect(spyOption).toHaveBeenCalledWith('catchup', { + type: 'boolean', + boolean: true, + describe: 'Scheduling events that ended in the past will move to the current date, so that their publishes run.' }); expect(spyOption).toHaveBeenCalledWith('logFile', { @@ -173,6 +181,7 @@ describe('event import command', () => { logFile, dir: 'temp/importEvent/', experimental: false, + catchup: false, originalIds: false }; const event = new EventWithEditions({ id: 'id-1' }); @@ -279,6 +288,388 @@ describe('event import command', () => { }); }); + describe('shouldUpdateSlot tests', function() { + it('should return false if content matches, true otherwise', async function() { + const slot1 = new EditionSlot({ content: { example: 'test', example2: { deep: 'is here' } } }); + const slot1dupe = new EditionSlot({ content: { example: 'test', example2: { deep: 'is here' } } }); + const slot2 = new EditionSlot({ content: { example: 'test', example2: { deep: 'mismatch' } } }); + const slot3 = new EditionSlot({ content: { example2: 'diff' } }); + + expect(importModule.shouldUpdateSlot(slot1, slot1dupe)).toBeFalsy(); + expect(importModule.shouldUpdateSlot(slot1, slot2)).toBeTruthy(); + expect(importModule.shouldUpdateSlot(slot2, slot3)).toBeTruthy(); + expect(importModule.shouldUpdateSlot(slot1, slot3)).toBeTruthy(); + }); + }); + + describe('shouldUpdateEvent tests', function() { + it('should call boundTimeRange, return false if fields match, true otherwise', async function() { + const event1 = new Event({ name: 'name', brief: '//brief', comment: 'comment', start: '1', end: '2' }); + const event1dupe = new Event({ name: 'name', brief: '//brief', comment: 'comment', start: '1', end: '2' }); + const event2 = new Event({ name: 'name2', brief: '//brief', comment: 'comment', start: '1', end: '2' }); + const event3 = new Event({ name: 'name', brief: '//brief2', comment: 'comment', start: '1', end: '2' }); + const event4 = new Event({ name: 'name', brief: '//brief', comment: 'comment2', start: '1', end: '2' }); + const event5 = new Event({ name: 'name', brief: '//brief', comment: 'comment', start: '1.5', end: '2' }); + const event6 = new Event({ name: 'name', brief: '//brief', comment: 'comment', start: '1', end: '2.5' }); + + jest.spyOn(importModule, 'boundTimeRange').mockReturnValue(); + + expect(importModule.shouldUpdateEvent(event1, event1dupe)).toBeFalsy(); + + expect(importModule.boundTimeRange).toHaveBeenCalledWith(event1, event1dupe); + + expect(importModule.shouldUpdateEvent(event1, event2)).toBeTruthy(); + expect(importModule.shouldUpdateEvent(event1, event3)).toBeTruthy(); + expect(importModule.shouldUpdateEvent(event1, event4)).toBeTruthy(); + expect(importModule.shouldUpdateEvent(event1, event5)).toBeTruthy(); + expect(importModule.shouldUpdateEvent(event1, event6)).toBeTruthy(); + expect(importModule.shouldUpdateEvent(event3, event4)).toBeTruthy(); + + jest.resetAllMocks(); + }); + }); + + describe('shouldUpdateEdition tests', function() { + it('should call boundTimeRange, return false if fields match, true otherwise', async function() { + const edition1 = new Edition({ name: 'name', activeEndDate: false, comment: 'comment', start: '1', end: '2' }); + const edition1dupe = new EditionWithSlots({ + name: 'name', + activeEndDate: false, + comment: 'comment', + start: '1', + end: '2', + slots: [] + }); + const edition2 = new EditionWithSlots({ + name: 'name2', + activeEndDate: false, + comment: 'comment', + start: '1', + end: '2', + slots: [] + }); + const edition3 = new EditionWithSlots({ + name: 'name', + activeEndDate: true, + comment: 'comment', + start: '1', + end: '2', + slots: [] + }); + const edition4 = new EditionWithSlots({ + name: 'name', + activeEndDate: false, + comment: 'comment2', + start: '1', + end: '2', + slots: [] + }); + const edition5 = new EditionWithSlots({ + name: 'name', + activeEndDate: false, + comment: 'comment', + start: '1.5', + end: '2', + slots: [] + }); + const edition6 = new EditionWithSlots({ + name: 'name', + activeEndDate: false, + comment: 'comment', + start: '1', + end: '2.5', + slots: [] + }); + + jest.spyOn(importModule, 'boundTimeRange').mockReturnValue(); + + expect(importModule.shouldUpdateEdition(edition1, [], edition1dupe)).toBeFalsy(); + + expect(importModule.boundTimeRange).toHaveBeenCalledWith(edition1, edition1dupe); + + expect(importModule.shouldUpdateEdition(edition1, [], edition2)).toBeTruthy(); + expect(importModule.shouldUpdateEdition(edition1, [], edition3)).toBeTruthy(); + expect(importModule.shouldUpdateEdition(edition1, [], edition4)).toBeTruthy(); + expect(importModule.shouldUpdateEdition(edition1, [], edition5)).toBeTruthy(); + expect(importModule.shouldUpdateEdition(edition1, [], edition6)).toBeTruthy(); + expect(importModule.shouldUpdateEdition(edition3, [], edition4)).toBeTruthy(); + + jest.resetAllMocks(); + }); + + it('should call boundTimeRange, return false if slots match, true otherwise', async function() { + const edition1 = new Edition({ name: 'name', activeEndDate: false, comment: 'comment', start: '1', end: '2' }); + const edition1dupe = new EditionWithSlots({ + name: 'name', + activeEndDate: false, + comment: 'comment', + start: '1', + end: '2', + slots: [new EditionSlot(), new EditionSlot()] + }); + + jest.spyOn(importModule, 'boundTimeRange').mockReturnValue(); + const spyShouldUpdateSlot = jest.spyOn(importModule, 'shouldUpdateSlot'); + + // Length different, return true immediately + expect(importModule.shouldUpdateEdition(edition1, [], edition1dupe)).toBeTruthy(); + expect(importModule.boundTimeRange).toHaveBeenCalledWith(edition1, edition1dupe); + expect(importModule.shouldUpdateEdition(edition1, [new EditionSlot()], edition1dupe)).toBeTruthy(); + expect( + importModule.shouldUpdateEdition( + edition1, + [new EditionSlot(), new EditionSlot(), new EditionSlot()], + edition1dupe + ) + ).toBeTruthy(); + + expect(importModule.shouldUpdateSlot).not.toHaveBeenCalled(); + + // Identical + spyShouldUpdateSlot.mockReturnValueOnce(false); + spyShouldUpdateSlot.mockReturnValueOnce(false); + expect( + importModule.shouldUpdateEdition(edition1, [new EditionSlot(), new EditionSlot()], edition1dupe) + ).toBeFalsy(); + + // First different + spyShouldUpdateSlot.mockReturnValueOnce(true); + spyShouldUpdateSlot.mockReturnValueOnce(false); + expect( + importModule.shouldUpdateEdition(edition1, [new EditionSlot(), new EditionSlot()], edition1dupe) + ).toBeTruthy(); + + // Second different + spyShouldUpdateSlot.mockReturnValueOnce(false); + spyShouldUpdateSlot.mockReturnValueOnce(true); + expect( + importModule.shouldUpdateEdition(edition1, [new EditionSlot(), new EditionSlot()], edition1dupe) + ).toBeTruthy(); + + // Both different + spyShouldUpdateSlot.mockReturnValueOnce(true); + spyShouldUpdateSlot.mockReturnValueOnce(true); + expect( + importModule.shouldUpdateEdition(edition1, [new EditionSlot(), new EditionSlot()], edition1dupe) + ).toBeTruthy(); + + jest.resetAllMocks(); + }); + }); + + describe('moveDateToFuture tests', function() { + it('should return the input date if it is in the future', async function() { + const future = new Date(); + const event = new Event(); + event.related.update = jest.fn(); + future.setSeconds(future.getSeconds() + 60); + + expect(await importModule.moveDateToFuture(future.toISOString(), event, 10)).toEqual(future.toISOString()); + + expect(event.related.update).not.toHaveBeenCalled(); + }); + + it('should choose a date the given offset from the current date if it is in the past', async function() { + const past = new Date(); + const futureEvent = new Date(); + const event = new Event(); + event.related.update = jest.fn(); + past.setSeconds(past.getSeconds() - 5); + futureEvent.setSeconds(futureEvent.getSeconds() + 60); + event.end = futureEvent.toISOString(); + + const result = new Date(await importModule.moveDateToFuture(past.toISOString(), event, 10)); + const expected = new Date(); + const error = 500; + expected.setSeconds(expected.getSeconds() + 10); + expect(Math.abs(result.getTime() - expected.getTime())).toBeLessThan(error); + + expect(event.related.update).not.toHaveBeenCalled(); + }); + + it("should update the given event's end date if the new date ends up being after it ends", async function() { + const past = new Date(); + const futureEvent = new Date(); + const event = new Event(); + event.related.update = jest.fn(); + past.setSeconds(past.getSeconds() - 5); + futureEvent.setSeconds(futureEvent.getSeconds() + 60); + event.end = futureEvent.toISOString(); + + const result = new Date(await importModule.moveDateToFuture(past.toISOString(), event, 120)); + const expected = new Date(); + const error = 500; + expected.setSeconds(expected.getSeconds() + 120); + expect(Math.abs(result.getTime() - expected.getTime())).toBeLessThan(error); + + expect(event.related.update).toHaveBeenCalled(); + const updateEvent = (event.related.update as jest.Mock).mock.calls[0][0]; + expect(Math.abs(new Date(updateEvent.end).getTime() - expected.getTime())).toBeLessThan(error); + }); + }); + + describe('prepareEditionForSchedule tests', function() { + it('should move the start and end dates to the future if the edition is scheduled', async function() { + const edition = new Edition({ start: '1', end: '2', publishingStatus: PublishingStatus.DRAFT }); + const event = new Event(); + + const futureSpy = jest.spyOn(importModule, 'moveDateToFuture'); + futureSpy.mockResolvedValueOnce('3'); + futureSpy.mockResolvedValueOnce('4'); + + importModule.prepareEditionForSchedule(edition, event); + expect(importModule.moveDateToFuture).not.toHaveBeenCalled(); + + edition.publishingStatus = PublishingStatus.SCHEDULED; + await importModule.prepareEditionForSchedule(edition, event); + expect(importModule.moveDateToFuture).toHaveBeenNthCalledWith( + 1, + '1', + event, + importModule.EditionSecondsAllowance + ); + expect(importModule.moveDateToFuture).toHaveBeenNthCalledWith( + 2, + '2', + event, + importModule.ScheduleSecondsAllowance + ); + expect(edition.start).toEqual('3'); + expect(edition.end).toEqual('4'); + + jest.resetAllMocks(); + }); + }); + + describe('skipScheduleIfNeeded tests', function() { + it('should remove scheduled status if the event end is in the past and catchup is false', async function() { + const date = new Date(); + date.setSeconds(date.getSeconds() - 10); + + const edition = new Edition({ end: date.toISOString(), publishingStatus: PublishingStatus.SCHEDULED }); + + importModule.skipScheduleIfNeeded(edition, false); + + expect(edition.publishingStatus).toEqual(PublishingStatus.DRAFT); + }); + + it('should not remove scheduled status if the event end is in the future', async function() { + const date = new Date(); + date.setSeconds(date.getSeconds() + 10); + + const edition = new Edition({ end: date.toISOString(), publishingStatus: PublishingStatus.SCHEDULED }); + + importModule.skipScheduleIfNeeded(edition, false); + + expect(edition.publishingStatus).toEqual(PublishingStatus.SCHEDULED); + }); + + it('should leave the edition unscheduled if it was before', async function() { + const date = new Date(); + date.setSeconds(date.getSeconds() - 10); + + const edition = new Edition({ end: date.toISOString(), publishingStatus: PublishingStatus.DRAFT }); + + importModule.skipScheduleIfNeeded(edition, false); + + expect(edition.publishingStatus).toEqual(PublishingStatus.DRAFT); + }); + + it('should not remove scheduled status if catchup is true, even if the event end is in the past', async function() { + const date = new Date(); + date.setSeconds(date.getSeconds() - 10); + + const edition = new Edition({ end: date.toISOString(), publishingStatus: PublishingStatus.SCHEDULED }); + + importModule.skipScheduleIfNeeded(edition, true); + + expect(edition.publishingStatus).toEqual(PublishingStatus.SCHEDULED); + }); + }); + + describe('scheduleEdition tests', function() { + it('should schedule without logging anything if no warnings are returned', async function() { + const edition = new Edition(); + const log = new FileLog(); + + edition.related.schedule = jest.fn().mockResolvedValue({}); + + await importModule.scheduleEdition(edition, log); + + expect(edition.related.schedule).toHaveBeenCalledTimes(1); + expect(edition.related.schedule).toHaveBeenCalledWith(false); + expect(log.accessGroup).toEqual([]); + }); + it('should log warnings/errors if they are returned, and try again with ignoreWarnings true', async function() { + const edition = new Edition(); + const log = new FileLog(); + + edition.related.schedule = jest.fn().mockResolvedValue({ + errors: [ + { + level: 'WARNING', + code: 'EDITION_SCHEDULE_OVERLAP', + message: 'Edition Schedule Overlap. Please try again later.', + overlaps: [ + { + editionId: 'edition-id', + name: 'Test schedule edition', + start: '2022-01-07T15:31:47.337Z' + } + ] + }, + { + level: 'WARNING', + code: 'EDITION_CONTAINS_SLOT_COLLISIONS', + message: 'Edition contains slots that collide with other editions.' + }, + { + level: 'ERROR', + code: 'FAKE_ERROR', + message: 'This is an error.' + } + ] + }); + + await importModule.scheduleEdition(edition, log); + + expect(edition.related.schedule).toHaveBeenCalledTimes(2); + expect(edition.related.schedule).toHaveBeenNthCalledWith(1, false); + expect(edition.related.schedule).toHaveBeenNthCalledWith(2, true); + expect(log.accessGroup).toMatchInlineSnapshot(` +Array [ + Object { + "action": "WARNING", + "comment": false, + "data": "", + }, + Object { + "comment": true, + "data": "WARNING: EDITION_SCHEDULE_OVERLAP: Edition Schedule Overlap. Please try again later. (Test schedule edition - edition-id 2022-01-07T15:31:47.337Z)", + }, + Object { + "action": "WARNING", + "comment": false, + "data": "", + }, + Object { + "comment": true, + "data": "WARNING: EDITION_CONTAINS_SLOT_COLLISIONS: Edition contains slots that collide with other editions.", + }, + Object { + "action": "ERROR", + "comment": false, + "data": "", + }, + Object { + "comment": true, + "data": "ERROR: FAKE_ERROR: This is an error.", + }, +] +`); + }); + }); + describe('rewriteSnapshots tests', function() { it('should create new snapshots if no mapping is present, and use existing when it is', async function() { const { mockSnapshotCreate } = mockValues({}); @@ -547,6 +938,255 @@ describe('event import command', () => { expect(importSlots).toHaveBeenCalledWith(slots, mapping, hub, realEdition, argv, log); }); + + it('should try schedule an edition if its scheduled status indicates that it was in the source', async function() { + const { mockEditionGet, mockEditionUpdate } = mockValues({}); + + const realEdition = new Edition({ id: 'id-2', name: 'updated', publishingStatus: PublishingStatus.DRAFT }); + (mockEditionUpdate as jest.Mock).mockResolvedValue(realEdition); + + const { client, hub, argv, log, mapping } = await commonMock(); + + argv.schedule = true; + mapping.registerEdition('id-1', 'id-2'); + + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const scheduleEdition = jest.spyOn(importModule, 'scheduleEdition').mockResolvedValue(); + const skipSchedule = jest.spyOn(importModule, 'skipScheduleIfNeeded').mockReturnValue(); + const prepareEdition = jest.spyOn(importModule, 'prepareEditionForSchedule').mockResolvedValue(); + + const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; + const importTest = [ + new EditionWithSlots({ + id: 'id-1', + name: 'Edition', + start: dateOffset(10).toISOString(), + end: dateOffset(15).toISOString(), + publishingStatus: PublishingStatus.SCHEDULED, + comment: 'comment', + slots + }) + ]; + + const realEvent = new Event({ + start: dateOffset(5).toISOString(), + end: dateOffset(20).toISOString() + }); + + await importEditions(importTest, mapping, client, hub, realEvent, argv, log); + + expect(mockEditionGet).toHaveBeenCalledWith('id-2'); + expect(mockEditionUpdate).toHaveBeenCalledTimes(1); + + expect(skipSchedule).toHaveBeenCalledWith(importTest[0], true); + expect(prepareEdition).toHaveBeenCalledWith(expect.any(Edition), realEvent); + + expect(importSlots).toHaveBeenCalledWith(slots, mapping, hub, realEdition, argv, log); + expect(scheduleEdition).toHaveBeenCalledWith(expect.any(Edition), log); + }); + + it('should try unschedule the existing edition if already scheduled, update if succeeded, reschedule', async function() { + const { mockEditionGet, mockEditionUpdate, mockEditionUnschedule, mockSlotsList, mockEdition } = mockValues({ + status: PublishingStatus.SCHEDULED + }); + + const baseEdition = { id: 'id-2', name: 'updated', publishingStatus: PublishingStatus.DRAFT }; + (mockEditionGet as jest.Mock).mockReset(); + (mockEditionGet as jest.Mock).mockResolvedValueOnce(mockEdition); + + const newEdition = new Edition(baseEdition); + newEdition.related.update = mockEdition.related.update; + (newEdition as any).client = { fetchLinkedResource: mockSlotsList }; + (mockEditionGet as jest.Mock).mockResolvedValueOnce(newEdition); + (mockEditionUpdate as jest.Mock).mockResolvedValue(newEdition); + (mockEditionUnschedule as jest.Mock).mockResolvedValue(undefined); + + const { client, hub, argv, log, mapping } = await commonMock(); + + argv.schedule = true; + mapping.registerEdition('id-1', 'id-2'); + + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const scheduleEdition = jest.spyOn(importModule, 'scheduleEdition').mockResolvedValue(); + const skipSchedule = jest.spyOn(importModule, 'skipScheduleIfNeeded').mockReturnValue(); + const prepareEdition = jest.spyOn(importModule, 'prepareEditionForSchedule').mockResolvedValue(); + + const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; + const importTest = [ + new EditionWithSlots({ + id: 'id-1', + name: 'Edition', + start: dateOffset(10).toISOString(), + end: dateOffset(15).toISOString(), + publishingStatus: PublishingStatus.SCHEDULED, + comment: 'comment', + slots + }) + ]; + + const realEvent = new Event({ + start: dateOffset(5).toISOString(), + end: dateOffset(20).toISOString() + }); + + await importEditions(importTest, mapping, client, hub, realEvent, argv, log); + + expect(mockEditionUnschedule).toHaveBeenCalled(); + expect(mockEditionGet).toHaveBeenCalledWith('id-2'); + expect(mockEditionUpdate).toHaveBeenCalledTimes(1); + + expect(skipSchedule).toHaveBeenCalledWith(importTest[0], true); + expect(prepareEdition).toHaveBeenCalledWith(expect.any(Edition), realEvent); + + expect(importSlots).toHaveBeenCalledWith(slots, mapping, hub, newEdition, argv, log); + expect(scheduleEdition).toHaveBeenCalledWith(expect.any(Edition), log); + }); + + it('should try unschedule the existing edition if already scheduled, do not update if failed', async function() { + const { mockEditionGet, mockEditionUpdate, mockEditionUnschedule, mockSlotsList, mockEdition } = mockValues({ + status: PublishingStatus.SCHEDULED + }); + + const baseEdition = { id: 'id-2', name: 'updated', publishingStatus: PublishingStatus.DRAFT }; + (mockEditionGet as jest.Mock).mockReset(); + (mockEditionGet as jest.Mock).mockResolvedValueOnce(mockEdition); + + const newEdition = new Edition(baseEdition); + newEdition.related.update = mockEdition.related.update; + (newEdition as any).client = { fetchLinkedResource: mockSlotsList }; + (mockEditionGet as jest.Mock).mockResolvedValueOnce(newEdition); + (mockEditionUnschedule as jest.Mock).mockRejectedValue(new Error('Unschedule Failed')); + + const { client, hub, argv, log, mapping } = await commonMock(); + + argv.schedule = true; + mapping.registerEdition('id-1', 'id-2'); + + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const scheduleEdition = jest.spyOn(importModule, 'scheduleEdition').mockResolvedValue(); + const skipSchedule = jest.spyOn(importModule, 'skipScheduleIfNeeded').mockReturnValue(); + const prepareEdition = jest.spyOn(importModule, 'prepareEditionForSchedule').mockResolvedValue(); + + const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; + const importTest = [ + new EditionWithSlots({ + id: 'id-1', + name: 'Edition', + start: dateOffset(10).toISOString(), + end: dateOffset(15).toISOString(), + publishingStatus: PublishingStatus.SCHEDULED, + comment: 'comment', + slots + }) + ]; + + const realEvent = new Event({ + start: dateOffset(5).toISOString(), + end: dateOffset(20).toISOString() + }); + + await importEditions(importTest, mapping, client, hub, realEvent, argv, log); + + expect(mockEditionUnschedule).toHaveBeenCalled(); + expect(mockEditionGet).toHaveBeenCalledWith('id-2'); + expect(mockEditionUpdate).not.toHaveBeenCalled(); + + expect(skipSchedule).toHaveBeenCalledWith(importTest[0], true); + expect(prepareEdition).not.toHaveBeenCalled(); + + expect(importSlots).not.toHaveBeenCalled(); + expect(scheduleEdition).not.toHaveBeenCalled(); + }); + + it('should not unschedule or update an edition published in the past', async function() { + const { mockEditionGet, mockEditionUpdate, mockEditionUnschedule, mockSlotsList, mockEdition } = mockValues({ + status: PublishingStatus.PUBLISHED + }); + + const baseEdition = { id: 'id-2', name: 'updated', publishingStatus: PublishingStatus.DRAFT }; + (mockEditionGet as jest.Mock).mockReset(); + (mockEditionGet as jest.Mock).mockResolvedValueOnce(mockEdition); + + const newEdition = new Edition(baseEdition); + newEdition.related.update = mockEdition.related.update; + (newEdition as any).client = { fetchLinkedResource: mockSlotsList }; + (mockEditionGet as jest.Mock).mockResolvedValueOnce(newEdition); + + const { client, hub, argv, log, mapping } = await commonMock(); + + argv.schedule = true; + mapping.registerEdition('id-1', 'id-2'); + + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const scheduleEdition = jest.spyOn(importModule, 'scheduleEdition').mockResolvedValue(); + const skipSchedule = jest.spyOn(importModule, 'skipScheduleIfNeeded').mockReturnValue(); + const prepareEdition = jest.spyOn(importModule, 'prepareEditionForSchedule').mockResolvedValue(); + + const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; + const importTest = [ + new EditionWithSlots({ + id: 'id-1', + name: 'Edition', + start: dateOffset(-20).toISOString(), + end: dateOffset(-15).toISOString(), + publishingStatus: PublishingStatus.PUBLISHED, + comment: 'comment', + slots + }) + ]; + + const realEvent = new Event({ + start: dateOffset(-25).toISOString(), + end: dateOffset(-10).toISOString() + }); + + await importEditions(importTest, mapping, client, hub, realEvent, argv, log); + + expect(mockEditionGet).toHaveBeenCalledWith('id-2'); + expect(mockEditionUnschedule).not.toHaveBeenCalled(); + expect(mockEditionUpdate).not.toHaveBeenCalled(); + + expect(skipSchedule).toHaveBeenCalledWith(importTest[0], true); + expect(prepareEdition).not.toHaveBeenCalled(); + + expect(importSlots).not.toHaveBeenCalled(); + expect(scheduleEdition).not.toHaveBeenCalled(); + }); + + it('should not update edition if it is identical', async function() { + const { mockEditionGet, mockEditionUpdate } = mockValues({}); + + const realEdition = new Edition({ id: 'id-2', name: 'updated' }); + (mockEditionUpdate as jest.Mock).mockResolvedValue(realEdition); + + const { client, hub, argv, log, mapping } = await commonMock(); + + mapping.registerEdition('id-1', 'id-2'); + + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const shouldUpdate = jest.spyOn(importModule, 'shouldUpdateEdition').mockReturnValue(false); + const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; + const importTest = [ + new EditionWithSlots({ + id: 'id-1', + name: 'Edition', + start: '0', + end: '1', + comment: 'comment', + slots + }) + ]; + + const realEvent = new Event(); + + await importEditions(importTest, mapping, client, hub, realEvent, argv, log); + + expect(mockEditionGet).toHaveBeenCalledWith('id-2'); + expect(shouldUpdate).toHaveBeenCalled(); + expect(mockEditionUpdate).not.toHaveBeenCalled(); + + expect(importSlots).not.toHaveBeenCalled(); + }); }); describe('importEvents tests', function() { diff --git a/src/commands/event/import.ts b/src/commands/event/import.ts index 2dff658c..34c4598f 100644 --- a/src/commands/event/import.ts +++ b/src/commands/event/import.ts @@ -27,11 +27,12 @@ import { } from '../../common/content-item/content-dependancy-tree'; import { SnapshotCreator } from 'dc-management-sdk-js/build/main/lib/model/SnapshotCreator'; import { isEqual } from 'lodash'; +import { dateMax, dateOffset, sortByEndDate, TimeRange } from '../../common/import/date-helpers'; -const InstantSecondsAllowance = 5; -const EditionSecondsAllowance = 5; -const EventSecondsAllowance = 60; -const ScheduleSecondsAllowance = 5; +export const InstantSecondsAllowance = 5; +export const EditionSecondsAllowance = 5; +export const EventSecondsAllowance = 60; +export const ScheduleSecondsAllowance = 5; export const command = 'import '; @@ -80,7 +81,13 @@ export const builder = (yargs: Argv): void => { type: 'boolean', boolean: true, describe: - 'Schedule events in the destination repo if they are scheduled in the source. If any new or updated imported events are scheduled in the past, they will be moved to happen at the time of import.' + 'Schedule events in the destination repo if they are scheduled in the source. If any new or updated scheduled events started in the past, they will be moved to happen at the time of import. If they ended in the past, they will be skipped by default.' + }) + + .option('catchup', { + type: 'boolean', + boolean: true, + describe: 'Scheduling events that ended in the past will move to the current date, so that their publishes run.' }) .option('originalIds', { @@ -108,26 +115,6 @@ interface SlotDependency extends ContentDependancy { _meta: SlotDependencyMeta; } -export const dateOffset = (seconds: number): Date => { - const date = new Date(); - date.setSeconds(date.getSeconds() + seconds); - - return date; -}; - -export const dateMax = (date1: Date, date2: Date): Date => { - return date1 > date2 ? date1 : date2; -}; - -export const dateMin = (date1: Date, date2: Date): Date => { - return date1 <= date2 ? date1 : date2; -}; - -interface TimeRange { - start?: string; - end?: string; -} - export const boundTimeRange = (realRange: TimeRange, range: TimeRange): void => { // Only update the resource start time if it is in the future, and less than existing. // Only update the resource end time if it is greater than existing. @@ -174,7 +161,7 @@ export const shouldUpdateEdition = ( edition.comment !== realEdition.comment || edition.activeEndDate !== realEdition.activeEndDate || edition.slots.length != realSlots.length || - edition.slots.map((x, i) => shouldUpdateSlot(x, realSlots[i])).reduce((a, b) => a && b, true) + edition.slots.map((x, i) => shouldUpdateSlot(x, realSlots[i])).reduce((a, b) => a || b, false) ); }; @@ -285,7 +272,7 @@ export const moveDateToFuture = async (date: string, event: Event, offset: numbe const newDate = dateMax(new Date(date as string), dateOffset(offset)); if (newDate > new Date(event.end as string)) { - event.end = dateOffset(EventSecondsAllowance).toISOString(); + event.end = dateMax(dateOffset(EventSecondsAllowance), newDate).toISOString(); await event.related.update(event); } @@ -325,6 +312,13 @@ export const scheduleEdition = async (edition: Edition, log: FileLog) => { } }; +export const skipScheduleIfNeeded = (edition: Edition, catchup: boolean): void => { + if (!catchup && isScheduled(edition) && new Date(edition.end as string) < new Date()) { + // Skip publish of events fully in the past, if catchup events are not to be created. + edition.publishingStatus = PublishingStatus.DRAFT; + } +}; + export const importEditions = async ( editions: EditionWithSlots[], mapping: ContentMapping, @@ -362,6 +356,8 @@ export const importEditions = async ( let update = true; let schedule = argv.schedule; + skipScheduleIfNeeded(edition, argv.catchup); + if (realEdition == null) { // Create a new edition based off of the file. await prepareEditionForSchedule(filteredEdition, event); @@ -482,7 +478,7 @@ export const importEvents = async ( } // Attempt to create editions - await importEditions(event.editions, mapping, client, hub, realEvent, argv, log); + await importEditions(sortByEndDate(event.editions), mapping, client, hub, realEvent, argv, log); } }; @@ -527,7 +523,7 @@ export const handler = async (argv: Arguments { originalIds: false, schedule: true, experimental: true, + catchup: false, logFile: state.logFile, ...state.to }); @@ -242,6 +243,7 @@ describe('event clone step', () => { originalIds: true, schedule: true, experimental: true, + catchup: false, logFile: state.logFile, ...state.to }); diff --git a/src/commands/hub/steps/event-clone-step.ts b/src/commands/hub/steps/event-clone-step.ts index 24173532..dfb5fb37 100644 --- a/src/commands/hub/steps/event-clone-step.ts +++ b/src/commands/hub/steps/event-clone-step.ts @@ -53,6 +53,7 @@ export class EventCloneStep implements CloneHubStep { originalIds: false, schedule: true, experimental: true, + catchup: false, ...state.to }); } catch (e) { @@ -89,6 +90,7 @@ export class EventCloneStep implements CloneHubStep { originalIds: true, schedule: true, experimental: true, + catchup: false, ...state.to } /*, diff --git a/src/common/import/date-helpers.spec.ts b/src/common/import/date-helpers.spec.ts new file mode 100644 index 00000000..e1cfe39f --- /dev/null +++ b/src/common/import/date-helpers.spec.ts @@ -0,0 +1,64 @@ +import { dateMax, dateMin, dateOffset, sortByEndDate } from './date-helpers'; + +jest.mock('fs'); + +describe('date-helpers', () => { + describe('dateOffset', () => { + it('should return the current date plus the number of seconds', () => { + const now = Date.now(); + const allowance = 500; //+-0.5s allowance for test variation. + + expect(Math.abs(dateOffset(0).getTime() - now)).toBeLessThan(allowance); + expect(Math.abs(dateOffset(90).getTime() - (now + 90 * 1000))).toBeLessThan(allowance); + expect(Math.abs(dateOffset(23473).getTime() - (now + 23473 * 1000))).toBeLessThan(allowance); + expect(Math.abs(dateOffset(-123).getTime() - (now - 123 * 1000))).toBeLessThan(allowance); + }); + }); + + describe('dateMax', () => { + it('should return the later of two dates', () => { + const earlier = new Date('2022-01-07T15:31:47.337Z'); + const later = new Date('2022-01-07T15:32:47.337Z'); + + expect(dateMax(earlier, later)).toEqual(later); + expect(dateMax(later, earlier)).toEqual(later); + + expect(dateMax(later, later)).toEqual(later); + }); + }); + + describe('dateMin', () => { + it('should return the earlier of two dates', () => { + const earlier = new Date('2022-01-07T15:31:47.337Z'); + const later = new Date('2022-01-07T15:32:47.337Z'); + + expect(dateMin(earlier, later)).toEqual(earlier); + expect(dateMin(later, earlier)).toEqual(earlier); + + expect(dateMin(earlier, earlier)).toEqual(earlier); + }); + }); + + describe('sortByEndDate', () => { + it('should return the dates ordered by end date in ascending order', () => { + const start = new Date('2022-01-07T15:30:47.337Z').toISOString(); + const earlier = { start, end: new Date('2022-01-07T15:31:47.337Z').toISOString() }; + const later = { start, end: new Date('2022-01-07T15:32:47.337Z').toISOString() }; + const latest = { start, end: new Date('2022-01-07T15:33:47.337Z').toISOString() }; + + expect(sortByEndDate([earlier])).toEqual([earlier]); + expect(sortByEndDate([later, earlier])).toEqual([earlier, later]); + expect(sortByEndDate([earlier, later])).toEqual([earlier, later]); + + expect(sortByEndDate([earlier, later, latest])).toEqual([earlier, later, latest]); + expect(sortByEndDate([later, latest, earlier])).toEqual([earlier, later, latest]); + expect(sortByEndDate([latest, later, earlier])).toEqual([earlier, later, latest]); + expect(sortByEndDate([earlier, latest, later])).toEqual([earlier, later, latest]); + expect(sortByEndDate([later, earlier, latest])).toEqual([earlier, later, latest]); + expect(sortByEndDate([latest, earlier, later])).toEqual([earlier, later, latest]); + }); + it('should return an empty array when given one', () => { + expect(sortByEndDate([])).toEqual([]); + }); + }); +}); diff --git a/src/common/import/date-helpers.ts b/src/common/import/date-helpers.ts new file mode 100644 index 00000000..e42dfd1d --- /dev/null +++ b/src/common/import/date-helpers.ts @@ -0,0 +1,23 @@ +export interface TimeRange { + start?: string; + end?: string; +} + +export const dateOffset = (seconds: number): Date => { + const date = new Date(); + date.setSeconds(date.getSeconds() + seconds); + + return date; +}; + +export const dateMax = (date1: Date, date2: Date): Date => { + return date1 > date2 ? date1 : date2; +}; + +export const dateMin = (date1: Date, date2: Date): Date => { + return date1 <= date2 ? date1 : date2; +}; + +export const sortByEndDate = (ranges: Type[]): Type[] => { + return ranges.sort((a, b) => new Date(a.end as string).getTime() - new Date(b.end as string).getTime()); +}; diff --git a/src/interfaces/import-event-builder-options.interface.ts b/src/interfaces/import-event-builder-options.interface.ts index dfa6b46a..89888c0b 100644 --- a/src/interfaces/import-event-builder-options.interface.ts +++ b/src/interfaces/import-event-builder-options.interface.ts @@ -6,5 +6,6 @@ export interface ImportEventBuilderOptions { originalIds: boolean; schedule: boolean; experimental: boolean; + catchup: boolean; logFile: FileLog; } From 448a1dfcc1124bf0badae08b331a97e3fad34ae7 Mon Sep 17 00:00:00 2001 From: Rhys Date: Thu, 17 Mar 2022 12:38:21 +0000 Subject: [PATCH 08/22] fix(event): save mapping when event import throws --- src/commands/event/import.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands/event/import.ts b/src/commands/event/import.ts index 34c4598f..6aee9a3e 100644 --- a/src/commands/event/import.ts +++ b/src/commands/event/import.ts @@ -523,7 +523,11 @@ export const handler = async (argv: Arguments Date: Thu, 17 Mar 2022 12:44:02 +0000 Subject: [PATCH 09/22] test(event): test for saving on throw --- src/commands/event/import.spec.ts | 43 ++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/commands/event/import.spec.ts b/src/commands/event/import.spec.ts index 03ae5d59..fd172e93 100644 --- a/src/commands/event/import.spec.ts +++ b/src/commands/event/import.spec.ts @@ -286,6 +286,45 @@ describe('event import command', () => { expect(trySaveMapping).toHaveBeenCalledWith(argv.mapFile, expect.any(ContentMapping), logFile); expect(logFile.closed).toBeTruthy(); }); + + it('should save the mapping even if importEvents throws', async function() { + const { getHubMock } = mockValues({}); + + const logFile = new FileLog(); + const argv = { + ...yargArgs, + ...config, + logFile, + dir: 'temp/importEvent/', + originalIds: false + }; + const event = new EventWithEditions({ id: 'id-1' }); + + (loadJsonFromDirectory as jest.Mock).mockResolvedValue({ + 'event1.json': event + }); + + const importEvents = jest.spyOn(importModule, 'importEvents').mockRejectedValue(new Error('Example')) + const trySaveMapping = jest.spyOn(importModule, 'trySaveMapping').mockResolvedValue(); + const getDefaultMappingPath = jest.spyOn(importModule, 'getDefaultMappingPath').mockReturnValue('mapping.json'); + + await handler(argv); + + expect(getHubMock).toHaveBeenCalledWith('hub-id'); //from returned hub + expect(loadJsonFromDirectory as jest.Mock).toHaveBeenCalledWith('temp/importEvent/', EventWithEditions); + + expect(importEvents).toHaveBeenCalledWith( + [event], + expect.any(ContentMapping), + expect.any(Object), + expect.any(Hub), + argv, + logFile + ); + expect(getDefaultMappingPath).toHaveBeenCalledWith('hub-1'); + expect(trySaveMapping).toHaveBeenCalledWith('mapping.json', expect.any(ContentMapping), logFile); + expect(logFile.closed).toBeTruthy(); + }); }); describe('shouldUpdateSlot tests', function() { @@ -1335,8 +1374,4 @@ Array [ expect(fakeMapping.save).not.toHaveBeenCalled(); }); }); - - /* - describe('handler tests', function() {}); - */ }); From a626827f0f73d8ba121eb5a8f8ee2538e79d3323 Mon Sep 17 00:00:00 2001 From: Rhys Date: Fri, 18 Mar 2022 16:27:37 +0000 Subject: [PATCH 10/22] fix(event): refetch edition before scheduling if it has been updated with >0 slots --- src/commands/event/import.spec.ts | 6 ++++-- src/commands/event/import.ts | 10 +++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/commands/event/import.spec.ts b/src/commands/event/import.spec.ts index fd172e93..c5ce5c72 100644 --- a/src/commands/event/import.spec.ts +++ b/src/commands/event/import.spec.ts @@ -304,7 +304,7 @@ describe('event import command', () => { 'event1.json': event }); - const importEvents = jest.spyOn(importModule, 'importEvents').mockRejectedValue(new Error('Example')) + const importEvents = jest.spyOn(importModule, 'importEvents').mockRejectedValue(new Error('Example')); const trySaveMapping = jest.spyOn(importModule, 'trySaveMapping').mockResolvedValue(); const getDefaultMappingPath = jest.spyOn(importModule, 'getDefaultMappingPath').mockReturnValue('mapping.json'); @@ -1037,6 +1037,7 @@ Array [ newEdition.related.update = mockEdition.related.update; (newEdition as any).client = { fetchLinkedResource: mockSlotsList }; (mockEditionGet as jest.Mock).mockResolvedValueOnce(newEdition); + (mockEditionGet as jest.Mock).mockResolvedValueOnce(newEdition); (mockEditionUpdate as jest.Mock).mockResolvedValue(newEdition); (mockEditionUnschedule as jest.Mock).mockResolvedValue(undefined); @@ -1071,7 +1072,8 @@ Array [ await importEditions(importTest, mapping, client, hub, realEvent, argv, log); expect(mockEditionUnschedule).toHaveBeenCalled(); - expect(mockEditionGet).toHaveBeenCalledWith('id-2'); + expect(mockEditionGet).toHaveBeenNthCalledWith(1, 'id-2'); + expect(mockEditionGet).toHaveBeenNthCalledWith(3, 'id-2'); expect(mockEditionUpdate).toHaveBeenCalledTimes(1); expect(skipSchedule).toHaveBeenCalledWith(importTest[0], true); diff --git a/src/commands/event/import.ts b/src/commands/event/import.ts index 6aee9a3e..b14f7855 100644 --- a/src/commands/event/import.ts +++ b/src/commands/event/import.ts @@ -13,7 +13,7 @@ import { } from 'dc-management-sdk-js'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; -import { loadFileFromDirectory, loadJsonFromDirectory } from '../../services/import.service'; +import { loadJsonFromDirectory } from '../../services/import.service'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { ImportEventBuilderOptions } from '../../interfaces/import-event-builder-options.interface'; import { EditionWithSlots, EventWithEditions } from './export'; @@ -262,7 +262,7 @@ export const importSlots = async ( } }; -export const isScheduled = (edition: Edition) => +export const isScheduled = (edition: Edition): boolean => edition.publishingStatus === PublishingStatus.PUBLISHED || edition.publishingStatus === PublishingStatus.PUBLISHING || edition.publishingStatus === PublishingStatus.SCHEDULING || @@ -288,7 +288,7 @@ export const prepareEditionForSchedule = async (edition: Edition, event: Event): } }; -export const scheduleEdition = async (edition: Edition, log: FileLog) => { +export const scheduleEdition = async (edition: Edition, log: FileLog): Promise => { const warning = await edition.related.schedule(false); if (warning.errors) { @@ -424,6 +424,10 @@ export const importEditions = async ( // If the original edition was scheduled, attempt to schedule the new one. if (schedule && !isScheduled(realEdition) && isScheduled(edition)) { + if (update && edition.slots.length > 0) { + // Refetch the edition to make sure it's up to date before scheduling. + realEdition = await client.editions.get(realEdition.id as string); + } await scheduleEdition(realEdition, log); } } From 50a2ba2c94a2351336bd671206f160acbdd81970 Mon Sep 17 00:00:00 2001 From: Rhys Date: Mon, 21 Mar 2022 15:56:03 +0000 Subject: [PATCH 11/22] feat(clone): add experimental flag to hub clone for event clone --- src/commands/hub/clone.spec.ts | 108 +++++++++++++++++- src/commands/hub/clone.ts | 20 +++- src/commands/hub/model/clone-hub-step.ts | 1 + .../hub/steps/content-clone-step.spec.ts | 1 + .../hub/steps/event-clone-step.spec.ts | 1 + src/commands/hub/steps/event-clone-step.ts | 2 + .../hub/steps/extension-clone-step.spec.ts | 1 + .../hub/steps/index-clone-step.spec.ts | 1 + .../hub/steps/schema-clone-step.spec.ts | 1 + .../hub/steps/settings-clone-step.spec.ts | 1 + .../hub/steps/type-clone-step.spec.ts | 1 + src/interfaces/clone-hub-builder-options.ts | 1 + 12 files changed, 133 insertions(+), 6 deletions(-) diff --git a/src/commands/hub/clone.spec.ts b/src/commands/hub/clone.spec.ts index 5d06100a..6d3b3845 100644 --- a/src/commands/hub/clone.spec.ts +++ b/src/commands/hub/clone.spec.ts @@ -32,12 +32,13 @@ function succeedOrFail(mock: any, succeed: () => boolean): jest.Mock { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -function mockStep(name: string, id: string, success: () => boolean): any { +function mockStep(name: string, id: string, success: () => boolean, isExperimental?: boolean): any { return jest.fn().mockImplementation(() => ({ run: succeedOrFail(jest.fn(), success), revert: succeedOrFail(jest.fn(), success), getName: jest.fn().mockReturnValue(name), - getId: jest.fn().mockReturnValue(id) + getId: jest.fn().mockReturnValue(id), + isExperimental })); } @@ -66,7 +67,7 @@ jest.mock('./steps/content-clone-step', () => ({ })); jest.mock('./steps/event-clone-step', () => ({ - EventCloneStep: mockStep('Clone Event', 'event', () => success[5]) + EventCloneStep: mockStep('Clone Event', 'event', () => success[6], true) })); jest.mock('../../common/log-helpers', () => ({ @@ -137,6 +138,13 @@ describe('hub clone command', () => { type: 'string' }); + expect(spyOption).toHaveBeenCalledWith('experimental', { + type: 'boolean', + boolean: true, + describe: + 'Must be passed to use the event clone step. Only use this argument if you fully understand its limitations.' + }); + expect(spyOption).toHaveBeenCalledWith('dstHubId', { type: 'string', describe: 'Destination hub ID. If not specified, it will be the same as the source.' @@ -242,6 +250,7 @@ describe('hub clone command', () => { clientId: 'client-id', clientSecret: 'client-id', hubId: 'hub-id', + experimental: true, revertLog: Promise.resolve(undefined) }; @@ -403,6 +412,50 @@ describe('hub clone command', () => { } }); + it('should exclude experimental steps if experimental is false', async () => { + clearMocks(); + success = [true, true, true, true, true, true, true]; + + const argv: Arguments = { + ...yargArgs, + ...config, + experimental: false, + + dir: `temp_${process.env.JEST_WORKER_ID}/clone/steps`, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + logFile: createLog(`temp_${process.env.JEST_WORKER_ID}/clone/steps/all.log`), + + force: false, + validate: false, + skipIncomplete: false, + media: true + }; + + const stepConfig = makeState(argv); + + await handler(argv); + + stepConfig.argv.mapFile = expect.any(String); + + const mocks = getMocks(); + + mocks.forEach(mock => { + const instance = mock.mock.results[0].value; + + if (instance.isExperimental) { + expect(instance.run).not.toHaveBeenCalled(); + } else { + expect(instance.run).toHaveBeenCalledWith(stepConfig); + } + }); + + const loadLog = new FileLog(); + await loadLog.loadFromFile(`temp_${process.env.JEST_WORKER_ID}/clone/steps/all.log`); + }); + it('should only have one of each type of step', () => { const stepsSoFar = new Set(); @@ -425,7 +478,8 @@ describe('hub clone command', () => { const config = { clientId: 'client-id', clientSecret: 'client-id', - hubId: 'hub-id' + hubId: 'hub-id', + experimental: true }; beforeAll(async () => { @@ -644,5 +698,51 @@ describe('hub clone command', () => { await loadLog.loadFromFile(`temp_${process.env.JEST_WORKER_ID}/clone-revert/step/step` + i + '.log'); } }); + + it('should exclude experimental steps if experimental is false', async () => { + clearMocks(); + success = [true, true, true, true, true, true, true]; + await ensureDirectoryExists(`temp_${process.env.JEST_WORKER_ID}/clone-revert/`); + await prepareFakeLog(`temp_${process.env.JEST_WORKER_ID}/clone-revert/steps.log`); + + const argv: Arguments = { + ...yargArgs, + ...config, + experimental: false, + + dir: `temp_${process.env.JEST_WORKER_ID}/clone-revert/steps`, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + logFile: createLog(`temp_${process.env.JEST_WORKER_ID}/clone-revert/steps/all.log`), + revertLog: openRevertLog(`temp_${process.env.JEST_WORKER_ID}/clone-revert/steps.log`), + + mapFile: `temp_${process.env.JEST_WORKER_ID}/clone-revert/steps/all.json`, + force: false, + validate: false, + skipIncomplete: false, + media: true + }; + + const stepConfig = makeState(argv); + + await handler(argv); + + const mocks = getMocks(); + + mocks.forEach(mock => { + const instance = mock.mock.results[0].value; + + if (instance.isExperimental) { + expect(instance.revert).not.toHaveBeenCalled(); + } else { + expect(instance.revert).toHaveBeenCalledWith(stepConfig); + } + }); + + const loadLog = new FileLog(); + await loadLog.loadFromFile(`temp_${process.env.JEST_WORKER_ID}/clone-revert/steps/all.log`); + }); }); }); diff --git a/src/commands/hub/clone.ts b/src/commands/hub/clone.ts index 82f72020..a44930cb 100644 --- a/src/commands/hub/clone.ts +++ b/src/commands/hub/clone.ts @@ -15,6 +15,7 @@ import { CloneHubState } from './model/clone-hub-state'; import { LogErrorLevel } from '../../common/archive/archive-log'; import { ExtensionCloneStep } from './steps/extension-clone-step'; import { EventCloneStep } from './steps/event-clone-step'; +import { CloneHubStep } from './model/clone-hub-step'; export function getDefaultMappingPath(name: string, platform: string = process.platform): string { return join( @@ -42,7 +43,7 @@ export const desc = export const LOG_FILENAME = (platform: string = process.platform): string => getDefaultLogPath('hub', 'clone', platform); -export const steps = [ +export const steps: CloneHubStep[] = [ new SettingsCloneStep(), new ExtensionCloneStep(), new SchemaCloneStep(), @@ -60,6 +61,13 @@ export const builder = (yargs: Argv): void => { type: 'string' }) + .option('experimental', { + type: 'boolean', + boolean: true, + describe: + 'Must be passed to use the event clone step. Only use this argument if you fully understand its limitations.' + }) + .option('dstHubId', { type: 'string', describe: 'Destination hub ID. If not specified, it will be the same as the source.' @@ -163,7 +171,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 7969d821..a0336c54 100644 --- a/src/commands/hub/steps/content-clone-step.spec.ts +++ b/src/commands/hub/steps/content-clone-step.spec.ts @@ -44,6 +44,7 @@ describe('content clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), + experimental: false, dir: directory, diff --git a/src/commands/hub/steps/event-clone-step.spec.ts b/src/commands/hub/steps/event-clone-step.spec.ts index 5852e0ab..afc89f5d 100644 --- a/src/commands/hub/steps/event-clone-step.spec.ts +++ b/src/commands/hub/steps/event-clone-step.spec.ts @@ -89,6 +89,7 @@ describe('event clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), + experimental: true, dir: directory, diff --git a/src/commands/hub/steps/event-clone-step.ts b/src/commands/hub/steps/event-clone-step.ts index dfb5fb37..18b78992 100644 --- a/src/commands/hub/steps/event-clone-step.ts +++ b/src/commands/hub/steps/event-clone-step.ts @@ -17,6 +17,8 @@ export class EventCloneStep implements CloneHubStep { return 'Clone Events'; } + isExperimental = true; + async run(state: CloneHubState): Promise { try { state.logFile.appendLine(`Exporting existing events from destination.`); diff --git a/src/commands/hub/steps/extension-clone-step.spec.ts b/src/commands/hub/steps/extension-clone-step.spec.ts index 476041fb..b128c18d 100644 --- a/src/commands/hub/steps/extension-clone-step.spec.ts +++ b/src/commands/hub/steps/extension-clone-step.spec.ts @@ -56,6 +56,7 @@ describe('extension clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), + experimental: false, dir: directory, diff --git a/src/commands/hub/steps/index-clone-step.spec.ts b/src/commands/hub/steps/index-clone-step.spec.ts index 649dbbcd..7e418ab1 100644 --- a/src/commands/hub/steps/index-clone-step.spec.ts +++ b/src/commands/hub/steps/index-clone-step.spec.ts @@ -56,6 +56,7 @@ describe('index clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), + experimental: false, 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 aeced297..c1514ea9 100644 --- a/src/commands/hub/steps/schema-clone-step.spec.ts +++ b/src/commands/hub/steps/schema-clone-step.spec.ts @@ -66,6 +66,7 @@ describe('schema clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), + experimental: false, dir: directory, diff --git a/src/commands/hub/steps/settings-clone-step.spec.ts b/src/commands/hub/steps/settings-clone-step.spec.ts index d56549e5..536d0b6a 100644 --- a/src/commands/hub/steps/settings-clone-step.spec.ts +++ b/src/commands/hub/steps/settings-clone-step.spec.ts @@ -43,6 +43,7 @@ describe('settings clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), + experimental: false, 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 96f38107..d8c85b76 100644 --- a/src/commands/hub/steps/type-clone-step.spec.ts +++ b/src/commands/hub/steps/type-clone-step.spec.ts @@ -66,6 +66,7 @@ describe('type clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), + experimental: false, dir: directory, diff --git a/src/interfaces/clone-hub-builder-options.ts b/src/interfaces/clone-hub-builder-options.ts index a71a343d..3e58e07f 100644 --- a/src/interfaces/clone-hub-builder-options.ts +++ b/src/interfaces/clone-hub-builder-options.ts @@ -23,4 +23,5 @@ export interface CloneHubBuilderOptions { republish?: boolean; excludeKeys?: boolean; + experimental: boolean; } From eeb50273b32e3d5997f3171355d4314067c13409 Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 22 Mar 2022 10:07:20 +0000 Subject: [PATCH 12/22] fix(clone): pass map file to event clone --- src/commands/hub/steps/event-clone-step.spec.ts | 3 +++ src/commands/hub/steps/event-clone-step.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/commands/hub/steps/event-clone-step.spec.ts b/src/commands/hub/steps/event-clone-step.spec.ts index afc89f5d..17bb3943 100644 --- a/src/commands/hub/steps/event-clone-step.spec.ts +++ b/src/commands/hub/steps/event-clone-step.spec.ts @@ -90,6 +90,7 @@ describe('event clone step', () => { ...config, logFile: new FileLog(), experimental: true, + mapFile: 'mapping.json', dir: directory, @@ -159,6 +160,7 @@ describe('event clone step', () => { originalIds: false, schedule: true, experimental: true, + mapFile: 'mapping.json', catchup: false, logFile: state.logFile, ...state.to @@ -244,6 +246,7 @@ describe('event clone step', () => { originalIds: true, schedule: true, experimental: true, + mapFile: 'mapping.json', catchup: false, logFile: state.logFile, ...state.to diff --git a/src/commands/hub/steps/event-clone-step.ts b/src/commands/hub/steps/event-clone-step.ts index 18b78992..e39e8559 100644 --- a/src/commands/hub/steps/event-clone-step.ts +++ b/src/commands/hub/steps/event-clone-step.ts @@ -52,6 +52,7 @@ export class EventCloneStep implements CloneHubStep { await importEvent({ dir: join(state.path, 'event'), logFile: state.logFile, + mapFile: state.argv.mapFile, originalIds: false, schedule: true, experimental: true, @@ -89,6 +90,7 @@ export class EventCloneStep implements CloneHubStep { { dir: join(state.path, 'oldEvent'), logFile: state.logFile, + mapFile: state.argv.mapFile, originalIds: true, schedule: true, experimental: true, From fd544fe23ab7eadb159acdb3a20f945bf90fb59f Mon Sep 17 00:00:00 2001 From: Rhys Date: Fri, 25 Mar 2022 16:39:24 +0000 Subject: [PATCH 13/22] refactor(event): update dc-management-sdk-js --- package-lock.json | 2 +- src/commands/event/import.spec.ts | 57 +++++++++++-------- src/commands/event/import.ts | 40 ++++++++----- .../event-schedule-error.ts | 18 ++++++ 4 files changed, 77 insertions(+), 40 deletions(-) create mode 100644 src/common/dc-management-sdk-js/event-schedule-error.ts diff --git a/package-lock.json b/package-lock.json index 72c70d89..97b2fbe5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3378,7 +3378,7 @@ "dev": true }, "dc-management-sdk-js": { - "version": "git+ssh://git@github.com/rs-amp/dc-management-sdk-js.git#07b1d31cc6dab72593bade5ecbe734fa20cbe7d5", + "version": "git+ssh://git@github.com/rs-amp/dc-management-sdk-js.git#39a5d1b5841daafcd7107c5a52d76043398888f3", "from": "git+ssh://git@github.com/rs-amp/dc-management-sdk-js.git#feature/events", "requires": { "axios": "^0.21.1", diff --git a/src/commands/event/import.spec.ts b/src/commands/event/import.spec.ts index c5ce5c72..00385fd3 100644 --- a/src/commands/event/import.spec.ts +++ b/src/commands/event/import.spec.ts @@ -643,32 +643,39 @@ describe('event import command', () => { const edition = new Edition(); const log = new FileLog(); - edition.related.schedule = jest.fn().mockResolvedValue({ - errors: [ - { - level: 'WARNING', - code: 'EDITION_SCHEDULE_OVERLAP', - message: 'Edition Schedule Overlap. Please try again later.', - overlaps: [ - { - editionId: 'edition-id', - name: 'Test schedule edition', - start: '2022-01-07T15:31:47.337Z' - } - ] - }, - { - level: 'WARNING', - code: 'EDITION_CONTAINS_SLOT_COLLISIONS', - message: 'Edition contains slots that collide with other editions.' - }, - { - level: 'ERROR', - code: 'FAKE_ERROR', - message: 'This is an error.' + edition.related.schedule = jest + .fn() + .mockRejectedValueOnce({ + response: { + data: { + errors: [ + { + level: 'WARNING', + code: 'EDITION_SCHEDULE_OVERLAP', + message: 'Edition Schedule Overlap. Please try again later.', + overlaps: [ + { + editionId: 'edition-id', + name: 'Test schedule edition', + start: '2022-01-07T15:31:47.337Z' + } + ] + }, + { + level: 'WARNING', + code: 'EDITION_CONTAINS_SLOT_COLLISIONS', + message: 'Edition contains slots that collide with other editions.' + }, + { + level: 'ERROR', + code: 'FAKE_ERROR', + message: 'This is an error.' + } + ] + } } - ] - }); + }) + .mockResolvedValueOnce({}); // Second call is resolved. await importModule.scheduleEdition(edition, log); diff --git a/src/commands/event/import.ts b/src/commands/event/import.ts index b14f7855..b21edf63 100644 --- a/src/commands/event/import.ts +++ b/src/commands/event/import.ts @@ -28,6 +28,7 @@ import { import { SnapshotCreator } from 'dc-management-sdk-js/build/main/lib/model/SnapshotCreator'; import { isEqual } from 'lodash'; import { dateMax, dateOffset, sortByEndDate, TimeRange } from '../../common/import/date-helpers'; +import { EditionScheduleStatus } from '../../common/dc-management-sdk-js/event-schedule-error'; export const InstantSecondsAllowance = 5; export const EditionSecondsAllowance = 5; @@ -289,26 +290,37 @@ export const prepareEditionForSchedule = async (edition: Edition, event: Event): }; export const scheduleEdition = async (edition: Edition, log: FileLog): Promise => { - const warning = await edition.related.schedule(false); + try { + await edition.related.schedule(false); + } catch (e) { + if (e.response && e.response.data && typeof e.response.data === 'object') { + // Attempt to parse the response data. + + const warning = new EditionScheduleStatus(e.response.data); - if (warning.errors) { - for (const error of warning.errors) { - if (error.level === 'WARNING') { - let message = `${error.code}: ${error.message}`; + if (warning.errors) { + for (const error of warning.errors) { + if (error.level === 'WARNING') { + let message = `${error.code}: ${error.message}`; + + if (error.overlaps) { + message += ` (${error.overlaps + .map(overlap => `${overlap.name} - ${overlap.editionId} ${overlap.start}`) + .join(', ')})`; + } - if (error.overlaps) { - message += ` (${error.overlaps - .map(overlap => `${overlap.name} - ${overlap.editionId} ${overlap.start}`) - .join(', ')})`; + log.warn(message); + } else { + log.error(`${error.code}: ${error.message}`); + } } - log.warn(message); - } else { - log.error(`${error.code}: ${error.message}`); + // Errors the second time will be thrown (ignoreWarnings is passed). + await edition.related.schedule(true); } + } else { + throw e; } - - await edition.related.schedule(true); } }; diff --git a/src/common/dc-management-sdk-js/event-schedule-error.ts b/src/common/dc-management-sdk-js/event-schedule-error.ts new file mode 100644 index 00000000..65d2f1ed --- /dev/null +++ b/src/common/dc-management-sdk-js/event-schedule-error.ts @@ -0,0 +1,18 @@ +import { HalResource } from 'dc-management-sdk-js'; + +export interface EditionScheduleOverlap { + editionId: string; + name: string; + start: string; +} + +export interface EditionScheduleError { + level: 'WARNING' | 'ERROR'; + code: string; + message: string; + overlaps?: EditionScheduleOverlap[]; +} + +export class EditionScheduleStatus extends HalResource { + public errors?: EditionScheduleError[]; +} From 1f46e105cdfd400bddb6eb3d76706ced5008be4b Mon Sep 17 00:00:00 2001 From: Rhys Date: Thu, 7 Apr 2022 17:00:01 +0100 Subject: [PATCH 14/22] fix: use lastModifiedDate from edition rather than client timestamp for schedule --- src/commands/event/import.spec.ts | 10 +++++----- src/commands/event/import.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/commands/event/import.spec.ts b/src/commands/event/import.spec.ts index 00385fd3..1638eba3 100644 --- a/src/commands/event/import.spec.ts +++ b/src/commands/event/import.spec.ts @@ -628,7 +628,7 @@ describe('event import command', () => { describe('scheduleEdition tests', function() { it('should schedule without logging anything if no warnings are returned', async function() { - const edition = new Edition(); + const edition = new Edition({ lastModifiedDate: 'date' }); const log = new FileLog(); edition.related.schedule = jest.fn().mockResolvedValue({}); @@ -636,11 +636,11 @@ describe('event import command', () => { await importModule.scheduleEdition(edition, log); expect(edition.related.schedule).toHaveBeenCalledTimes(1); - expect(edition.related.schedule).toHaveBeenCalledWith(false); + expect(edition.related.schedule).toHaveBeenCalledWith(false, 'date'); expect(log.accessGroup).toEqual([]); }); it('should log warnings/errors if they are returned, and try again with ignoreWarnings true', async function() { - const edition = new Edition(); + const edition = new Edition({ lastModifiedDate: 'date' }); const log = new FileLog(); edition.related.schedule = jest @@ -680,8 +680,8 @@ describe('event import command', () => { await importModule.scheduleEdition(edition, log); expect(edition.related.schedule).toHaveBeenCalledTimes(2); - expect(edition.related.schedule).toHaveBeenNthCalledWith(1, false); - expect(edition.related.schedule).toHaveBeenNthCalledWith(2, true); + expect(edition.related.schedule).toHaveBeenNthCalledWith(1, false, 'date'); + expect(edition.related.schedule).toHaveBeenNthCalledWith(2, true, 'date'); expect(log.accessGroup).toMatchInlineSnapshot(` Array [ Object { diff --git a/src/commands/event/import.ts b/src/commands/event/import.ts index b21edf63..0acef748 100644 --- a/src/commands/event/import.ts +++ b/src/commands/event/import.ts @@ -291,7 +291,7 @@ export const prepareEditionForSchedule = async (edition: Edition, event: Event): export const scheduleEdition = async (edition: Edition, log: FileLog): Promise => { try { - await edition.related.schedule(false); + await edition.related.schedule(false, edition.lastModifiedDate); } catch (e) { if (e.response && e.response.data && typeof e.response.data === 'object') { // Attempt to parse the response data. @@ -316,7 +316,7 @@ export const scheduleEdition = async (edition: Edition, log: FileLog): Promise Date: Wed, 20 Apr 2022 15:48:38 +0100 Subject: [PATCH 15/22] fix(event): update edition start time before schedule if publishing after snapshot create --- src/commands/event/import.spec.ts | 147 +++++++++++++++++++++++++++--- src/commands/event/import.ts | 33 +++++-- 2 files changed, 161 insertions(+), 19 deletions(-) diff --git a/src/commands/event/import.spec.ts b/src/commands/event/import.spec.ts index 1638eba3..112c7b7f 100644 --- a/src/commands/event/import.spec.ts +++ b/src/commands/event/import.spec.ts @@ -556,7 +556,7 @@ describe('event import command', () => { futureSpy.mockResolvedValueOnce('3'); futureSpy.mockResolvedValueOnce('4'); - importModule.prepareEditionForSchedule(edition, event); + await importModule.prepareEditionForSchedule(edition, event); expect(importModule.moveDateToFuture).not.toHaveBeenCalled(); edition.publishingStatus = PublishingStatus.SCHEDULED; @@ -578,6 +578,36 @@ describe('event import command', () => { jest.resetAllMocks(); }); + + it('should move the start and end dates to the future if the force parameter is true', async function() { + const edition = new Edition({ start: '1', end: '2', publishingStatus: PublishingStatus.DRAFT }); + const event = new Event(); + + const futureSpy = jest.spyOn(importModule, 'moveDateToFuture'); + futureSpy.mockResolvedValueOnce('3'); + futureSpy.mockResolvedValueOnce('4'); + + await importModule.prepareEditionForSchedule(edition, event); + expect(importModule.moveDateToFuture).not.toHaveBeenCalled(); + + await importModule.prepareEditionForSchedule(edition, event, true); + expect(importModule.moveDateToFuture).toHaveBeenNthCalledWith( + 1, + '1', + event, + importModule.EditionSecondsAllowance + ); + expect(importModule.moveDateToFuture).toHaveBeenNthCalledWith( + 2, + '2', + event, + importModule.ScheduleSecondsAllowance + ); + expect(edition.start).toEqual('3'); + expect(edition.end).toEqual('4'); + + jest.resetAllMocks(); + }); }); describe('skipScheduleIfNeeded tests', function() { @@ -811,7 +841,7 @@ Array [ mapping.registerSlot('id-1', 'id-2'); - const rewriteSnapshots = jest.spyOn(importModule, 'rewriteSnapshots').mockResolvedValue(); + const rewriteSnapshots = jest.spyOn(importModule, 'rewriteSnapshots').mockResolvedValue(false); const importTest = [ new EditionSlot({ id: 'id-1', @@ -822,7 +852,9 @@ Array [ const realEdition = new Edition(); realEdition.related.slots.list = jest.fn().mockResolvedValue(new MockPage(EditionSlot, [realSlot])); - await importSlots(importTest, mapping, hub, realEdition, argv, log); + const result = await importSlots(importTest, mapping, hub, realEdition, argv, log); + + expect(result).toBeFalsy(); // rewriteSnapshots returns false. expect(realEdition.related.slots.list).toHaveBeenCalledTimes(1); expect(realSlot.related.content).toHaveBeenCalledTimes(1); @@ -838,7 +870,7 @@ Array [ const { hub, argv, log, mapping } = await commonMock({ originalIds: true }); - const rewriteSnapshots = jest.spyOn(importModule, 'rewriteSnapshots').mockResolvedValue(); + const rewriteSnapshots = jest.spyOn(importModule, 'rewriteSnapshots').mockResolvedValue(false); const importTest = [ new EditionSlot({ id: 'id-1', @@ -865,7 +897,7 @@ Array [ const { hub, argv, log, mapping } = await commonMock({ originalIds: false }); - const rewriteSnapshots = jest.spyOn(importModule, 'rewriteSnapshots').mockResolvedValue(); + const rewriteSnapshots = jest.spyOn(importModule, 'rewriteSnapshots').mockResolvedValue(false); const importTest = [ new EditionSlot({ id: 'id-1', @@ -887,6 +919,44 @@ Array [ expect(rewriteSnapshots).toHaveBeenCalledWith('{ "content": "test" }', mapping, hub, log); }); + + it('should return true if any rewriteSnapshots call returns true', async function() { + mockValues({}); + + const realSlot = new EditionSlot({ id: 'id-new', content: 'updated' }); + realSlot.related.content = jest.fn().mockResolvedValue(realSlot); + + const { hub, argv, log, mapping } = await commonMock({ originalIds: false }); + + const rewriteSnapshots = jest + .spyOn(importModule, 'rewriteSnapshots') + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const importTest = [ + new EditionSlot({ + id: 'id-1', + content: '{ "content": "test" }' + }), + new EditionSlot({ + id: 'id-2', + content: '{ "content": "test" }' + }) + ]; + + const realEdition = new Edition(); + realEdition.related.slots.create = jest.fn().mockResolvedValue(new MockPage(EditionSlot, [realSlot])); + realEdition.related.slots.list = jest.fn().mockResolvedValue(new MockPage(EditionSlot, [])); + + const result = await importSlots(importTest, mapping, hub, realEdition, argv, log); + + expect(result).toBeTruthy(); + + expect(realEdition.related.slots.list).toHaveBeenCalledTimes(1); + expect(realEdition.related.slots.create).toHaveBeenCalledTimes(2); + expect(realSlot.related.content).toHaveBeenCalledTimes(2); + + expect(rewriteSnapshots).toHaveBeenCalledTimes(2); + }); }); describe('importEditions tests', function() { @@ -900,7 +970,7 @@ Array [ mapping.registerEdition('id-1', 'id-2'); - const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(false); const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; const importTest = [ new EditionWithSlots({ @@ -931,7 +1001,7 @@ Array [ const { client, hub, argv, log, mapping } = await commonMock({ originalIds: true }); - const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(false); const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; const importTest = [ new EditionWithSlots({ @@ -963,7 +1033,7 @@ Array [ const { client, hub, argv, log, mapping } = await commonMock({ originalIds: false }); - const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(false); const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; const importTest = [ new EditionWithSlots({ @@ -996,7 +1066,7 @@ Array [ argv.schedule = true; mapping.registerEdition('id-1', 'id-2'); - const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(false); const scheduleEdition = jest.spyOn(importModule, 'scheduleEdition').mockResolvedValue(); const skipSchedule = jest.spyOn(importModule, 'skipScheduleIfNeeded').mockReturnValue(); const prepareEdition = jest.spyOn(importModule, 'prepareEditionForSchedule').mockResolvedValue(); @@ -1053,7 +1123,7 @@ Array [ argv.schedule = true; mapping.registerEdition('id-1', 'id-2'); - const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(false); const scheduleEdition = jest.spyOn(importModule, 'scheduleEdition').mockResolvedValue(); const skipSchedule = jest.spyOn(importModule, 'skipScheduleIfNeeded').mockReturnValue(); const prepareEdition = jest.spyOn(importModule, 'prepareEditionForSchedule').mockResolvedValue(); @@ -1110,7 +1180,7 @@ Array [ argv.schedule = true; mapping.registerEdition('id-1', 'id-2'); - const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(false); const scheduleEdition = jest.spyOn(importModule, 'scheduleEdition').mockResolvedValue(); const skipSchedule = jest.spyOn(importModule, 'skipScheduleIfNeeded').mockReturnValue(); const prepareEdition = jest.spyOn(importModule, 'prepareEditionForSchedule').mockResolvedValue(); @@ -1165,7 +1235,7 @@ Array [ argv.schedule = true; mapping.registerEdition('id-1', 'id-2'); - const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(false); const scheduleEdition = jest.spyOn(importModule, 'scheduleEdition').mockResolvedValue(); const skipSchedule = jest.spyOn(importModule, 'skipScheduleIfNeeded').mockReturnValue(); const prepareEdition = jest.spyOn(importModule, 'prepareEditionForSchedule').mockResolvedValue(); @@ -1211,7 +1281,7 @@ Array [ mapping.registerEdition('id-1', 'id-2'); - const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(); + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(false); const shouldUpdate = jest.spyOn(importModule, 'shouldUpdateEdition').mockReturnValue(false); const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; const importTest = [ @@ -1235,6 +1305,57 @@ Array [ expect(importSlots).not.toHaveBeenCalled(); }); + + it('should refetch and update editions so that their start dates are not in the past after snapshot creation when publishing', async function() { + const { mockEditionGet, mockEditionUpdate } = mockValues({}); + + const realEdition = new Edition({ id: 'id-2', name: 'updated', publishingStatus: PublishingStatus.DRAFT }); + (mockEditionUpdate as jest.Mock).mockResolvedValue(realEdition); + + const { client, hub, argv, log, mapping } = await commonMock(); + + argv.schedule = true; + mapping.registerEdition('id-1', 'id-2'); + + // Indicate that the snapshot creation has happened. + const importSlots = jest.spyOn(importModule, 'importSlots').mockResolvedValue(true); + const scheduleEdition = jest.spyOn(importModule, 'scheduleEdition').mockResolvedValue(); + const skipSchedule = jest.spyOn(importModule, 'skipScheduleIfNeeded').mockReturnValue(); + const prepareEdition = jest.spyOn(importModule, 'prepareEditionForSchedule').mockImplementation(async edition => { + edition.start = dateOffset(5).toISOString(); + }); + + const slots = [new EditionSlot({ id: 'slot1' }), new EditionSlot({ id: 'slot2' })]; + const importTest = [ + new EditionWithSlots({ + id: 'id-1', + name: 'Edition', + start: dateOffset(-10).toISOString(), + end: dateOffset(15).toISOString(), + publishingStatus: PublishingStatus.SCHEDULED, + comment: 'comment', + slots + }) + ]; + + const realEvent = new Event({ + start: dateOffset(-10).toISOString(), + end: dateOffset(20).toISOString() + }); + + await importEditions(importTest, mapping, client, hub, realEvent, argv, log); + + expect(mockEditionGet).toHaveBeenCalledWith('id-2'); + expect(mockEditionUpdate).toHaveBeenCalledTimes(2); + + expect(skipSchedule).toHaveBeenCalledWith(importTest[0], true); + expect(prepareEdition).toHaveBeenCalledWith(expect.any(Edition), realEvent); + + expect(importSlots).toHaveBeenCalledWith(slots, mapping, hub, realEdition, argv, log); + expect(scheduleEdition).toHaveBeenCalledWith(expect.any(Edition), log); + + expect(importModule.prepareEditionForSchedule).toBeCalledTimes(2); // This should be called again just before schedule. + }); }); describe('importEvents tests', function() { diff --git a/src/commands/event/import.ts b/src/commands/event/import.ts index 0acef748..9a1e9bf4 100644 --- a/src/commands/event/import.ts +++ b/src/commands/event/import.ts @@ -172,13 +172,15 @@ export const rewriteSnapshots = async ( mapping: ContentMapping, hub: Hub, log: FileLog -): Promise => { +): Promise => { // Search for links/references in the slot content. const dummyRepo = new ContentRepository(); const tree = new ContentDependancyTree([{ repo: dummyRepo, content }], new ContentMapping()); const dependencies = tree.all[0].dependancies; + let snapshotCreated = false; + for (const dep of dependencies) { const entry = dep.dependancy as SlotDependency; @@ -204,11 +206,14 @@ export const rewriteSnapshots = async ( mapping.registerSnapshot(entry.id as string, snapshotId); log.addAction('SNAPSHOT-CREATE', snapshotId); + snapshotCreated = true; } dep.dependancy.id = snapshotId; entry._meta.rootContentItemId = itemId; } + + return snapshotCreated; }; export const importSlots = async ( @@ -218,8 +223,9 @@ export const importSlots = async ( edition: Edition, argv: ImportEventBuilderOptions, log: FileLog -): Promise => { +): Promise => { const editionSlots = await paginator(edition.related.slots.list); + let snapshot = false; for (const slot of slots) { let realSlot: EditionSlot | undefined = undefined; @@ -254,13 +260,15 @@ export const importSlots = async ( } // Update the existing slot based off of the file. - await rewriteSnapshots(slot.content, mapping, hub, log); + snapshot = snapshot || (await rewriteSnapshots(slot.content, mapping, hub, log)); realSlot = await realSlot.related.content(slot.content); log.addComment(`${updated ? 'Updated' : 'Created'} slot ${realSlot.slotId}.`); log.addAction(`SLOT-${updated ? 'UPDATE' : 'CREATE'}`, realSlot.id as string); } + + return snapshot; }; export const isScheduled = (edition: Edition): boolean => @@ -281,8 +289,8 @@ export const moveDateToFuture = async (date: string, event: Event, offset: numbe return newDate.toISOString(); }; -export const prepareEditionForSchedule = async (edition: Edition, event: Event): Promise => { - if (isScheduled(edition)) { +export const prepareEditionForSchedule = async (edition: Edition, event: Event, force = false): Promise => { + if (force || isScheduled(edition)) { // This edition must start in the future for it to be scheduled. edition.start = await moveDateToFuture(edition.start as string, event, EditionSecondsAllowance); edition.end = await moveDateToFuture(edition.end as string, event, ScheduleSecondsAllowance); @@ -429,9 +437,11 @@ export const importEditions = async ( } } + let createdSnapshots = false; + if (update) { // Attempt to create/update slots. - await importSlots(edition.slots, mapping, hub, realEdition, argv, log); + createdSnapshots = await importSlots(edition.slots, mapping, hub, realEdition, argv, log); } // If the original edition was scheduled, attempt to schedule the new one. @@ -439,6 +449,17 @@ export const importEditions = async ( if (update && edition.slots.length > 0) { // Refetch the edition to make sure it's up to date before scheduling. realEdition = await client.editions.get(realEdition.id as string); + + if (createdSnapshots) { + // We might need to move the edition into the future again, + // as creating snapshots may have taken more time than our scheduling grace period. + const lastStart = realEdition.start; + await prepareEditionForSchedule(realEdition, event, true); + + if (realEdition.start != lastStart) { + realEdition = await realEdition.related.update(realEdition); + } + } } await scheduleEdition(realEdition, log); } From 1b4032869f012c7a6a499102e0585199fd31756e Mon Sep 17 00:00:00 2001 From: Rhys Date: Fri, 22 Apr 2022 12:15:45 +0100 Subject: [PATCH 16/22] fix(event): fix issue causing snapshots to not rewrite --- src/commands/event/import.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/event/import.ts b/src/commands/event/import.ts index 9a1e9bf4..7ea97325 100644 --- a/src/commands/event/import.ts +++ b/src/commands/event/import.ts @@ -260,7 +260,7 @@ export const importSlots = async ( } // Update the existing slot based off of the file. - snapshot = snapshot || (await rewriteSnapshots(slot.content, mapping, hub, log)); + snapshot = (await rewriteSnapshots(slot.content, mapping, hub, log)) || snapshot; realSlot = await realSlot.related.content(slot.content); From 22772b33663414f38b4a00a291a3615ae3af57ed Mon Sep 17 00:00:00 2001 From: Rhys Date: Thu, 28 Apr 2022 12:38:56 +0100 Subject: [PATCH 17/22] docs(event): add events documentation --- README.md | 10 ++- docs/EVENT.md | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 docs/EVENT.md diff --git a/README.md b/README.md index 286c72bd..685a1c47 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Run `dc-cli --help` to get a list of available commands. - [extension](#extension) - [search-index](#search-index) - [content-repository](#content-repository) + - [event](#event) - [settings](#settings) - [hub](#hub) - [Building the CLI](#building-the-cli) @@ -123,6 +124,12 @@ These commands can be used to get details for a specific repository, list multip [View commands for **content-repository**](docs/CONTENT-REPOSITORY.md) +### event + +This category includes interactions with Dynamic Content's events and its constituent parts (Editions, Slots, and Snapshots). These commands can be used to export and import events, and to archive events. + +[View commands for **event**](docs/EVENT.md) + ### settings This category includes interactions with the supporting properties of a Dynamic Content hub. These commands can be used to export and import a hub's breakpoint settings for visualization, preview applications, workflow states, and locales. @@ -165,5 +172,4 @@ Outlined below are the detailed permissions required to run each command of the | `content-type-schema get ` | Hub - READ | CONTENT:FUNCTIONAL:CONTENT_TYPE:READ | | `content-type-schema list` | Hub - READ | CONTENT:FUNCTIONAL:CONTENT_TYPE:READ | | `content-type-schema update ` | Hub - READ | CONTENT:FUNCTIONAL:CONTENT_TYPE:EDIT | -| `content-type-schema import ` | Hub - READ | CONTENT:FUNCTIONAL:CONTENT_TYPE:READ
CONTENT:FUNCTIONAL:CONTENT_TYPE:CREATE
CONTENT:FUNCTIONAL:CONTENT_TYPE:EDIT | - +| `content-type-schema import ` | Hub - READ | CONTENT:FUNCTIONAL:CONTENT_TYPE:READ
CONTENT:FUNCTIONAL:CONTENT_TYPE:CREATE
CONTENT:FUNCTIONAL:CONTENT_TYPE:EDIT | \ No newline at end of file diff --git a/docs/EVENT.md b/docs/EVENT.md new file mode 100644 index 00000000..28ae8e6e --- /dev/null +++ b/docs/EVENT.md @@ -0,0 +1,192 @@ +# event + +## Description + +The **event** category includes interactions with Dynamic Content's events and its constituent parts (Editions, Slots, and Snapshots). These commands can be used to export and import events, and to archive events. + +Run `dc-cli event --help` to get a list of available commands. + +Return to [README.md](../README.md) for information on other command categories. + + + +- [Common Options](#common-options) +- [Useful Information](#useful-information) + - [Experimental: Export & Import](#experimental-export--import) + - [Mapping files](#mapping-files) + +- [Commands](#commands) + - [archive](#archive) + - [export](#export) + - [import](#import) + + + +## Common Options + +The following options are available for all **event** commands. + +| Option Name | Type | Description | +| -------------- | ---------------------------------------------------------- | -------------------------------- | +| --version | [boolean] | Show version number | +| --clientId | [string]
[required] | Client ID for the source hub | +| --clientSecret | [string]
[required] | Client secret for the source hub | +| --hubId | [string]
[required] | Hub ID for the source hub | +| --config | [string]
[default: "~/.amplience/dc-cli-config.json"] | Path to JSON config file | +| --help | [boolean] | Show help | +| --logFile | [string]
[default: (generated-value)] | Path to a log file to write to. | + +## Useful Information + +### Experimental: Export & Import + +The most granular part of an event in Dynamic Content is a snapshot. This is a representation of a content item exactly as it appears at the point when it was added to an edition using the content browser, or when it was saved to an edition in the production view. + +Due to this nature of snapshots, the event import command will not always result in an exact copy of the events exported with the event export command. Whilst the properties of the parent edition and event will match those of the source, the snapshots will be created as new. If a snapshotted content item in the source hub's exported events have been updated since the snapshot was created, then it will be the updated version of that content item which will be created as snapshots in the destination hub. + +As such, these commands should be considered an experimental feature, and should be used at your own risk if you understand these limitations. To use the import command, you must pass the `--experimental` argument along with any others. + +### Mapping files + +When importing events with the DC CLI, this creates or references a mapping file to determine whether the imported event should be created as new, or if an existing one within the Dynamic Content platform should be updated. + +For example exporting an event (eg `111111111111111111111111`) from one hub then importing it to another for the first time will create a new event with a randomly generated UUID (eg `222222222222222222222222`). + +To instruct the DC CLI on which event to update with future actions, a mapping between the source and destination is stored in a mapping file. This mapping file will contain an array of every event mapping identified for jobs using that mapping file, along with its constituent parts (editions, slots, and snapshots). Using the previous examples: + +``` +{ + "contentItems": [ + [ + "111111111111111111111111", + "222222222222222222222222" + ] + ], + "workflowStates": [], + "events": [ + [ + "111111111111111111111111", + "222222222222222222222222" + ] + ], + "editions": [ + [ + "111111111111111111111111", + "222222222222222222222222" + ] + ], + "slots": [ + [ + "111111111111111111111111", + "222222222222222222222222" + ] + ], + "snapshots": [ + [ + "111111111111111111111111", + "222222222222222222222222" + ] + ] +} +``` + +If no mapping file is specified (with the `--mapFile` argument) then a default one will be created or updated, using the destination's resource type (hub) and its ID, and stored within a default location in your user directory. For example: + +* Mac: `~/.amplience/imports/hub-111111111111111111111111.json` +* Windows: `%UserProfile%\.amplience\imports\hub-111111111111111111111111.json` + +If a mapping file does not exist at the point of import, then any imported events will be created as new, and a new mapping file will be created. If a mapping file exists, and was provided with the `--mapFile` argument, then any events found within the mapping file will be updated. Any events not contained in the mapping file will will be created as new, and will then be added to the mapping file. + +Content items referenced within your events' slots are not imported with the `event import` command, and will need to exist in the destination hub beforehand. Content item references will be reliant on existing mappings from a previous [content item](CONTENT-ITEM.md) import, though the command will try to look up the original content item IDs if no mapping is present (for same hub export/import use cases). + +## Commands + +### archive + +Archives events and their child edition in the targeted Dynamic Content hub, and will also unschedule them if they have not yet started. + +This command requires either an event ID or name to filter, and will not archive any events if no filter is provided. + +``` +dc-cli event archive [id] +``` + +#### Options + +| Option Name | Type | Description | +| ----------------- | --------- | ------------------------------------------------------------ | +| --name | [string] | The name of an Event to be archived.
A regex can be provided to select multiple items with similar or matching names (eg /.header/).
A single --name option may be given to match a single event pattern.
Multiple --name options may be given to match multiple events' patterns at the same time, or even multiple regex. | +| -f,
--force | [boolean] | If present, there will be no confirmation prompt before archiving the found content. | +| -s,
--silent | [boolean] | If present, no log file will be produced. | + +#### Examples + +##### Archive event with the ID of "foo" + +`dc-cli event archive foo` + +##### Archive all events with "Christmas" in their name + +`dc-cli event archive --name "/Christmas/"` + +### export + +Exports events from the targeted Dynamic Content hub into the specified filesystem location. + +We recommend reading about [snapshots and DC CLI](#snapshots-and-dc-cli) before exporting or importing events. + +``` +dc-cli event export +``` + +#### Options + +| Option Name | Type | Description | +| ----------- | --------- | ------------------------------------------------------------ | +| --id | [string] | Export a single event by ID, rather then fetching all of them. | +| --fromDate | [string] | Start date for filtering events.
Either "NOW" or in the format "\:\", example: "-7:DAYS". | +| --toDate | [string] | To date for filtering events.
Either "NOW" or in the format "\:\", example: "-7:DAYS". | +| --snapshots | [boolean] | Save content snapshots with events, in subfolder "snapshots/". | + +#### Examples + +##### Export all events from a Hub + +`dc-cli event export ./myDirectory/event` + +##### Export all events which start 7 days from now from a Hub + +`dc-cli event export ./myDirectory/event --fromDate "+7:DAYS"` + +### import + +Imports events from the specified filesystem location to the targeted Dynamic Content hub. + +We recommend reading about [snapshots and DC CLI](#snapshots-and-dc-cli) before exporting or importing events. + +Before importing events you must ensure that a valid [content item](#CONTENT-ITEM.md) exists in the destination hub for each content item contained within each event, and contains an appropriate mapping within the [mapping file](#mapping-files). + +``` +dc-cli event import +``` + +#### Options + +| Option Name | Type | Description | +| --------------- | --------- | ------------------------------------------------------------ | +| --experimental | [boolean] | Must be passed to use the event import command.
Only use this command if you fully understand its [limitations](#experimental-export--import). | +| --mapFile | [string] | Mapping file to use when updating content that already exists.
Updated with any new mappings that are generated.
If not present, will be created.
For more information, see [mapping files](#MAPPING-FILES). | +| -f
--force | [boolean] | Overwrite existing events, editions, slots and snapshots without asking. | +| --schedule | [boolean] | Schedule events in the destination repo if they are scheduled in the source.
If any new or updated scheduled events started in the past, they will be moved to happen at the time of import.
If they ended in the past, they will be skipped by default. | +| --catchup | [boolean] | Scheduling events that ended in the past will move to the current date, so that their publishes run. | +| --originalIds | [boolean] | Use original IDs. | + +#### Examples + +##### Import events from the filesystem + +`dc-cli event import ./myDirectory/event --experimental` + +##### Specify a mapping file when importing + +`dc-cli event import ./myDirectory/event --mapFile ./myDirectory/mappingFile.json --experimental` \ No newline at end of file From f88955ef834ffec5889cf4d6fcec1028828c7bc7 Mon Sep 17 00:00:00 2001 From: Rhys Date: Fri, 6 May 2022 16:35:52 +0100 Subject: [PATCH 18/22] refactor(event): change experimental flag to acceptSnapshotLimits --- docs/EVENT.md | 37 +++++++++++++------ src/commands/event/import.spec.ts | 10 ++--- src/commands/event/import.ts | 8 ++-- .../hub/steps/event-clone-step.spec.ts | 4 +- src/commands/hub/steps/event-clone-step.ts | 4 +- .../import-event-builder-options.interface.ts | 2 +- 6 files changed, 39 insertions(+), 26 deletions(-) diff --git a/docs/EVENT.md b/docs/EVENT.md index 28ae8e6e..2b224503 100644 --- a/docs/EVENT.md +++ b/docs/EVENT.md @@ -12,7 +12,7 @@ Return to [README.md](../README.md) for information on other command categories. - [Common Options](#common-options) - [Useful Information](#useful-information) - - [Experimental: Export & Import](#experimental-export--import) + - [Export & Import limitations](#export--import-limitations) - [Mapping files](#mapping-files) - [Commands](#commands) @@ -38,13 +38,26 @@ The following options are available for all **event** commands. ## Useful Information -### Experimental: Export & Import +### Export & Import limitations -The most granular part of an event in Dynamic Content is a snapshot. This is a representation of a content item exactly as it appears at the point when it was added to an edition using the content browser, or when it was saved to an edition in the production view. +When exporting and importing events with the DC CLI, it is important to understand that these commands have some limitations in regards to the snapshot components of events. + +Events, Editions, and Slots will be exported and imported much like with the other export and import commands in the CLI. However the most granular part of an event in Dynamic Content, the Snapshot, is a representation of a content item exactly as it appears at the point when it was added to an edition using the content browser, or when it was saved to an edition in the production view. Due to this nature of snapshots, the event import command will not always result in an exact copy of the events exported with the event export command. Whilst the properties of the parent edition and event will match those of the source, the snapshots will be created as new. If a snapshotted content item in the source hub's exported events have been updated since the snapshot was created, then it will be the updated version of that content item which will be created as snapshots in the destination hub. -As such, these commands should be considered an experimental feature, and should be used at your own risk if you understand these limitations. To use the import command, you must pass the `--experimental` argument along with any others. +Snapshots can still optionally be exported to your file system by passing the `--snapshots` argument with your export command, however these will not be used during import and would only be used for reference purposes. + +The below table details what will and will not be included with the export and import commands: + +| Component | Exported with `export` command? | Imported with `import` command? | +| --------- | --------------------------------------------- | ------------------------------------------------------------ | +| Events | Yes | Yes | +| Editions | Yes | Yes | +| Slots | Yes | Yes | +| Snapshots | Yes*

(With `--snapshots` argument) | No

Snapshots are always created from the latest version of content items in the destination hub. | + +If you accept the above limitations and wish use the import command, you must pass the `--acceptSnapshotLimits` argument along with any others. ### Mapping files @@ -172,14 +185,14 @@ dc-cli event import #### Options -| Option Name | Type | Description | -| --------------- | --------- | ------------------------------------------------------------ | -| --experimental | [boolean] | Must be passed to use the event import command.
Only use this command if you fully understand its [limitations](#experimental-export--import). | -| --mapFile | [string] | Mapping file to use when updating content that already exists.
Updated with any new mappings that are generated.
If not present, will be created.
For more information, see [mapping files](#MAPPING-FILES). | -| -f
--force | [boolean] | Overwrite existing events, editions, slots and snapshots without asking. | -| --schedule | [boolean] | Schedule events in the destination repo if they are scheduled in the source.
If any new or updated scheduled events started in the past, they will be moved to happen at the time of import.
If they ended in the past, they will be skipped by default. | -| --catchup | [boolean] | Scheduling events that ended in the past will move to the current date, so that their publishes run. | -| --originalIds | [boolean] | Use original IDs. | +| Option Name | Type | Description | +| ---------------------- | --------- | ------------------------------------------------------------ | +| --acceptSnapshotLimits | [boolean] | Must be passed to use the event import command.
Only use this command if you fully understand its [limitations](#export--import-limitations). | +| --mapFile | [string] | Mapping file to use when updating content that already exists.
Updated with any new mappings that are generated.
If not present, will be created.
For more information, see [mapping files](#MAPPING-FILES). | +| -f
--force | [boolean] | Overwrite existing events, editions, slots and snapshots without asking. | +| --schedule | [boolean] | Schedule events in the destination repo if they are scheduled in the source.
If any new or updated scheduled events started in the past, they will be moved to happen at the time of import.
If they ended in the past, they will be skipped by default. | +| --catchup | [boolean] | Scheduling events that ended in the past will move to the current date, so that their publishes run. | +| --originalIds | [boolean] | Use original IDs. | #### Examples diff --git a/src/commands/event/import.spec.ts b/src/commands/event/import.spec.ts index 112c7b7f..528a50a8 100644 --- a/src/commands/event/import.spec.ts +++ b/src/commands/event/import.spec.ts @@ -56,7 +56,7 @@ describe('event import command', () => { clientSecret: 'client-id', hubId: 'hub-id', schedule: false, - experimental: true, + acceptSnapshotLimits: true, catchup: true }; @@ -81,7 +81,7 @@ describe('event import command', () => { ...config, dir: '', originalIds: false, - experimental: true, + acceptSnapshotLimits: true, logFile: log, ...customArgs } @@ -115,7 +115,7 @@ describe('event import command', () => { type: 'string' }); - expect(spyOption).toHaveBeenCalledWith('experimental', { + expect(spyOption).toHaveBeenCalledWith('acceptSnapshotLimits', { type: 'boolean', boolean: true, describe: @@ -171,7 +171,7 @@ describe('event import command', () => { await rimraf('temp/importEvent/'); }); - it('should return immediately if experimental is false', async function() { + it('should return immediately if acceptSnapshotLimits is false', async function() { const { getHubMock } = mockValues({}); const logFile = new FileLog(); @@ -180,7 +180,7 @@ describe('event import command', () => { ...config, logFile, dir: 'temp/importEvent/', - experimental: false, + acceptSnapshotLimits: false, catchup: false, originalIds: false }; diff --git a/src/commands/event/import.ts b/src/commands/event/import.ts index 7ea97325..eacbb439 100644 --- a/src/commands/event/import.ts +++ b/src/commands/event/import.ts @@ -58,7 +58,7 @@ export const builder = (yargs: Argv): void => { type: 'string' }) - .option('experimental', { + .option('acceptSnapshotLimits', { type: 'boolean', boolean: true, describe: @@ -534,11 +534,11 @@ export const trySaveMapping = async ( }; export const handler = async (argv: Arguments): Promise => { - const { dir, logFile, experimental } = argv; + const { dir, logFile, acceptSnapshotLimits } = argv; - if (!experimental) { + if (!acceptSnapshotLimits) { console.log( - 'Event import is an experimental feature, only use it if you fully understand its limitations. To use this command, pass the --experimental flag.' + 'Event import may result in a different state from the export due to snapshots of referenced content items being taken at the time of creation. Only use it if you fully understand its limitations. To use this command, pass the --acceptSnapshotLimits flag.' ); return; } diff --git a/src/commands/hub/steps/event-clone-step.spec.ts b/src/commands/hub/steps/event-clone-step.spec.ts index 17bb3943..60e07876 100644 --- a/src/commands/hub/steps/event-clone-step.spec.ts +++ b/src/commands/hub/steps/event-clone-step.spec.ts @@ -159,7 +159,7 @@ describe('event clone step', () => { dir: join(state.path, 'event'), originalIds: false, schedule: true, - experimental: true, + acceptSnapshotLimits: true, mapFile: 'mapping.json', catchup: false, logFile: state.logFile, @@ -245,7 +245,7 @@ describe('event clone step', () => { dir: join(state.path, 'oldEvent'), originalIds: true, schedule: true, - experimental: true, + acceptSnapshotLimits: true, mapFile: 'mapping.json', catchup: false, logFile: state.logFile, diff --git a/src/commands/hub/steps/event-clone-step.ts b/src/commands/hub/steps/event-clone-step.ts index e39e8559..2c181dcb 100644 --- a/src/commands/hub/steps/event-clone-step.ts +++ b/src/commands/hub/steps/event-clone-step.ts @@ -55,7 +55,7 @@ export class EventCloneStep implements CloneHubStep { mapFile: state.argv.mapFile, originalIds: false, schedule: true, - experimental: true, + acceptSnapshotLimits: true, catchup: false, ...state.to }); @@ -93,7 +93,7 @@ export class EventCloneStep implements CloneHubStep { mapFile: state.argv.mapFile, originalIds: true, schedule: true, - experimental: true, + acceptSnapshotLimits: true, catchup: false, ...state.to } diff --git a/src/interfaces/import-event-builder-options.interface.ts b/src/interfaces/import-event-builder-options.interface.ts index 89888c0b..61f6c95d 100644 --- a/src/interfaces/import-event-builder-options.interface.ts +++ b/src/interfaces/import-event-builder-options.interface.ts @@ -5,7 +5,7 @@ export interface ImportEventBuilderOptions { mapFile?: string; originalIds: boolean; schedule: boolean; - experimental: boolean; + acceptSnapshotLimits: boolean; catchup: boolean; logFile: FileLog; } From ea1ba5442df0bd6151f97231f5e8d596bf243552 Mon Sep 17 00:00:00 2001 From: Rhys Date: Mon, 9 May 2022 10:54:41 +0100 Subject: [PATCH 19/22] refactor(event): update event clone accept argument --- docs/EVENT.md | 6 ++--- src/commands/hub/clone.spec.ts | 22 +++++++++---------- src/commands/hub/clone.ts | 8 +++---- src/commands/hub/model/clone-hub-step.ts | 2 +- .../hub/steps/content-clone-step.spec.ts | 2 +- .../hub/steps/event-clone-step.spec.ts | 2 +- src/commands/hub/steps/event-clone-step.ts | 2 +- .../hub/steps/extension-clone-step.spec.ts | 2 +- .../hub/steps/index-clone-step.spec.ts | 2 +- .../hub/steps/schema-clone-step.spec.ts | 2 +- .../hub/steps/settings-clone-step.spec.ts | 2 +- .../hub/steps/type-clone-step.spec.ts | 2 +- src/interfaces/clone-hub-builder-options.ts | 2 +- 13 files changed, 28 insertions(+), 28 deletions(-) diff --git a/docs/EVENT.md b/docs/EVENT.md index 2b224503..ef116615 100644 --- a/docs/EVENT.md +++ b/docs/EVENT.md @@ -42,7 +42,7 @@ The following options are available for all **event** commands. When exporting and importing events with the DC CLI, it is important to understand that these commands have some limitations in regards to the snapshot components of events. -Events, Editions, and Slots will be exported and imported much like with the other export and import commands in the CLI. However the most granular part of an event in Dynamic Content, the Snapshot, is a representation of a content item exactly as it appears at the point when it was added to an edition using the content browser, or when it was saved to an edition in the production view. +Events, Editions, and Slots will be exported and imported much like with the other export and import commands in the CLI. However the most granular part of an event in Dynamic Content, the Snapshot, is a representation of a content item exactly as it appears at the point when it was added to an edition using the content browser, or when it was saved to an edition in the production view. Due to this nature of snapshots, the event import command will not always result in an exact copy of the events exported with the event export command. Whilst the properties of the parent edition and event will match those of the source, the snapshots will be created as new. If a snapshotted content item in the source hub's exported events have been updated since the snapshot was created, then it will be the updated version of that content item which will be created as snapshots in the destination hub. @@ -198,8 +198,8 @@ dc-cli event import ##### Import events from the filesystem -`dc-cli event import ./myDirectory/event --experimental` +`dc-cli event import ./myDirectory/event --acceptSnapshotLimits` ##### Specify a mapping file when importing -`dc-cli event import ./myDirectory/event --mapFile ./myDirectory/mappingFile.json --experimental` \ No newline at end of file +`dc-cli event import ./myDirectory/event --mapFile ./myDirectory/mappingFile.json --acceptSnapshotLimits` \ No newline at end of file diff --git a/src/commands/hub/clone.spec.ts b/src/commands/hub/clone.spec.ts index 6d3b3845..7e89bd59 100644 --- a/src/commands/hub/clone.spec.ts +++ b/src/commands/hub/clone.spec.ts @@ -32,13 +32,13 @@ function succeedOrFail(mock: any, succeed: () => boolean): jest.Mock { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -function mockStep(name: string, id: string, success: () => boolean, isExperimental?: boolean): any { +function mockStep(name: string, id: string, success: () => boolean, isLimited?: boolean): any { return jest.fn().mockImplementation(() => ({ run: succeedOrFail(jest.fn(), success), revert: succeedOrFail(jest.fn(), success), getName: jest.fn().mockReturnValue(name), getId: jest.fn().mockReturnValue(id), - isExperimental + isLimited })); } @@ -138,7 +138,7 @@ describe('hub clone command', () => { type: 'string' }); - expect(spyOption).toHaveBeenCalledWith('experimental', { + expect(spyOption).toHaveBeenCalledWith('acceptSnapshotLimits', { type: 'boolean', boolean: true, describe: @@ -250,7 +250,7 @@ describe('hub clone command', () => { clientId: 'client-id', clientSecret: 'client-id', hubId: 'hub-id', - experimental: true, + acceptSnapshotLimits: true, revertLog: Promise.resolve(undefined) }; @@ -412,14 +412,14 @@ describe('hub clone command', () => { } }); - it('should exclude experimental steps if experimental is false', async () => { + it('should exclude acceptSnapshotLimits steps if acceptSnapshotLimits is false', async () => { clearMocks(); success = [true, true, true, true, true, true, true]; const argv: Arguments = { ...yargArgs, ...config, - experimental: false, + acceptSnapshotLimits: false, dir: `temp_${process.env.JEST_WORKER_ID}/clone/steps`, @@ -445,7 +445,7 @@ describe('hub clone command', () => { mocks.forEach(mock => { const instance = mock.mock.results[0].value; - if (instance.isExperimental) { + if (instance.isLimited) { expect(instance.run).not.toHaveBeenCalled(); } else { expect(instance.run).toHaveBeenCalledWith(stepConfig); @@ -479,7 +479,7 @@ describe('hub clone command', () => { clientId: 'client-id', clientSecret: 'client-id', hubId: 'hub-id', - experimental: true + acceptSnapshotLimits: true }; beforeAll(async () => { @@ -699,7 +699,7 @@ describe('hub clone command', () => { } }); - it('should exclude experimental steps if experimental is false', async () => { + it('should exclude acceptSnapshotLimits steps if acceptSnapshotLimits is false', async () => { clearMocks(); success = [true, true, true, true, true, true, true]; await ensureDirectoryExists(`temp_${process.env.JEST_WORKER_ID}/clone-revert/`); @@ -708,7 +708,7 @@ describe('hub clone command', () => { const argv: Arguments = { ...yargArgs, ...config, - experimental: false, + acceptSnapshotLimits: false, dir: `temp_${process.env.JEST_WORKER_ID}/clone-revert/steps`, @@ -734,7 +734,7 @@ describe('hub clone command', () => { mocks.forEach(mock => { const instance = mock.mock.results[0].value; - if (instance.isExperimental) { + if (instance.isLimited) { expect(instance.revert).not.toHaveBeenCalled(); } else { expect(instance.revert).toHaveBeenCalledWith(stepConfig); diff --git a/src/commands/hub/clone.ts b/src/commands/hub/clone.ts index a44930cb..5df29383 100644 --- a/src/commands/hub/clone.ts +++ b/src/commands/hub/clone.ts @@ -61,7 +61,7 @@ export const builder = (yargs: Argv): void => { type: 'string' }) - .option('experimental', { + .option('acceptSnapshotLimits', { type: 'boolean', boolean: true, describe: @@ -171,7 +171,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 a0336c54..9c0fe1a8 100644 --- a/src/commands/hub/steps/content-clone-step.spec.ts +++ b/src/commands/hub/steps/content-clone-step.spec.ts @@ -44,7 +44,7 @@ describe('content clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), - experimental: false, + acceptSnapshotLimits: false, dir: directory, diff --git a/src/commands/hub/steps/event-clone-step.spec.ts b/src/commands/hub/steps/event-clone-step.spec.ts index 60e07876..b9e65bb8 100644 --- a/src/commands/hub/steps/event-clone-step.spec.ts +++ b/src/commands/hub/steps/event-clone-step.spec.ts @@ -89,7 +89,7 @@ describe('event clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), - experimental: true, + acceptSnapshotLimits: true, mapFile: 'mapping.json', dir: directory, diff --git a/src/commands/hub/steps/event-clone-step.ts b/src/commands/hub/steps/event-clone-step.ts index 2c181dcb..b23332b7 100644 --- a/src/commands/hub/steps/event-clone-step.ts +++ b/src/commands/hub/steps/event-clone-step.ts @@ -17,7 +17,7 @@ export class EventCloneStep implements CloneHubStep { return 'Clone Events'; } - isExperimental = true; + isLimited = true; async run(state: CloneHubState): Promise { try { diff --git a/src/commands/hub/steps/extension-clone-step.spec.ts b/src/commands/hub/steps/extension-clone-step.spec.ts index b128c18d..e4cbfd69 100644 --- a/src/commands/hub/steps/extension-clone-step.spec.ts +++ b/src/commands/hub/steps/extension-clone-step.spec.ts @@ -56,7 +56,7 @@ describe('extension clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), - experimental: false, + acceptSnapshotLimits: false, dir: directory, diff --git a/src/commands/hub/steps/index-clone-step.spec.ts b/src/commands/hub/steps/index-clone-step.spec.ts index 7e418ab1..9c5fe847 100644 --- a/src/commands/hub/steps/index-clone-step.spec.ts +++ b/src/commands/hub/steps/index-clone-step.spec.ts @@ -56,7 +56,7 @@ describe('index clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), - experimental: false, + acceptSnapshotLimits: false, 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 c1514ea9..827458a1 100644 --- a/src/commands/hub/steps/schema-clone-step.spec.ts +++ b/src/commands/hub/steps/schema-clone-step.spec.ts @@ -66,7 +66,7 @@ describe('schema clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), - experimental: false, + acceptSnapshotLimits: false, dir: directory, diff --git a/src/commands/hub/steps/settings-clone-step.spec.ts b/src/commands/hub/steps/settings-clone-step.spec.ts index 536d0b6a..2bc85c37 100644 --- a/src/commands/hub/steps/settings-clone-step.spec.ts +++ b/src/commands/hub/steps/settings-clone-step.spec.ts @@ -43,7 +43,7 @@ describe('settings clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), - experimental: false, + acceptSnapshotLimits: false, 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 d8c85b76..f7e36661 100644 --- a/src/commands/hub/steps/type-clone-step.spec.ts +++ b/src/commands/hub/steps/type-clone-step.spec.ts @@ -66,7 +66,7 @@ describe('type clone step', () => { ...yargArgs, ...config, logFile: new FileLog(), - experimental: false, + acceptSnapshotLimits: false, dir: directory, diff --git a/src/interfaces/clone-hub-builder-options.ts b/src/interfaces/clone-hub-builder-options.ts index 3e58e07f..3c2a7f36 100644 --- a/src/interfaces/clone-hub-builder-options.ts +++ b/src/interfaces/clone-hub-builder-options.ts @@ -23,5 +23,5 @@ export interface CloneHubBuilderOptions { republish?: boolean; excludeKeys?: boolean; - experimental: boolean; + acceptSnapshotLimits: boolean; } From 300354f5125211a0631a1d95bdd4b3de8393bf62 Mon Sep 17 00:00:00 2001 From: Rhys Date: Mon, 9 May 2022 12:04:59 +0100 Subject: [PATCH 20/22] docs(event): add events info to hub clone docs --- docs/HUB.md | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/HUB.md b/docs/HUB.md index 17558ccd..4d3534a2 100644 --- a/docs/HUB.md +++ b/docs/HUB.md @@ -84,6 +84,7 @@ Exports all of the following (where applicable) from the source hub, then import * Content Types * Content Items * Search Indexes +* Events (requires `--acceptSnapshotLimits` argument, due to some [limitations](EVENT.md#export--import-limitations)) ``` dc-cli hub clone @@ -91,21 +92,22 @@ dc-cli hub clone #### Options -| Option Name | Type | Description | -| ------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | -| --dstHubId | [string] | Destination hub ID.
If not specified, it will be the same as the source. | -| --dstClientId | [string] | Destination account's client ID.
If not specified, it will be the same as the source. | -| --dstSecret | [string] | Destination account's secret.
Must be used alongside dstClientId. | -| --mapFile | [string] | Mapping file to use when updating content that already exists.
Updated with any new mappings that are generated. If not present, will be created. | -| -v
--validate | [boolean] | Only recreate folder structure.
Content is validated but not imported. | -| --skipIncomplete | [boolean] | Skip any content item that has one or more missing dependancy. | -| --lastPublish | [boolean] | When available, export the last published version of a content item rather than its newest version. | -| --publish | [boolean] | Publish any content items that have an existing publish status in their JSON. | -| --republish | [boolean] | Republish content items regardless of whether the import changed them or not.
(--publish not required) | -| --excludeKeys | [boolean] | Exclude delivery keys when importing content items. | -| --media | [boolean] | Detect and rewrite media links to match assets in the target account's DAM.
Your client must have DAM permissions configured. | -| --revertLog | [string] | Revert a previous clone using a given revert log and given directory.
Reverts steps in reverse order, starting at the specified one. | -| --step | [string]
[choices: "settings", "schema", "type", "content"] | Start at a specific step.
Steps after the one you specify will also run. | +| Option Name | Type | Description | +| ---------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | +| --acceptSnapshotLimits | [boolean] | Must be passed to use the event clone step.
Only use this command if you fully understand its [limitations](EVENT.md#export--import-limitations). | +| --dstHubId | [string] | Destination hub ID.
If not specified, it will be the same as the source. | +| --dstClientId | [string] | Destination account's client ID.
If not specified, it will be the same as the source. | +| --dstSecret | [string] | Destination account's secret.
Must be used alongside dstClientId. | +| --mapFile | [string] | Mapping file to use when updating content that already exists.
Updated with any new mappings that are generated. If not present, will be created. | +| -v
--validate | [boolean] | Only recreate folder structure.
Content is validated but not imported. | +| --skipIncomplete | [boolean] | Skip any content item that has one or more missing dependancy. | +| --lastPublish | [boolean] | When available, export the last published version of a content item rather than its newest version. | +| --publish | [boolean] | Publish any content items that have an existing publish status in their JSON. | +| --republish | [boolean] | Republish content items regardless of whether the import changed them or not.
(--publish not required) | +| --excludeKeys | [boolean] | Exclude delivery keys when importing content items. | +| --media | [boolean] | Detect and rewrite media links to match assets in the target account's DAM.
Your client must have DAM permissions configured. | +| --revertLog | [string] | Revert a previous clone using a given revert log and given directory.
Reverts steps in reverse order, starting at the specified one. | +| --step | [string]
[choices: "settings", "schema", "type", "content"] | Start at a specific step.
Steps after the one you specify will also run. | #### Examples @@ -113,6 +115,10 @@ dc-cli hub clone `dc-cli hub clone ./myDirectory/hub` +##### Clone hub with events + +`dc-cli hub clone ./myDirectory/hub --acceptSnapshotLimits` + ##### Resume a hub clone from the content item step `dc-cli hub clone ./myDirectory/hub --step content` From b98e1a62bb81d7eb28180286c2610a56eeb9a2b6 Mon Sep 17 00:00:00 2001 From: Rhys Date: Mon, 16 May 2022 16:06:59 +0100 Subject: [PATCH 21/22] build(event): update dc-management-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 97b2fbe5..ca97e90b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3378,8 +3378,9 @@ "dev": true }, "dc-management-sdk-js": { - "version": "git+ssh://git@github.com/rs-amp/dc-management-sdk-js.git#39a5d1b5841daafcd7107c5a52d76043398888f3", - "from": "git+ssh://git@github.com/rs-amp/dc-management-sdk-js.git#feature/events", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/dc-management-sdk-js/-/dc-management-sdk-js-1.17.0.tgz", + "integrity": "sha512-nY7YobFSCHCQrl0+6Q7/1xqsGDRaCrsm4FRW5iOSkeJJHSOSv9M28mmKQI7DaE4tMIx+//KdJFSucdshwjjEQg==", "requires": { "axios": "^0.21.1", "url-template": "^2.0.8" diff --git a/package.json b/package.json index e1d33cf6..7c49a171 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "ajv": "^6.12.3", "axios": "^0.21.1", "chalk": "^2.4.2", - "dc-management-sdk-js": "git+ssh://git@github.com/rs-amp/dc-management-sdk-js.git#feature/events", + "dc-management-sdk-js": "^1.17.0", "lodash": "^4.17.21", "node-fetch": "^2.6.1", "promise-retry": "^2.0.1", From 49b5f42541b34ba4019523d27d33542888807ccf Mon Sep 17 00:00:00 2001 From: Rhys Date: Mon, 16 May 2022 16:22:28 +0100 Subject: [PATCH 22/22] test(event): fix jest tests --- src/commands/event/import.spec.ts | 35 ++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/commands/event/import.spec.ts b/src/commands/event/import.spec.ts index 528a50a8..a6fd64ec 100644 --- a/src/commands/event/import.spec.ts +++ b/src/commands/event/import.spec.ts @@ -24,6 +24,7 @@ import { ImportEventBuilderOptions } from '../../interfaces/import-event-builder import { loadJsonFromDirectory } from '../../services/import.service'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { dateOffset } from '../../common/import/date-helpers'; +import { ensureDirectoryExists } from '../../common/import/directory-utils'; jest.mock('../../services/dynamic-content-client-factory'); jest.mock('../../services/import.service'); @@ -164,11 +165,11 @@ describe('event import command', () => { describe('handler tests', function() { beforeAll(async () => { - await rimraf('temp/importEvent/'); + await rimraf(`temp_${process.env.JEST_WORKER_ID}/importEvent/`); }); afterAll(async () => { - await rimraf('temp/importEvent/'); + await rimraf(`temp_${process.env.JEST_WORKER_ID}/importEvent/`); }); it('should return immediately if acceptSnapshotLimits is false', async function() { @@ -179,7 +180,7 @@ describe('event import command', () => { ...yargArgs, ...config, logFile, - dir: 'temp/importEvent/', + dir: `temp_${process.env.JEST_WORKER_ID}/importEvent/`, acceptSnapshotLimits: false, catchup: false, originalIds: false @@ -213,7 +214,7 @@ describe('event import command', () => { ...yargArgs, ...config, logFile, - dir: 'temp/importEvent/', + dir: `temp_${process.env.JEST_WORKER_ID}/importEvent/`, originalIds: false }; const event = new EventWithEditions({ id: 'id-1' }); @@ -229,7 +230,10 @@ describe('event import command', () => { await handler(argv); expect(getHubMock).toHaveBeenCalledWith('hub-id'); //from returned hub - expect(loadJsonFromDirectory as jest.Mock).toHaveBeenCalledWith('temp/importEvent/', EventWithEditions); + expect(loadJsonFromDirectory as jest.Mock).toHaveBeenCalledWith( + `temp_${process.env.JEST_WORKER_ID}/importEvent/`, + EventWithEditions + ); expect(importEvents).toHaveBeenCalledWith( [event], @@ -252,8 +256,8 @@ describe('event import command', () => { ...yargArgs, ...config, logFile, - mapFile: 'temp/importEvent/importEvent.json', - dir: 'temp/importEvent/', + mapFile: `temp_${process.env.JEST_WORKER_ID}/importEvent/importEvent.json`, + dir: `temp_${process.env.JEST_WORKER_ID}/importEvent/`, originalIds: false }; const event = new EventWithEditions({ id: 'id-1' }); @@ -266,13 +270,18 @@ describe('event import command', () => { const trySaveMapping = jest.spyOn(importModule, 'trySaveMapping').mockResolvedValue(); const getDefaultMappingPath = jest.spyOn(importModule, 'getDefaultMappingPath'); + await ensureDirectoryExists(`temp_${process.env.JEST_WORKER_ID}/importEvent/`); + const existingMapping = new ContentMapping(); await existingMapping.save(argv.mapFile); await handler(argv); expect(getHubMock).toHaveBeenCalledWith('hub-id'); //from returned hub - expect(loadJsonFromDirectory as jest.Mock).toHaveBeenCalledWith('temp/importEvent/', EventWithEditions); + expect(loadJsonFromDirectory as jest.Mock).toHaveBeenCalledWith( + `temp_${process.env.JEST_WORKER_ID}/importEvent/`, + EventWithEditions + ); expect(importEvents).toHaveBeenCalledWith( [event], @@ -295,7 +304,7 @@ describe('event import command', () => { ...yargArgs, ...config, logFile, - dir: 'temp/importEvent/', + dir: `temp_${process.env.JEST_WORKER_ID}/importEvent/`, originalIds: false }; const event = new EventWithEditions({ id: 'id-1' }); @@ -311,7 +320,10 @@ describe('event import command', () => { await handler(argv); expect(getHubMock).toHaveBeenCalledWith('hub-id'); //from returned hub - expect(loadJsonFromDirectory as jest.Mock).toHaveBeenCalledWith('temp/importEvent/', EventWithEditions); + expect(loadJsonFromDirectory as jest.Mock).toHaveBeenCalledWith( + `temp_${process.env.JEST_WORKER_ID}/importEvent/`, + EventWithEditions + ); expect(importEvents).toHaveBeenCalledWith( [event], @@ -1112,6 +1124,7 @@ Array [ const newEdition = new Edition(baseEdition); newEdition.related.update = mockEdition.related.update; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (newEdition as any).client = { fetchLinkedResource: mockSlotsList }; (mockEditionGet as jest.Mock).mockResolvedValueOnce(newEdition); (mockEditionGet as jest.Mock).mockResolvedValueOnce(newEdition); @@ -1171,6 +1184,7 @@ Array [ const newEdition = new Edition(baseEdition); newEdition.related.update = mockEdition.related.update; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (newEdition as any).client = { fetchLinkedResource: mockSlotsList }; (mockEditionGet as jest.Mock).mockResolvedValueOnce(newEdition); (mockEditionUnschedule as jest.Mock).mockRejectedValue(new Error('Unschedule Failed')); @@ -1227,6 +1241,7 @@ Array [ const newEdition = new Edition(baseEdition); newEdition.related.update = mockEdition.related.update; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (newEdition as any).client = { fetchLinkedResource: mockSlotsList }; (mockEditionGet as jest.Mock).mockResolvedValueOnce(newEdition);