chore(history): add api tests (#20157)

This commit is contained in:
markkaylor 2024-04-23 10:50:47 +02:00 committed by GitHub
parent 7431ba9b38
commit bdaafbbb3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 495 additions and 231 deletions

View File

@ -45,18 +45,18 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
const mainFieldValue = version.data[mainField];
const getBackLink = (): To => {
const getNextNavigation = (): To => {
const pluginsQueryParams = stringify({ plugins: query.plugins }, { encode: false });
if (collectionType === COLLECTION_TYPES) {
return {
pathname: `../${collectionType}/${version.contentType}/${version.relatedDocumentId}`,
pathname: `/content-manager/${collectionType}/${version.contentType}/${version.relatedDocumentId}`,
search: pluginsQueryParams,
};
}
return {
pathname: `../${collectionType}/${version.contentType}`,
pathname: `/content-manager/${collectionType}/${version.contentType}`,
search: pluginsQueryParams,
};
};
@ -64,16 +64,17 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
const handleRestore = async () => {
try {
const response = await restoreVersion({
documentId: version.relatedDocumentId,
collectionType,
params: {
versionId: version.id,
documentId: version.relatedDocumentId,
contentType: version.contentType,
},
body: { contentType: version.contentType },
});
if ('data' in response) {
navigate(`/content-manager/${collectionType}/${slug}/${response.data.data?.documentId}`);
navigate(getNextNavigation());
toggleNotification({
type: 'success',
@ -137,7 +138,7 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
startIcon={<ArrowLeft />}
as={NavLink}
// @ts-expect-error - types are not inferred correctly through the as prop.
to={getBackLink()}
to={getNextNavigation()}
>
{formatMessage({
id: 'global.back',

View File

@ -80,7 +80,7 @@ describe('VersionHeader', () => {
const backLink = screen.getByRole('link', { name: 'Back' });
expect(backLink).toHaveAttribute(
'href',
'/collection-types/api::kitchensink.kitchensink/pcwmq3rlmp5w0be3cuplhnpr'
'/content-manager/collection-types/api::kitchensink.kitchensink/pcwmq3rlmp5w0be3cuplhnpr'
);
});
@ -111,7 +111,7 @@ describe('VersionHeader', () => {
const backLink = screen.getByRole('link', { name: 'Back' });
expect(backLink).toHaveAttribute(
'href',
'/collection-types/api::kitchensink.kitchensink/pcwmq3rlmp5w0be3cuplhnpr?plugins[i18n][locale]=en'
'/content-manager/collection-types/api::kitchensink.kitchensink/pcwmq3rlmp5w0be3cuplhnpr?plugins[i18n][locale]=en'
);
});
@ -158,7 +158,10 @@ describe('VersionHeader', () => {
expect(await screen.findByText('Test Title (homepage)')).toBeInTheDocument();
const backLink = screen.getByRole('link', { name: 'Back' });
expect(backLink).toHaveAttribute('href', '/single-types/api::homepage.homepage');
expect(backLink).toHaveAttribute(
'href',
'/content-manager/single-types/api::homepage.homepage'
);
});
it('should display the correct title and subtitle for a localized entry', async () => {
@ -185,7 +188,7 @@ describe('VersionHeader', () => {
const backLink = screen.getByRole('link', { name: 'Back' });
expect(backLink).toHaveAttribute(
'href',
'/single-types/api::homepage.homepage?plugins[i18n][locale]=en'
'/content-manager/single-types/api::homepage.homepage?plugins[i18n][locale]=en'
);
});
});

View File

@ -1,9 +1,17 @@
import { Data } from '@strapi/types';
import {
GetHistoryVersions,
RestoreHistoryVersion,
} from '../../../../shared/contracts/history-versions';
import { COLLECTION_TYPES } from '../../constants/collections';
import { contentManagerApi } from '../../services/api';
interface RestoreVersion extends RestoreHistoryVersion.Request {
documentId: Data.ID;
collectionType?: string;
}
const historyVersionsApi = contentManagerApi.injectEndpoints({
endpoints: (builder) => ({
getHistoryVersions: builder.query<
@ -21,23 +29,27 @@ const historyVersionsApi = contentManagerApi.injectEndpoints({
},
providesTags: ['HistoryVersion'],
}),
restoreVersion: builder.mutation<RestoreHistoryVersion.Response, RestoreHistoryVersion.Request>(
{
query({ params, body }) {
return {
url: `/content-manager/history-versions/${params.versionId}/restore`,
method: 'PUT',
data: body,
};
},
invalidatesTags: (_res, _error, { params }) => {
return [
'HistoryVersion',
{ type: 'Document', id: `${params.contentType}_${params.documentId}` },
];
},
}
),
restoreVersion: builder.mutation<RestoreHistoryVersion.Response, RestoreVersion>({
query({ params, body }) {
return {
url: `/content-manager/history-versions/${params.versionId}/restore`,
method: 'PUT',
data: body,
};
},
invalidatesTags: (_res, _error, { documentId, collectionType, params }) => {
return [
'HistoryVersion',
{
type: 'Document',
id:
collectionType === COLLECTION_TYPES
? `${params.contentType}_${documentId}`
: params.contentType,
},
];
},
}),
}),
});

View File

@ -1,199 +0,0 @@
import { createHistoryVersionController } from '../history-version';
const mockFindVersionsPage = jest.fn();
// History utils
jest.mock('../../utils', () => ({
getService: jest.fn((_strapi, name) => {
if (name === 'history') {
return {
findVersionsPage: mockFindVersionsPage,
};
}
}),
}));
// Content Manager utils
jest.mock('../../../utils', () => ({
getService: jest.fn((name) => {
if (name === 'permission-checker') {
return {
create: jest.fn(() => ({
cannot: {
read: jest.fn(() => false),
},
sanitizeQuery: jest.fn((query) => query),
})),
};
}
}),
}));
describe('History version controller', () => {
beforeEach(() => {
mockFindVersionsPage.mockClear();
});
describe('findMany', () => {
it('should require contentType and documentId for collection types', () => {
const ctx = {
state: {
userAbility: {},
},
query: {},
};
const historyVersionController = createHistoryVersionController({
// @ts-expect-error - we're not mocking the entire strapi object
strapi: { getModel: jest.fn(() => ({ kind: 'collectionType' })) },
});
// @ts-expect-error partial context
expect(historyVersionController.findMany(ctx)).rejects.toThrow(
/contentType and documentId are required/
);
expect(mockFindVersionsPage).not.toHaveBeenCalled();
});
it('should require contentType for single types', () => {
const ctx = {
state: {
userAbility: {},
},
query: {},
};
const historyVersionController = createHistoryVersionController({
// @ts-expect-error - we're not mocking the entire strapi object
strapi: { getModel: jest.fn(() => ({ kind: 'singleType' })) },
});
// @ts-expect-error partial context
expect(historyVersionController.findMany(ctx)).rejects.toThrow(/contentType is required/);
expect(mockFindVersionsPage).not.toHaveBeenCalled();
});
});
it('should call findVersionsPage for collection types', async () => {
const ctx = {
state: {
userAbility: {},
},
query: {
documentId: 'document-id',
contentType: 'api::test.test',
},
};
mockFindVersionsPage.mockResolvedValueOnce({
results: [{ id: 'history-version-id' }],
pagination: {
page: 1,
pageSize: 20,
pageCount: 1,
total: 0,
},
});
const historyVersionController = createHistoryVersionController({
// @ts-expect-error - we're not mocking the entire strapi object
strapi: { getModel: jest.fn(() => ({ kind: 'collectionType' })) },
});
// @ts-expect-error partial context
const response = await historyVersionController.findMany(ctx);
expect(mockFindVersionsPage).toHaveBeenCalled();
expect(response.data.length).toBe(1);
expect(response.meta.pagination).toBeDefined();
});
it('should call findVersionsPage for single types', async () => {
const ctx = {
state: {
userAbility: {},
},
query: {
contentType: 'api::test.test',
},
};
mockFindVersionsPage.mockResolvedValueOnce({
results: [{ id: 'history-version-id' }],
pagination: {
page: 1,
pageSize: 20,
pageCount: 1,
total: 0,
},
});
const historyVersionController = createHistoryVersionController({
// @ts-expect-error - we're not mocking the entire strapi object
strapi: { getModel: jest.fn(() => ({ kind: 'singleType' })) },
});
// @ts-expect-error partial context
const response = await historyVersionController.findMany(ctx);
expect(mockFindVersionsPage).toHaveBeenCalled();
expect(response.data.length).toBe(1);
expect(response.meta.pagination).toBeDefined();
});
it('applies pagination params', async () => {
const ctx = {
state: {
userAbility: {},
},
query: {
contentType: 'api::test.test',
},
};
const historyVersionController = createHistoryVersionController({
// @ts-expect-error - we're not mocking the entire strapi object
strapi: { getModel: jest.fn(() => ({ kind: 'singleType' })) },
});
/**
* Applies default pagination params
*/
mockFindVersionsPage.mockResolvedValueOnce({
results: [],
pagination: {
page: 1,
pageSize: 20,
},
});
// @ts-expect-error partial context
const mockResponse = await historyVersionController.findMany(ctx);
expect(mockFindVersionsPage).toHaveBeenCalledWith(
expect.objectContaining({
page: 1,
pageSize: 20,
})
);
expect(mockResponse.meta.pagination.page).toBe(1);
expect(mockResponse.meta.pagination.pageSize).toBe(20);
/**
* Prevents invalid pagination params
*/
mockFindVersionsPage.mockResolvedValueOnce({
results: [],
pagination: {},
});
// @ts-expect-error partial context
await historyVersionController.findMany({
...ctx,
query: { ...ctx.query, page: '-1', pageSize: '1000' },
});
expect(mockFindVersionsPage).toHaveBeenCalledWith(
expect.objectContaining({
page: 1,
pageSize: 20,
})
);
});
});

View File

@ -37,13 +37,13 @@ const createHistoryVersionController = ({ strapi }: { strapi: Core.Strapi }) =>
return {
async findMany(ctx) {
const contentTypeUid = ctx.query.contentType as UID.ContentType;
const isSingleType = strapi.getModel(contentTypeUid).kind === 'singleType';
const isSingleType = strapi.getModel(contentTypeUid)?.kind === 'singleType';
if (isSingleType && !contentTypeUid) {
throw new errors.ForbiddenError('contentType is required');
}
if (!contentTypeUid && !ctx.query.documentId) {
if (!isSingleType && (!contentTypeUid || !ctx.query.documentId)) {
throw new errors.ForbiddenError('contentType and documentId are required');
}

View File

@ -47,6 +47,9 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
};
const localesService = strapi.plugin('i18n')?.service('locales');
const getDefaultLocale = async () => (localesService ? localesService.getDefaultLocale() : null);
const getLocaleDictionary = async () => {
if (!localesService) return {};
@ -163,8 +166,9 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
? { documentId: result.documentId, locale: context.params?.locale }
: { documentId: context.params.documentId, locale: context.params?.locale };
const defaultLocale = localesService ? await localesService.getDefaultLocale() : null;
const defaultLocale = await getDefaultLocale();
const locale = documentContext.locale || defaultLocale;
const document = await strapi.documents(contentTypeUid).findOne({
documentId: documentContext.documentId,
locale,
@ -251,6 +255,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
results: HistoryVersions.HistoryVersionDataResponse[];
pagination: HistoryVersions.Pagination;
}> {
const locale = params.locale || (await getDefaultLocale());
const [{ results, pagination }, localeDictionary] = await Promise.all([
query.findPage({
...params,
@ -258,7 +263,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
$and: [
{ contentType: params.contentType },
...(params.documentId ? [{ relatedDocumentId: params.documentId }] : []),
...(params.locale ? [{ locale: params.locale }] : []),
...(locale ? [{ locale }] : []),
],
},
populate: ['createdBy'],
@ -497,6 +502,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
const data = omit(['id', ...Object.keys(schemaDiff.removed)], dataWithoutMissingRelations);
const restoredDocument = await strapi.documents(version.contentType).update({
documentId: version.relatedDocumentId,
locale: version.locale,
data,
});

View File

@ -82,7 +82,6 @@ export declare namespace RestoreHistoryVersion {
export interface Request {
params: {
versionId: Data.ID;
documentId: Data.ID;
contentType: UID.ContentType;
};
body: {

View File

@ -0,0 +1,442 @@
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';
const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE';
const collectionTypeUid = 'api::product.product';
const collectionTypeModel = {
draftAndPublish: true,
singularName: 'product',
pluralName: 'products',
displayName: 'Product',
kind: 'collectionType',
pluginOptions: {
i18n: {
localized: true,
},
},
attributes: {
name: {
type: 'string',
pluginOptions: {
i18n: {
localized: true,
},
},
},
description: {
type: 'string',
pluginOptions: {
i18n: {
localized: true,
},
},
},
},
};
const singleTypeUid = 'api::homepage.homepage';
const singleTypeModel = {
draftAndPublish: true,
singularName: 'homepage',
pluralName: 'homepages',
displayName: 'Homepage',
kind: 'singleType',
pluginOptions: {
i18n: {
localized: true,
},
},
attributes: {
title: {
type: 'string',
pluginOptions: {
i18n: {
localized: true,
},
},
},
subtitle: {
type: 'string',
pluginOptions: {
i18n: {
localized: true,
},
},
},
},
};
interface CreateEntryArgs {
uid: string;
data: Record<string, unknown>;
isCollectionType?: boolean;
}
interface UpdateEntryArgs extends CreateEntryArgs {
documentId?: string;
locale?: string;
}
describeOnCondition(edition === 'EE')('History API', () => {
const builder = createTestBuilder();
let strapi;
let rq;
let collectionTypeDocumentId;
let singleTypeDocumentId;
const createEntry = async ({ uid, data, isCollectionType = true }: CreateEntryArgs) => {
const type = isCollectionType ? 'collection-types' : 'single-types';
const { body } = await rq({
method: 'POST',
url: `/content-manager/${type}/${uid}`,
body: data,
});
return body;
};
const updateEntry = async ({ uid, documentId, data, locale }: UpdateEntryArgs) => {
const type = documentId ? 'collection-types' : 'single-types';
const params = documentId ? `${type}/${uid}/${documentId}` : `${type}/${uid}`;
const { body } = await rq({
method: 'PUT',
url: `/content-manager/${params}`,
body: data,
qs: { locale },
});
return body;
};
const createUserAndReq = async (
userName: string,
permissions: { action: string; subject: string }[]
) => {
const utils = createUtils(strapi);
const role = await utils.createRole({
name: `role-${userName}`,
description: `Role with restricted permissions for ${userName}`,
});
const rolePermissions = await utils.assignPermissionsToRole(role.id, permissions);
Object.assign(role, { permissions: rolePermissions });
const user = await utils.createUser({
firstname: userName,
lastname: 'User',
email: `${userName}.user@strapi.io`,
roles: [role.id],
});
const rq = await createAuthRequest({ strapi, userInfo: user });
return rq;
};
beforeAll(async () => {
await builder.addContentTypes([collectionTypeModel, singleTypeModel]).build();
strapi = await createStrapiInstance();
rq = await createAuthRequest({ strapi });
// Create another locale
const localeService = strapi.plugin('i18n').service('locales');
await localeService.create({ code: 'fr', name: 'French' });
// Create a collection type to create an initial history version
const collectionType = await createEntry({
uid: collectionTypeUid,
data: {
name: 'Product 1',
},
});
// Update the single type to create an initial history version
const singleType = await updateEntry({
uid: singleTypeUid,
data: {
title: 'Welcome',
},
isCollectionType: false,
});
// Set the documentIds to test
collectionTypeDocumentId = collectionType.data.documentId;
singleTypeDocumentId = singleType.data.documentId;
// Update to create history versions for entries in different locales
await Promise.all([
updateEntry({
documentId: collectionTypeDocumentId,
uid: collectionTypeUid,
data: {
description: 'Hello',
},
}),
updateEntry({
documentId: collectionTypeDocumentId,
uid: collectionTypeUid,
locale: 'fr',
data: {
name: 'Produit 1',
},
}),
updateEntry({
documentId: collectionTypeDocumentId,
uid: collectionTypeUid,
locale: 'fr',
data: {
description: 'Coucou',
},
}),
updateEntry({
uid: singleTypeUid,
data: {
description: 'Wow, amazing!',
},
isCollectionType: false,
}),
updateEntry({
uid: singleTypeUid,
data: {
title: 'Bienvenue',
},
isCollectionType: false,
locale: 'fr',
}),
updateEntry({
uid: singleTypeUid,
data: {
description: 'Super',
},
isCollectionType: false,
locale: 'fr',
}),
]);
});
afterAll(async () => {
await strapi.destroy();
await builder.cleanup();
});
describe('Find many history versions', () => {
test('A collection type throws with invalid query params', async () => {
const noDocumentId = await rq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}`,
});
const noContentTypeUid = await rq({
method: 'GET',
url: `/content-manager/history-versions/?documentId=${collectionTypeDocumentId}`,
});
expect(noDocumentId.statusCode).toBe(403);
expect(noContentTypeUid.statusCode).toBe(403);
});
test('A single type throws with invalid query params', async () => {
const singleTypeNoContentTypeUid = await rq({
method: 'GET',
url: `/content-manager/history-versions/`,
});
expect(singleTypeNoContentTypeUid.statusCode).toBe(403);
});
test('Throws without read permissions', async () => {
const restrictedRq = await createUserAndReq('restricted', []);
const res = await restrictedRq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}`,
});
expect(res.statusCode).toBe(403);
});
test('A collection type finds many versions in the default locale', async () => {
const collectionType = await rq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}`,
});
expect(collectionType.statusCode).toBe(200);
expect(collectionType.body.data).toHaveLength(2);
expect(collectionType.body.data[0].relatedDocumentId).toBe(collectionTypeDocumentId);
expect(collectionType.body.data[1].relatedDocumentId).toBe(collectionTypeDocumentId);
expect(collectionType.body.data[0].locale.code).toBe('en');
expect(collectionType.body.data[1].locale.code).toBe('en');
expect(collectionType.body.meta.pagination).toEqual({
page: 1,
pageSize: 20,
pageCount: 1,
total: 2,
});
});
test('A collection type finds many versions in the provided locale', async () => {
const collectionType = await rq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}&locale=fr`,
});
expect(collectionType.statusCode).toBe(200);
expect(collectionType.body.data).toHaveLength(2);
expect(collectionType.body.data[0].relatedDocumentId).toBe(collectionTypeDocumentId);
expect(collectionType.body.data[1].relatedDocumentId).toBe(collectionTypeDocumentId);
expect(collectionType.body.data[0].locale.code).toBe('fr');
expect(collectionType.body.data[1].locale.code).toBe('fr');
expect(collectionType.body.meta.pagination).toEqual({
page: 1,
pageSize: 20,
pageCount: 1,
total: 2,
});
});
test('A single type finds many versions in the default locale', async () => {
const singleType = await rq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${singleTypeUid}`,
});
expect(singleType.statusCode).toBe(200);
expect(singleType.body.data).toHaveLength(2);
expect(singleType.body.data[0].relatedDocumentId).toBe(singleTypeDocumentId);
expect(singleType.body.data[1].relatedDocumentId).toBe(singleTypeDocumentId);
expect(singleType.body.data[0].locale.code).toBe('en');
expect(singleType.body.data[1].locale.code).toBe('en');
expect(singleType.body.meta.pagination).toEqual({
page: 1,
pageSize: 20,
pageCount: 1,
total: 2,
});
});
test('A single type finds many versions in the provided locale', async () => {
const singleType = await rq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${singleTypeUid}&locale=fr`,
});
expect(singleType.statusCode).toBe(200);
expect(singleType.body.data).toHaveLength(2);
expect(singleType.body.data[0].relatedDocumentId).toBe(singleTypeDocumentId);
expect(singleType.body.data[1].relatedDocumentId).toBe(singleTypeDocumentId);
expect(singleType.body.data[0].locale.code).toBe('fr');
expect(singleType.body.data[1].locale.code).toBe('fr');
expect(singleType.body.meta.pagination).toEqual({
page: 1,
pageSize: 20,
pageCount: 1,
total: 2,
});
});
test('Applies pagination params', async () => {
const collectionType = await rq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}&page=1&pageSize=1`,
});
expect(collectionType.body.data).toHaveLength(1);
expect(collectionType.body.meta.pagination).toEqual({
page: 1,
pageSize: 1,
pageCount: 2,
total: 2,
});
});
});
describe('Restore a history version', () => {
test('Throws with invalid body', async () => {
const res = await rq({
method: 'PUT',
url: `/content-manager/history-versions/1/restore`,
body: {},
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
data: null,
error: {
status: 400,
name: 'ValidationError',
message: 'contentType is required',
},
});
});
test('Throws without update permissions', async () => {
const restrictedRq = await createUserAndReq('read', [
{ action: 'plugin::content-manager.explorer.read', subject: collectionTypeUid },
]);
const res = await restrictedRq({
method: 'PUT',
url: `/content-manager/history-versions/1/restore`,
body: {
contentType: collectionTypeUid,
},
});
expect(res.statusCode).toBe(403);
expect(res.body).toMatchObject({
data: null,
error: {
status: 403,
name: 'ForbiddenError',
message: 'Forbidden',
},
});
});
test('Restores a history version in the default locale', async () => {
const currentDocument = await strapi
.documents(collectionTypeUid)
.findOne({ documentId: collectionTypeDocumentId });
await rq({
method: 'PUT',
url: `/content-manager/history-versions/1/restore`,
body: {
contentType: collectionTypeUid,
},
});
const restoredDocument = await strapi
.documents(collectionTypeUid)
.findOne({ documentId: collectionTypeDocumentId });
expect(currentDocument.description).toBe('Hello');
expect(restoredDocument.description).toBe(null);
});
test('Restores a history version in the provided locale', async () => {
const currentDocument = await strapi
.documents(collectionTypeUid)
.findOne({ documentId: collectionTypeDocumentId, locale: 'fr' });
await rq({
method: 'PUT',
url: `/content-manager/history-versions/4/restore`,
body: {
contentType: collectionTypeUid,
},
});
const restoredDocument = await strapi
.documents(collectionTypeUid)
.findOne({ documentId: collectionTypeDocumentId, locale: 'fr' });
expect(currentDocument.description).toBe('Coucou');
expect(restoredDocument.description).toBe(null);
});
});
});