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..ef116615 --- /dev/null +++ b/docs/EVENT.md @@ -0,0 +1,205 @@ +# 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) + - [Export & Import limitations](#export--import-limitations) + - [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 + +### Export & Import limitations + +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. + +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 + +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 | +| ---------------------- | --------- | ------------------------------------------------------------ | +| --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 + +##### Import events from the filesystem + +`dc-cli event import ./myDirectory/event --acceptSnapshotLimits` + +##### Specify a mapping file when importing + +`dc-cli event import ./myDirectory/event --mapFile ./myDirectory/mappingFile.json --acceptSnapshotLimits` \ No newline at end of file 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` diff --git a/package-lock.json b/package-lock.json index adbd50ab..ca97e90b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3378,9 +3378,9 @@ "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": "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 b25d3750..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": "^1.14.0", + "dc-management-sdk-js": "^1.17.0", "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..00814a39 --- /dev/null +++ b/src/commands/event/__snapshots__/export.spec.ts.snap @@ -0,0 +1,468 @@ +// 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\\", + \\"id\\": \\"1\\", + \\"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\\", + \\"id\\": \\"1\\", + \\"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 [ + "{ + \\"id\\": \\"test1\\", + \\"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\\": \\"test2\\", + \\"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..be2fb229 --- /dev/null +++ b/src/commands/event/event-test-helpers.ts @@ -0,0 +1,369 @@ +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; + mockEditionUnschedule: () => void; + mockEdition: Edition; +} => { + 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(); + const mockEditionUnschedule = 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; + editions[0].related.unschedule = mockEditionUnschedule; + 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, + mockEditionUnschedule, + mockEventsList, + mockEventUpdate, + mockEventCreate, + mockSlotsList, + mockSlotContent, + mockSnapshotGet, + mockSnapshotItem, + mockSnapshotCreate, + mockEdition: editions[0] + }; +}; 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..b3bc2a1a --- /dev/null +++ b/src/commands/event/export.ts @@ -0,0 +1,385 @@ +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') { + 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..a6fd64ec --- /dev/null +++ b/src/commands/event/import.spec.ts @@ -0,0 +1,1522 @@ +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, PublishingStatus } 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'; +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'); + +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', + schedule: false, + acceptSnapshotLimits: true, + catchup: true + }; + + 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, + acceptSnapshotLimits: true, + 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('acceptSnapshotLimits', { + 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: + '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('schedule', { + type: 'boolean', + boolean: true, + describe: + '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', { + 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_${process.env.JEST_WORKER_ID}/importEvent/`); + }); + + afterAll(async () => { + await rimraf(`temp_${process.env.JEST_WORKER_ID}/importEvent/`); + }); + + it('should return immediately if acceptSnapshotLimits is false', async function() { + const { getHubMock } = mockValues({}); + + const logFile = new FileLog(); + const argv = { + ...yargArgs, + ...config, + logFile, + dir: `temp_${process.env.JEST_WORKER_ID}/importEvent/`, + acceptSnapshotLimits: false, + catchup: 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({}); + + const logFile = new FileLog(); + const argv = { + ...yargArgs, + ...config, + logFile, + dir: `temp_${process.env.JEST_WORKER_ID}/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_${process.env.JEST_WORKER_ID}/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_${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' }); + + (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'); + + 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_${process.env.JEST_WORKER_ID}/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(); + }); + + 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_${process.env.JEST_WORKER_ID}/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_${process.env.JEST_WORKER_ID}/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() { + 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'); + + await 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(); + }); + + 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() { + 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({ lastModifiedDate: 'date' }); + 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, '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({ lastModifiedDate: 'date' }); + const log = new FileLog(); + + 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); + + expect(edition.related.schedule).toHaveBeenCalledTimes(2); + expect(edition.related.schedule).toHaveBeenNthCalledWith(1, false, 'date'); + expect(edition.related.schedule).toHaveBeenNthCalledWith(2, true, 'date'); + 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({}); + + 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'); + 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() { + 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(false); + 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])); + + 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); + + 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(false); + 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(false); + 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); + }); + + 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() { + 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(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(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(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-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(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 + }) + ]; + + 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); + }); + + 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(false); + 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; + // 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); + (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(false); + 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).toHaveBeenNthCalledWith(1, 'id-2'); + expect(mockEditionGet).toHaveBeenNthCalledWith(3, '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; + // 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')); + + const { client, hub, argv, log, mapping } = await commonMock(); + + argv.schedule = true; + mapping.registerEdition('id-1', 'id-2'); + + 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(); + + 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; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (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(false); + 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(false); + 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(); + }); + + 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() { + 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(); + }); + }); +}); diff --git a/src/commands/event/import.ts b/src/commands/event/import.ts new file mode 100644 index 00000000..eacbb439 --- /dev/null +++ b/src/commands/event/import.ts @@ -0,0 +1,574 @@ +import { Arguments, Argv } from 'yargs'; +import { ConfigurationParameters } from '../configure'; +import { + DynamicContent, + Event, + Edition, + EditionSlot, + Hub, + ContentRepository, + Snapshot, + 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 { 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'; +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; +export const EventSecondsAllowance = 60; +export const ScheduleSecondsAllowance = 5; + +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('acceptSnapshotLimits', { + 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: + '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('schedule', { + type: 'boolean', + boolean: true, + describe: + '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', { + 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 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, false) + ); +}; + +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; + + let snapshotCreated = false; + + for (const dep of dependencies) { + const entry = dep.dependancy as SlotDependency; + + // 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: itemId, + 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); + snapshotCreated = true; + } + + dep.dependancy.id = snapshotId; + entry._meta.rootContentItemId = itemId; + } + + return snapshotCreated; +}; + +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); + let snapshot = false; + + 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. + snapshot = (await rewriteSnapshots(slot.content, mapping, hub, log)) || snapshot; + + 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 => + 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 = dateMax(dateOffset(EventSecondsAllowance), newDate).toISOString(); + + await event.related.update(event); + } + + return newDate.toISOString(); +}; + +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); + } +}; + +export const scheduleEdition = async (edition: Edition, log: FileLog): Promise => { + try { + 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. + + 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 (error.overlaps) { + message += ` (${error.overlaps + .map(overlap => `${overlap.name} - ${overlap.editionId} ${overlap.start}`) + .join(', ')})`; + } + + 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, edition.lastModifiedDate); + } + } else { + throw e; + } + } +}; + +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, + 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, + publishingStatus: edition.publishingStatus + }); + + 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); + + 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 { + 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; + } + } + + let createdSnapshots = false; + + if (update) { + // Attempt to create/update slots. + createdSnapshots = 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)) { + 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); + } + } +}; + +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 if (shouldUpdateEvent(realEvent, event)) { + // 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(sortByEndDate(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, acceptSnapshotLimits } = argv; + + if (!acceptSnapshotLimits) { + console.log( + '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; + } + + 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}'.`); + } + + try { + await importEvents(sortByEndDate(Object.values(events)), mapping, client, hub, argv, log); + } catch (e) { + log.error('Failed to import events.', e); + } + + 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..7e89bd59 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 { @@ -31,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, 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) + getId: jest.fn().mockReturnValue(id), + isLimited })); } @@ -64,6 +66,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[6], true) +})); + jest.mock('../../common/log-helpers', () => ({ ...jest.requireActual('../../common/log-helpers'), getDefaultLogPath: jest.fn() @@ -82,7 +88,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 ]; } @@ -131,6 +138,13 @@ describe('hub clone command', () => { type: 'string' }); + expect(spyOption).toHaveBeenCalledWith('acceptSnapshotLimits', { + 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.' @@ -236,6 +250,7 @@ describe('hub clone command', () => { clientId: 'client-id', clientSecret: 'client-id', hubId: 'hub-id', + acceptSnapshotLimits: true, revertLog: Promise.resolve(undefined) }; @@ -270,7 +285,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 +323,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 +367,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, @@ -397,6 +412,50 @@ describe('hub clone command', () => { } }); + it('should exclude acceptSnapshotLimits steps if acceptSnapshotLimits is false', async () => { + clearMocks(); + success = [true, true, true, true, true, true, true]; + + const argv: Arguments = { + ...yargArgs, + ...config, + acceptSnapshotLimits: 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.isLimited) { + 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(); @@ -419,7 +478,8 @@ describe('hub clone command', () => { const config = { clientId: 'client-id', clientSecret: 'client-id', - hubId: 'hub-id' + hubId: 'hub-id', + acceptSnapshotLimits: true }; beforeAll(async () => { @@ -464,7 +524,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 +564,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 +613,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 +650,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`); @@ -638,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 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/`); + await prepareFakeLog(`temp_${process.env.JEST_WORKER_ID}/clone-revert/steps.log`); + + const argv: Arguments = { + ...yargArgs, + ...config, + acceptSnapshotLimits: 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.isLimited) { + 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 23b88375..5df29383 100644 --- a/src/commands/hub/clone.ts +++ b/src/commands/hub/clone.ts @@ -14,6 +14,8 @@ 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'; +import { CloneHubStep } from './model/clone-hub-step'; export function getDefaultMappingPath(name: string, platform: string = process.platform): string { return join( @@ -41,13 +43,14 @@ 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(), new TypeCloneStep(), new IndexCloneStep(), - new ContentCloneStep() + new ContentCloneStep(), + new EventCloneStep() ]; export const builder = (yargs: Argv): void => { @@ -58,6 +61,13 @@ export const builder = (yargs: Argv): void => { type: 'string' }) + .option('acceptSnapshotLimits', { + 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.' @@ -161,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..9c0fe1a8 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(), + 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 new file mode 100644 index 00000000..b9e65bb8 --- /dev/null +++ b/src/commands/hub/steps/event-clone-step.spec.ts @@ -0,0 +1,277 @@ +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(), + acceptSnapshotLimits: true, + mapFile: 'mapping.json', + + 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, + schedule: true, + acceptSnapshotLimits: true, + mapFile: 'mapping.json', + catchup: 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, + schedule: true, + acceptSnapshotLimits: true, + mapFile: 'mapping.json', + catchup: false, + 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..b23332b7 --- /dev/null +++ b/src/commands/hub/steps/event-clone-step.ts @@ -0,0 +1,112 @@ +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'; + } + + isLimited = true; + + 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, + mapFile: state.argv.mapFile, + originalIds: false, + schedule: true, + acceptSnapshotLimits: true, + catchup: 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, + mapFile: state.argv.mapFile, + originalIds: true, + schedule: true, + acceptSnapshotLimits: true, + catchup: false, + ...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/commands/hub/steps/extension-clone-step.spec.ts b/src/commands/hub/steps/extension-clone-step.spec.ts index 476041fb..e4cbfd69 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(), + 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 649dbbcd..9c5fe847 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(), + 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 aeced297..827458a1 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(), + 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 d56549e5..2bc85c37 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(), + 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 96f38107..f7e36661 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(), + acceptSnapshotLimits: false, dir: directory, 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/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[]; +} 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/clone-hub-builder-options.ts b/src/interfaces/clone-hub-builder-options.ts index a71a343d..3c2a7f36 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; + acceptSnapshotLimits: boolean; } 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..61f6c95d --- /dev/null +++ b/src/interfaces/import-event-builder-options.interface.ts @@ -0,0 +1,11 @@ +import { FileLog } from '../common/file-log'; + +export interface ImportEventBuilderOptions { + dir: string; + mapFile?: string; + originalIds: boolean; + schedule: boolean; + acceptSnapshotLimits: boolean; + catchup: boolean; + logFile: FileLog; +}