From 433f88c457c5c21f96604833a2d87a8ce6d1ded3 Mon Sep 17 00:00:00 2001 From: Mark Kaylor Date: Fri, 19 Apr 2024 15:24:10 +0200 Subject: [PATCH] chore(history): refactor restore --- .../history/services/__tests__/utils.test.ts | 13 +- .../server/src/history/services/history.ts | 198 +++----------- .../server/src/history/services/utils.ts | 256 +++++++++++++++--- .../shared/contracts/history-versions.ts | 4 +- .../history/history.test.api.ts | 181 ++++++++++++- .../content-manager/history/rec.jpg | Bin 0 -> 787 bytes .../content-manager/history/strapi.jpg | Bin 0 -> 5253 bytes 7 files changed, 434 insertions(+), 218 deletions(-) create mode 100644 tests/api/core/content-manager/content-manager/history/rec.jpg create mode 100644 tests/api/core/content-manager/content-manager/history/strapi.jpg diff --git a/packages/core/content-manager/server/src/history/services/__tests__/utils.test.ts b/packages/core/content-manager/server/src/history/services/__tests__/utils.test.ts index e95d97b13a9..46af7d87eb1 100644 --- a/packages/core/content-manager/server/src/history/services/__tests__/utils.test.ts +++ b/packages/core/content-manager/server/src/history/services/__tests__/utils.test.ts @@ -1,7 +1,16 @@ -import { getSchemaAttributesDiff } from '../utils'; +import { createHistoryUtils } from '../utils'; -describe('history-version service utils', () => { +const baseStrapiMock = { + plugin: jest.fn(() => {}), +}; + +describe('History utils', () => { describe('getSchemaAttributesDiff', () => { + const { getSchemaAttributesDiff } = createHistoryUtils({ + // @ts-expect-error ignore + strapi: baseStrapiMock, + }); + it('should return a diff', () => { const versionSchema = { title: { diff --git a/packages/core/content-manager/server/src/history/services/history.ts b/packages/core/content-manager/server/src/history/services/history.ts index 75ba2cc3093..218cfdd2cad 100644 --- a/packages/core/content-manager/server/src/history/services/history.ts +++ b/packages/core/content-manager/server/src/history/services/history.ts @@ -1,5 +1,5 @@ -import type { Core, Modules, UID, Data, Schema, Struct } from '@strapi/types'; -import { contentTypes, errors } from '@strapi/utils'; +import type { Core, Modules, Data, Schema, Struct } from '@strapi/types'; +import { async, errors } from '@strapi/utils'; import { omit, pick } from 'lodash/fp'; import { scheduleJob } from 'node-schedule'; @@ -10,14 +10,12 @@ import { CreateHistoryVersion, HistoryVersionDataResponse, } from '../../../../shared/contracts/history-versions'; -import { getSchemaAttributesDiff } from './utils'; +import { createHistoryUtils } from './utils'; // Needed because the query engine doesn't return any types yet type HistoryVersionQueryResult = Omit & Pick; -const DEFAULT_RETENTION_DAYS = 90; - const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => { const state: { deleteExpiredJob: ReturnType | null; @@ -28,104 +26,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => { }; const query = strapi.db.query(HISTORY_VERSION_UID); - - const getRetentionDays = (strapi: Core.Strapi) => { - const featureConfig = strapi.ee.features.get('cms-content-history'); - - const licenseRetentionDays = - typeof featureConfig === 'object' && featureConfig?.options.retentionDays; - - const userRetentionDays: number = strapi.config.get('admin.history.retentionDays'); - - // Allow users to override the license retention days, but not to increase it - if (userRetentionDays && userRetentionDays < licenseRetentionDays) { - return userRetentionDays; - } - - // User didn't provide retention days value, use the license or fallback to default - return Math.min(licenseRetentionDays, DEFAULT_RETENTION_DAYS); - }; - - const localesService = strapi.plugin('i18n')?.service('locales'); - - const getDefaultLocale = async () => (localesService ? localesService.getDefaultLocale() : null); - - const getLocaleDictionary = async () => { - if (!localesService) return {}; - - const locales = (await localesService.find()) || []; - return locales.reduce( - ( - acc: Record>, - locale: NonNullable - ) => { - acc[locale.code] = { name: locale.name, code: locale.code }; - - return acc; - }, - {} - ); - }; - - const getVersionStatus = async ( - contentTypeUid: HistoryVersions.CreateHistoryVersion['contentType'], - document: Modules.Documents.AnyDocument | null - ) => { - const documentMetadataService = strapi.plugin('content-manager').service('document-metadata'); - const meta = await documentMetadataService.getMetadata(contentTypeUid, document); - - return documentMetadataService.getStatus(document, meta.availableStatus); - }; - - /** - * Creates a populate object that looks for all the relations that need - * to be saved in history, and populates only the fields needed to later retrieve the content. - */ - const getDeepPopulate = (uid: UID.Schema) => { - const model = strapi.getModel(uid); - const attributes = Object.entries(model.attributes); - - return attributes.reduce((acc: any, [attributeName, attribute]) => { - switch (attribute.type) { - case 'relation': { - const isVisible = contentTypes.isVisibleAttribute(model, attributeName); - if (isVisible) { - acc[attributeName] = { fields: ['documentId', 'locale', 'publishedAt'] }; - } - break; - } - - case 'media': { - acc[attributeName] = { fields: ['id'] }; - break; - } - - case 'component': { - const populate = getDeepPopulate(attribute.component); - acc[attributeName] = { populate }; - break; - } - - case 'dynamiczone': { - // Use fragments to populate the dynamic zone components - const populatedComponents = (attribute.components || []).reduce( - (acc: any, componentUID: UID.Component) => { - acc[componentUID] = { populate: getDeepPopulate(componentUID) }; - return acc; - }, - {} - ); - - acc[attributeName] = { on: populatedComponents }; - break; - } - default: - break; - } - - return acc; - }, {}); - }; + const historyUtils = createHistoryUtils({ strapi }); return { async bootstrap() { @@ -166,15 +67,15 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => { ? { documentId: result.documentId, locale: context.params?.locale } : { documentId: context.params.documentId, locale: context.params?.locale }; - const defaultLocale = await getDefaultLocale(); + const defaultLocale = await historyUtils.getDefaultLocale(); const locale = documentContext.locale || defaultLocale; const document = await strapi.documents(contentTypeUid).findOne({ documentId: documentContext.documentId, locale, - populate: getDeepPopulate(contentTypeUid), + populate: historyUtils.getDeepPopulate(contentTypeUid), }); - const status = await getVersionStatus(contentTypeUid, document); + const status = await historyUtils.getVersionStatus(contentTypeUid, document); /** * Store schema of both the fields and the fields of the attributes, as it will let us know @@ -204,7 +105,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => { onCommit(() => { this.createVersion({ contentType: contentTypeUid, - data: omit(FIELDS_TO_IGNORE, document), + data: omit(FIELDS_TO_IGNORE, document) as Modules.Documents.AnyDocument, schema: omit(FIELDS_TO_IGNORE, attributesSchema), componentsSchemas, relatedDocumentId: documentContext.documentId, @@ -217,7 +118,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => { return result; }); - const retentionDays = getRetentionDays(strapi); + const retentionDays = historyUtils.getRetentionDays(); // Schedule a job to delete expired history versions every day at midnight state.deleteExpiredJob = scheduleJob('0 0 * * *', () => { const retentionDaysInMilliseconds = retentionDays * 24 * 60 * 60 * 1000; @@ -255,7 +156,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => { results: HistoryVersions.HistoryVersionDataResponse[]; pagination: HistoryVersions.Pagination; }> { - const locale = params.locale || (await getDefaultLocale()); + const locale = params.locale || (await historyUtils.getDefaultLocale()); const [{ results, pagination }, localeDictionary] = await Promise.all([ query.findPage({ ...params, @@ -269,7 +170,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => { populate: ['createdBy'], orderBy: [{ createdAt: 'desc' }], }), - getLocaleDictionary(), + historyUtils.getLocaleDictionary(), ]); type EntryToPopulate = @@ -330,7 +231,10 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => { ...relatedEntry, ...(isNormalRelation ? { - status: await getVersionStatus(attributeSchema.target, relatedEntry), + status: await historyUtils.getVersionStatus( + attributeSchema.target, + relatedEntry + ), } : {}), }); @@ -385,7 +289,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => { ...result, data: await populateEntryRelations(result), meta: { - unknownAttributes: getSchemaAttributesDiff( + unknownAttributes: historyUtils.getSchemaAttributesDiff( result.schema, strapi.getModel(params.contentType).attributes ), @@ -407,7 +311,10 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => { async restoreVersion(versionId: Data.ID) { const version = await query.findOne({ where: { id: versionId } }); const contentTypeSchemaAttributes = strapi.getModel(version.contentType).attributes; - const schemaDiff = getSchemaAttributesDiff(version.schema, contentTypeSchemaAttributes); + const schemaDiff = historyUtils.getSchemaAttributesDiff( + version.schema, + contentTypeSchemaAttributes + ); // Set all added attribute values to null const dataWithoutAddedAttributes = Object.keys(schemaDiff.added).reduce( @@ -422,16 +329,16 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => { FIELDS_TO_IGNORE, contentTypeSchemaAttributes ) as Struct.SchemaAttributes; + // Set all deleted relation values to null - const dataWithoutMissingRelations = await Object.entries(sanitizedSchemaAttributes).reduce( + const reducer = async.reduce(Object.entries(sanitizedSchemaAttributes)); + const dataWithoutMissingRelations = await reducer( async ( - previousRelationAttributesPromise: Promise>, + previousRelationAttributes: Record, [name, attribute]: [string, Schema.Attribute.AnyAttribute] ) => { - const previousRelationAttributes = await previousRelationAttributesPromise; - - const relationData = version.data[name]; - if (relationData === null) { + const versionRelationData = version.data[name]; + if (!versionRelationData) { return previousRelationAttributes; } @@ -441,62 +348,19 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => { attribute.relation !== 'morphToOne' && attribute.relation !== 'morphToMany' ) { - if (Array.isArray(relationData)) { - if (relationData.length === 0) return previousRelationAttributes; - - const existingAndMissingRelations = await Promise.all( - relationData.map((relation) => { - return strapi.documents(attribute.target).findOne({ - documentId: relation.documentId, - locale: relation.locale || undefined, - }); - }) - ); - const existingRelations = existingAndMissingRelations.filter( - (relation) => relation !== null - ) as Modules.Documents.AnyDocument[]; - - previousRelationAttributes[name] = existingRelations; - } else { - const existingRelation = await strapi.documents(attribute.target).findOne({ - documentId: relationData.documentId, - locale: relationData.locale || undefined, - }); - - if (!existingRelation) { - previousRelationAttributes[name] = null; - } - } + const data = await historyUtils.getRelationRestoreValue(versionRelationData, attribute); + previousRelationAttributes[name] = data; } if (attribute.type === 'media') { - if (attribute.multiple) { - const existingAndMissingMedias = await Promise.all( - // @ts-expect-error Fix the type definitions so this isn't any - relationData.map((media) => { - return strapi.db - .query('plugin::upload.file') - .findOne({ where: { id: media.id } }); - }) - ); - - const existingMedias = existingAndMissingMedias.filter((media) => media != null); - previousRelationAttributes[name] = existingMedias; - } else { - const existingMedia = await strapi.db - .query('plugin::upload.file') - .findOne({ where: { id: version.data[name].id } }); - - if (!existingMedia) { - previousRelationAttributes[name] = null; - } - } + const data = await historyUtils.getMediaRestoreValue(versionRelationData, attribute); + previousRelationAttributes[name] = data; } return previousRelationAttributes; }, // Clone to avoid mutating the original version data - Promise.resolve(structuredClone(dataWithoutAddedAttributes)) + structuredClone(dataWithoutAddedAttributes) ); const data = omit(['id', ...Object.keys(schemaDiff.removed)], dataWithoutMissingRelations); diff --git a/packages/core/content-manager/server/src/history/services/utils.ts b/packages/core/content-manager/server/src/history/services/utils.ts index 89ce2c334b6..2bf25094cb9 100644 --- a/packages/core/content-manager/server/src/history/services/utils.ts +++ b/packages/core/content-manager/server/src/history/services/utils.ts @@ -1,42 +1,234 @@ import { difference, omit } from 'lodash/fp'; -import type { Struct } from '@strapi/types'; +import type { Struct, UID } from '@strapi/types'; +import { Core, Data, Modules, Schema } from '@strapi/types'; +import { contentTypes } from '@strapi/utils'; import { CreateHistoryVersion } from '../../../../shared/contracts/history-versions'; import { FIELDS_TO_IGNORE } from '../constants'; +import { HistoryVersions } from '../../../../shared/contracts'; -/** - * @description - * Get the difference between the version schema and the content type schema - * @returns the attributes with their original shape - */ -export const getSchemaAttributesDiff = ( - versionSchemaAttributes: CreateHistoryVersion['schema'], - contentTypeSchemaAttributes: Struct.SchemaAttributes -) => { - // Omit the same fields that were omitted when creating a history version - const sanitizedContentTypeSchemaAttributes = omit(FIELDS_TO_IGNORE, contentTypeSchemaAttributes); - - const reduceDifferenceToAttributesObject = ( - diffKeys: string[], - source: CreateHistoryVersion['schema'] +const DEFAULT_RETENTION_DAYS = 90; + +export const createHistoryUtils = ({ strapi }: { strapi: Core.Strapi }) => { + /** + * @description + * Get the difference between the version schema and the content type schema + */ + const getSchemaAttributesDiff = ( + versionSchemaAttributes: CreateHistoryVersion['schema'], + contentTypeSchemaAttributes: Struct.SchemaAttributes ) => { - return diffKeys.reduce((previousAttributesObject, diffKey) => { - previousAttributesObject[diffKey] = source[diffKey]; + // Omit the same fields that were omitted when creating a history version + const sanitizedContentTypeSchemaAttributes = omit( + FIELDS_TO_IGNORE, + contentTypeSchemaAttributes + ); - return previousAttributesObject; - }, {}); + const reduceDifferenceToAttributesObject = ( + diffKeys: string[], + source: CreateHistoryVersion['schema'] + ) => { + return diffKeys.reduce( + (previousAttributesObject, diffKey) => { + previousAttributesObject[diffKey] = source[diffKey]; + + return previousAttributesObject; + }, + {} + ); + }; + + const versionSchemaKeys = Object.keys(versionSchemaAttributes); + const contentTypeSchemaAttributesKeys = Object.keys(sanitizedContentTypeSchemaAttributes); + // The attribute is new if it's on the content type schema but not on the version schema + const uniqueToContentType = difference(contentTypeSchemaAttributesKeys, versionSchemaKeys); + const added = reduceDifferenceToAttributesObject( + uniqueToContentType, + sanitizedContentTypeSchemaAttributes + ); + // The attribute was removed or renamed if it's on the version schema but not on the content type schema + const uniqueToVersion = difference(versionSchemaKeys, contentTypeSchemaAttributesKeys); + const removed = reduceDifferenceToAttributesObject(uniqueToVersion, versionSchemaAttributes); + + return { added, removed }; + }; + + /** + * @description + * Gets the value to set for a relation when restoring a document + */ + const getRelationRestoreValue = async ( + versionRelationData: Data.Entity, + attribute: Schema.Attribute.RelationWithTarget + ) => { + if (Array.isArray(versionRelationData)) { + if (versionRelationData.length === 0) return versionRelationData; + + const existingAndMissingRelations = await Promise.all( + versionRelationData.map((relation) => { + return strapi.documents(attribute.target).findOne({ + documentId: relation.documentId, + locale: relation.locale || undefined, + }); + }) + ); + const existingRelations = existingAndMissingRelations.filter( + (relation) => relation !== null + ) as Modules.Documents.AnyDocument[]; + + return existingRelations; + } + const existingRelationOrNull = await strapi.documents(attribute.target).findOne({ + documentId: versionRelationData.documentId, + locale: versionRelationData.locale || undefined, + }); + + return existingRelationOrNull; + }; + + /** + * @description + * Gets the value to set for a media asset when restoring a document + */ + const getMediaRestoreValue = async ( + versionRelationData: Data.Entity, + attribute: Schema.Attribute.Media + ) => { + if (attribute.multiple) { + const existingAndMissingMedias = await Promise.all( + // @ts-expect-error Fix the type definitions so this isn't any + versionRelationData.map((media) => { + return strapi.db.query('plugin::upload.file').findOne({ where: { id: media.id } }); + }) + ); + + const existingMedias = existingAndMissingMedias.filter((media) => media != null); + return existingMedias; + } + + const existingMediaOrNull = await strapi.db + .query('plugin::upload.file') + .findOne({ where: { id: versionRelationData.id } }); + + return existingMediaOrNull; + }; + + const localesService = strapi.plugin('i18n')?.service('locales'); + + const getDefaultLocale = async () => (localesService ? localesService.getDefaultLocale() : null); + + /** + * + * @description + * Creates a dictionary of all locales available + */ + const getLocaleDictionary = async (): Promise<{ + [key: string]: { name: string; code: string }; + }> => { + if (!localesService) return {}; + + const locales = (await localesService.find()) || []; + return locales.reduce( + ( + acc: Record>, + locale: NonNullable + ) => { + acc[locale.code] = { name: locale.name, code: locale.code }; + + return acc; + }, + {} + ); + }; + + /** + * + * @description + * Gets the number of retention days defined on the license or configured by the user + */ + const getRetentionDays = () => { + const featureConfig = strapi.ee.features.get('cms-content-history'); + const licenseRetentionDays = + typeof featureConfig === 'object' && featureConfig?.options.retentionDays; + const userRetentionDays: number = strapi.config.get('admin.history.retentionDays'); + + // Allow users to override the license retention days, but not to increase it + if (userRetentionDays && userRetentionDays < licenseRetentionDays) { + return userRetentionDays; + } + + // User didn't provide retention days value, use the license or fallback to default + return Math.min(licenseRetentionDays, DEFAULT_RETENTION_DAYS); + }; + + const getVersionStatus = async ( + contentTypeUid: HistoryVersions.CreateHistoryVersion['contentType'], + document: Modules.Documents.AnyDocument | null + ) => { + const documentMetadataService = strapi.plugin('content-manager').service('document-metadata'); + const meta = await documentMetadataService.getMetadata(contentTypeUid, document); + + return documentMetadataService.getStatus(document, meta.availableStatus); }; - const versionSchemaKeys = Object.keys(versionSchemaAttributes); - const contentTypeSchemaAttributesKeys = Object.keys(sanitizedContentTypeSchemaAttributes); - // The attribute is new if it's on the content type schema but not on the version schema - const uniqueToContentType = difference(contentTypeSchemaAttributesKeys, versionSchemaKeys); - const added = reduceDifferenceToAttributesObject( - uniqueToContentType, - sanitizedContentTypeSchemaAttributes - ); - // The attribute was removed or renamed if it's on the version schema but not on the content type schema - const uniqueToVersion = difference(versionSchemaKeys, contentTypeSchemaAttributesKeys); - const removed = reduceDifferenceToAttributesObject(uniqueToVersion, versionSchemaAttributes); + /** + * @description + * Creates a populate object that looks for all the relations that need + * to be saved in history, and populates only the fields needed to later retrieve the content. + */ + const getDeepPopulate = (uid: UID.Schema) => { + const model = strapi.getModel(uid); + const attributes = Object.entries(model.attributes); + + return attributes.reduce((acc: any, [attributeName, attribute]) => { + switch (attribute.type) { + case 'relation': { + const isVisible = contentTypes.isVisibleAttribute(model, attributeName); + if (isVisible) { + acc[attributeName] = { fields: ['documentId', 'locale', 'publishedAt'] }; + } + break; + } + + case 'media': { + acc[attributeName] = { fields: ['id'] }; + break; + } + + case 'component': { + const populate = getDeepPopulate(attribute.component); + acc[attributeName] = { populate }; + break; + } + + case 'dynamiczone': { + // Use fragments to populate the dynamic zone components + const populatedComponents = (attribute.components || []).reduce( + (acc: any, componentUID: UID.Component) => { + acc[componentUID] = { populate: getDeepPopulate(componentUID) }; + return acc; + }, + {} + ); - return { added, removed }; + acc[attributeName] = { on: populatedComponents }; + break; + } + default: + break; + } + + return acc; + }, {}); + }; + + return { + getSchemaAttributesDiff, + getRelationRestoreValue, + getMediaRestoreValue, + getDefaultLocale, + getLocaleDictionary, + getRetentionDays, + getVersionStatus, + getDeepPopulate, + }; }; diff --git a/packages/core/content-manager/shared/contracts/history-versions.ts b/packages/core/content-manager/shared/contracts/history-versions.ts index e724e88a1c4..a843dbecdaf 100644 --- a/packages/core/content-manager/shared/contracts/history-versions.ts +++ b/packages/core/content-manager/shared/contracts/history-versions.ts @@ -1,4 +1,4 @@ -import type { Data, Struct, UID } from '@strapi/types'; +import type { Data, Modules, Struct, UID } from '@strapi/types'; import { type errors } from '@strapi/utils'; /** @@ -11,7 +11,7 @@ export interface CreateHistoryVersion { relatedDocumentId: Data.ID; locale: string | null; status: 'draft' | 'published' | 'modified' | null; - data: Record; + data: Modules.Documents.AnyDocument; schema: Struct.SchemaAttributes; componentsSchemas: Record<`${string}.${string}`, Struct.SchemaAttributes>; } diff --git a/tests/api/core/content-manager/content-manager/history/history.test.api.ts b/tests/api/core/content-manager/content-manager/history/history.test.api.ts index da4deff6076..8182854a9e2 100644 --- a/tests/api/core/content-manager/content-manager/history/history.test.api.ts +++ b/tests/api/core/content-manager/content-manager/history/history.test.api.ts @@ -2,9 +2,35 @@ import { createStrapiInstance } from 'api-tests/strapi'; import { createAuthRequest } from 'api-tests/request'; import { createUtils, describeOnCondition } from 'api-tests/utils'; import { createTestBuilder } from 'api-tests/builder'; +import fs from 'fs'; +import path from 'path'; const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE'; +const relationUid = 'api::tag.tag'; +const relationModel = { + draftAndPublish: true, + singularName: 'tag', + pluralName: 'tags', + displayName: 'Tag', + kind: 'collectionType', + pluginOptions: { + i18n: { + localized: true, + }, + }, + attributes: { + name: { + type: 'string', + pluginOptions: { + i18n: { + localized: true, + }, + }, + }, + }, +}; + const collectionTypeUid = 'api::product.product'; const collectionTypeModel = { draftAndPublish: true, @@ -34,6 +60,36 @@ const collectionTypeModel = { }, }, }, + tags_one_to_one: { + type: 'relation', + relation: 'oneToOne', + target: relationUid, + targetAttribute: 'product', + }, + tags_one_to_many: { + type: 'relation', + relation: 'oneToMany', + target: relationUid, + targetAttribute: 'tag_one_to_many', + }, + image: { + type: 'media', + multiple: false, + pluginOptions: { + i18n: { + localized: true, + }, + }, + }, + images: { + type: 'media', + multiple: true, + pluginOptions: { + i18n: { + localized: true, + }, + }, + }, }, }; @@ -86,6 +142,7 @@ describeOnCondition(edition === 'EE')('History API', () => { let rq; let collectionTypeDocumentId; let singleTypeDocumentId; + let relations; const createEntry = async ({ uid, data, isCollectionType = true }: CreateEntryArgs) => { const type = isCollectionType ? 'collection-types' : 'single-types'; @@ -113,6 +170,21 @@ describeOnCondition(edition === 'EE')('History API', () => { return body; }; + const uploadFiles = async () => { + const res = await rq({ + method: 'POST', + url: '/upload', + formData: { + files: [ + fs.createReadStream(path.join(__dirname, 'rec.jpg')), + fs.createReadStream(path.join(__dirname, 'strapi.jpg')), + ], + }, + }); + + return res.body; + }; + const createUserAndReq = async ( userName: string, permissions: { action: string; subject: string }[] @@ -139,7 +211,7 @@ describeOnCondition(edition === 'EE')('History API', () => { }; beforeAll(async () => { - await builder.addContentTypes([collectionTypeModel, singleTypeModel]).build(); + await builder.addContentTypes([relationModel, collectionTypeModel, singleTypeModel]).build(); strapi = await createStrapiInstance(); rq = await createAuthRequest({ strapi }); @@ -148,16 +220,46 @@ describeOnCondition(edition === 'EE')('History API', () => { const localeService = strapi.plugin('i18n').service('locales'); await localeService.create({ code: 'fr', name: 'French' }); + // Create the relations to be added to versions + relations = await Promise.all([ + createEntry({ + uid: relationUid, + data: { + name: 'Tag 1', + }, + }), + createEntry({ + uid: relationUid, + data: { + name: 'Tag 2', + }, + }), + createEntry({ + uid: relationUid, + data: { + name: 'Tag 3', + }, + }), + ]); + const relationIds = relations.map((relation) => relation.data.documentId); + + // Upload media assets to be added to versions + const [imageA, imageB] = await uploadFiles(); // Create a collection type to create an initial history version - const collectionType = await createEntry({ + const collectionTypeEntry = await createEntry({ uid: collectionTypeUid, data: { name: 'Product 1', + tags_one_to_one: relationIds[0], + tags_one_to_many: relationIds, + + image: imageA.id, + images: [imageA.id, imageB.id], }, }); // Update the single type to create an initial history version - const singleType = await updateEntry({ + const singleTypeEntry = await updateEntry({ uid: singleTypeUid, data: { title: 'Welcome', @@ -165,8 +267,8 @@ describeOnCondition(edition === 'EE')('History API', () => { isCollectionType: false, }); // Set the documentIds to test - collectionTypeDocumentId = collectionType.data.documentId; - singleTypeDocumentId = singleType.data.documentId; + collectionTypeDocumentId = collectionTypeEntry.data.documentId; + singleTypeDocumentId = singleTypeEntry.data.documentId; // Update to create history versions for entries in different locales await Promise.all([ @@ -225,7 +327,7 @@ describeOnCondition(edition === 'EE')('History API', () => { }); describe('Find many history versions', () => { - test('A collection type throws with invalid query params', async () => { + test('Throws with invalid query params for a collection type', async () => { const noDocumentId = await rq({ method: 'GET', url: `/content-manager/history-versions/?contentType=${collectionTypeUid}`, @@ -240,7 +342,7 @@ describeOnCondition(edition === 'EE')('History API', () => { expect(noContentTypeUid.statusCode).toBe(403); }); - test('A single type throws with invalid query params', async () => { + test('Throws with invalid query params for a single type', async () => { const singleTypeNoContentTypeUid = await rq({ method: 'GET', url: `/content-manager/history-versions/`, @@ -259,7 +361,7 @@ describeOnCondition(edition === 'EE')('History API', () => { expect(res.statusCode).toBe(403); }); - test('A collection type finds many versions in the default locale', async () => { + test('Finds many versions in the default locale for a collection type', async () => { const collectionType = await rq({ method: 'GET', url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}`, @@ -279,7 +381,7 @@ describeOnCondition(edition === 'EE')('History API', () => { }); }); - test('A collection type finds many versions in the provided locale', async () => { + test('Finds many versions in the provided locale for a collection type', async () => { const collectionType = await rq({ method: 'GET', url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}&locale=fr`, @@ -299,7 +401,7 @@ describeOnCondition(edition === 'EE')('History API', () => { }); }); - test('A single type finds many versions in the default locale', async () => { + test('Finds many versions in the default locale for a single type', async () => { const singleType = await rq({ method: 'GET', url: `/content-manager/history-versions/?contentType=${singleTypeUid}`, @@ -319,7 +421,7 @@ describeOnCondition(edition === 'EE')('History API', () => { }); }); - test('A single type finds many versions in the provided locale', async () => { + test('Finds many versions in the provided locale for a single type', async () => { const singleType = await rq({ method: 'GET', url: `/content-manager/history-versions/?contentType=${singleTypeUid}&locale=fr`, @@ -339,7 +441,7 @@ describeOnCondition(edition === 'EE')('History API', () => { }); }); - test('Applies pagination params', async () => { + test('Finds many versions with pagination params', async () => { const collectionType = await rq({ method: 'GET', url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}&page=1&pageSize=1`, @@ -380,7 +482,7 @@ describeOnCondition(edition === 'EE')('History API', () => { ]); const res = await restrictedRq({ method: 'PUT', - url: `/content-manager/history-versions/1/restore`, + url: `/content-manager/history-versions/4/restore`, body: { contentType: collectionTypeUid, }, @@ -404,7 +506,8 @@ describeOnCondition(edition === 'EE')('History API', () => { await rq({ method: 'PUT', - url: `/content-manager/history-versions/1/restore`, + // The 4th history version created was the first collectionType english version + url: `/content-manager/history-versions/4/restore`, body: { contentType: collectionTypeUid, }, @@ -425,7 +528,8 @@ describeOnCondition(edition === 'EE')('History API', () => { await rq({ method: 'PUT', - url: `/content-manager/history-versions/4/restore`, + // The 7th history version created was the first collectionType french version + url: `/content-manager/history-versions/7/restore`, body: { contentType: collectionTypeUid, }, @@ -438,5 +542,52 @@ describeOnCondition(edition === 'EE')('History API', () => { expect(currentDocument.description).toBe('Coucou'); expect(restoredDocument.description).toBe(null); }); + + test('Restores a history version with missing relations', async () => { + // All versions of the document were created with relations + const currentDocument = await strapi.documents(collectionTypeUid).findOne({ + documentId: collectionTypeDocumentId, + populate: ['tags_one_to_one', 'tags_one_to_many'], + }); + + // Delete a relation + await strapi.documents(relationUid).delete({ documentId: relations[0].data.documentId }); + + // Restore the version containing the deleted relation + const restoredDocument = await strapi.documents(collectionTypeUid).findOne({ + documentId: collectionTypeDocumentId, + populate: ['tags_one_to_one', 'tags_one_to_many', 'image'], + }); + + expect(currentDocument['tags_one_to_one']).not.toBe(null); + expect(currentDocument['tags_one_to_many']).toHaveLength(3); + expect(restoredDocument['tags_one_to_one']).toBe(null); + expect(restoredDocument['tags_one_to_many']).toHaveLength(2); + }); + + test('Restores a history version with missing media assets', async () => { + // All versions of the document were created with media + const currentDocument = await strapi.documents(collectionTypeUid).findOne({ + documentId: collectionTypeDocumentId, + populate: ['image', 'images'], + }); + + // Delete the asset + await rq({ + method: 'DELETE', + url: `/upload/files/1`, + }); + + // Restore the version containing the deleted media + const restoredDocument = await strapi.documents(collectionTypeUid).findOne({ + documentId: collectionTypeDocumentId, + populate: ['image', 'images'], + }); + + expect(currentDocument['image']).not.toBe(null); + expect(currentDocument['images']).toHaveLength(2); + expect(restoredDocument['image']).toBe(null); + expect(restoredDocument['images']).toHaveLength(1); + }); }); }); diff --git a/tests/api/core/content-manager/content-manager/history/rec.jpg b/tests/api/core/content-manager/content-manager/history/rec.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e402224d6d04ed5bee05c9af55d754c490037d65 GIT binary patch literal 787 zcmex=LK$;OGwtxvPE3$wY!3HV(|CYfbAS1sdzc?emK*3ngfWgAa)0YKg8W4cl zs$izT71^Gf{S2E}UN&&fc=N-l?*9P>K@J8H1`%dPB?cxzMrJ|A|3?_)fp)Sof&o|? zkYHqDW?^Mx=iubx1}fMpz`(@F%*@2X%*qO~hOrhX&%h$cDx_%W$R-?^$gWfnAuRebI{N?Mn?>~P20{IIVo)B*V zNr=zT{3QtV7ZVE$GYdP&UyMxUAdd^Ouqqm|2{{I`Cl(4T88vc8f2KE_o9%~}YXK;@p{B@hbnL!2&3}j>sTnr2hTw+{+Af_Y+1WI~J zz@P^TR)E0~5V!*e|AB@uDygVgm{=s3m?S)#(=g{5$>9H8V6tTTe~W>KnGu+nm<1W^ S8J@jZ)Wx6y#Q!fKm^T3@zAuIV literal 0 HcmV?d00001 diff --git a/tests/api/core/content-manager/content-manager/history/strapi.jpg b/tests/api/core/content-manager/content-manager/history/strapi.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3ae7e8de07bf8495dc0a9356c826a02a8c3825e6 GIT binary patch literal 5253 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 literal 0 HcmV?d00001