chore(content-releases): Add entries to content-releases' actions (#18918)

* chore(content-releases): add entries to content releases actions

* chore(content-releases): add entries relation to findMany content-release's actions

* chore(content-releases): improve findOne with actions code

* chore(Content-releases): remi feedback

* chore(content-releases): fix getReleaseActions response type

* chore(content-releases): change findOne and findActions endpoints

* chore(content-releases): fix error in release's tests

* chore(content-releases): use queryBuilder from strapi.db

* chore(content-releases): use queryBuilder from strapi.db
This commit is contained in:
Fernando Chávez 2023-12-04 10:10:51 +01:00 committed by GitHub
parent 4f6722c6d4
commit 9b4c03b10b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 415 additions and 53 deletions

View File

@ -1,5 +1,33 @@
import releaseActionController from '../release-action'; import releaseActionController from '../release-action';
const mockSanitizedQueryRead = jest.fn().mockResolvedValue({});
const mockFindActions = jest.fn().mockResolvedValue({ results: [], pagination: {} });
const mockSanitizeOutput = jest.fn((entry: { id: number; name: string }) => ({ id: entry.id }));
jest.mock('../../utils', () => ({
getService: jest.fn(() => ({
create: jest.fn(),
findActions: mockFindActions,
findReleaseContentTypesMainFields: jest.fn(() => ({
'api::contentTypeA.contentTypeA': {
mainField: 'name',
},
'api::contentTypeB.contentTypeB': {
mainField: 'name',
},
})),
})),
getAllowedContentTypes: jest
.fn()
.mockReturnValue(['api::contentTypeA.contentTypeA', 'api::contentTypeB.contentTypeB']),
getPermissionsChecker: jest.fn(() => ({
sanitizedQuery: {
read: mockSanitizedQueryRead,
},
sanitizeOutput: mockSanitizeOutput,
})),
}));
describe('Release Action controller', () => { describe('Release Action controller', () => {
describe('create', () => { describe('create', () => {
beforeEach(() => { beforeEach(() => {
@ -66,4 +94,45 @@ describe('Release Action controller', () => {
expect(() => releaseActionController.create(ctx)).rejects.toThrow('type is a required field'); expect(() => releaseActionController.create(ctx)).rejects.toThrow('type is a required field');
}); });
}); });
describe('findMany', () => {
const ctx = {
state: {
userAbility: {
can: jest.fn(),
cannot: jest.fn(),
},
},
params: {
releaseId: 1,
},
};
it('should call sanitizedQueryRead once for each contentType', async () => {
// @ts-expect-error Ignore missing properties
await releaseActionController.findMany(ctx);
expect(mockSanitizedQueryRead).toHaveBeenCalledTimes(2);
});
it('should call findActions with the right params', async () => {
// @ts-expect-error Ignore missing properties
await releaseActionController.findMany(ctx);
expect(mockFindActions).toHaveBeenCalledWith(
1,
['api::contentTypeA.contentTypeA', 'api::contentTypeB.contentTypeB'],
{
populate: {
entry: {
on: {
'api::contentTypeA.contentTypeA': {},
'api::contentTypeB.contentTypeB': {},
},
},
},
}
);
});
});
}); });

View File

@ -1,10 +1,25 @@
import releaseController from '../release'; import releaseController from '../release';
const mockFindPage = jest.fn();
const mockFindMany = jest.fn();
const mockCountActions = jest.fn();
jest.mock('../../utils', () => ({
getService: jest.fn(() => ({
findOne: jest.fn(() => ({ id: 1 })),
findPage: mockFindPage,
findMany: mockFindMany,
countActions: mockCountActions,
findReleaseContentTypesMainFields: jest.fn(),
})),
getAllowedContentTypes: jest.fn(() => ['contentTypeA', 'contentTypeB']),
}));
describe('Release controller', () => { describe('Release controller', () => {
describe('findMany', () => { describe('findMany', () => {
it('should call findPage', async () => { it('should call findPage', async () => {
const findPage = jest.fn().mockResolvedValue({ results: [], pagination: {} }); mockFindPage.mockResolvedValue({ results: [], pagination: {} });
const findMany = jest.fn().mockResolvedValue([]); mockFindMany.mockResolvedValue([]);
const userAbility = { const userAbility = {
can: jest.fn(), can: jest.fn(),
}; };
@ -30,28 +45,17 @@ describe('Release controller', () => {
}, },
}, },
}, },
plugins: {
// @ts-expect-error Ignore missing properties
'content-releases': {
services: {
release: {
findPage,
findMany,
},
},
},
},
}; };
// @ts-expect-error partial context // @ts-expect-error partial context
await releaseController.findMany(ctx); await releaseController.findMany(ctx);
expect(findPage).toHaveBeenCalled(); expect(mockFindPage).toHaveBeenCalled();
}); });
it('should call findMany', async () => { it('should call findMany', async () => {
const findPage = jest.fn().mockResolvedValue({ results: [], pagination: {} }); mockFindPage.mockResolvedValue({ results: [], pagination: {} });
const findMany = jest.fn().mockResolvedValue([]); mockFindMany.mockResolvedValue([]);
const userAbility = { const userAbility = {
can: jest.fn(), can: jest.fn(),
}; };
@ -74,23 +78,12 @@ describe('Release controller', () => {
}, },
}, },
}, },
plugins: {
// @ts-expect-error Ignore missing properties
'content-releases': {
services: {
release: {
findPage,
findMany,
},
},
},
},
}; };
// @ts-expect-error partial context // @ts-expect-error partial context
await releaseController.findMany(ctx); await releaseController.findMany(ctx);
expect(findMany).toHaveBeenCalled(); expect(mockFindMany).toHaveBeenCalled();
}); });
}); });
describe('create', () => { describe('create', () => {
@ -154,4 +147,67 @@ describe('Release controller', () => {
); );
}); });
}); });
describe('findOne', () => {
global.strapi = {
...global.strapi,
plugins: {
// @ts-expect-error incomplete plugin
'content-manager': {
services: {
'content-types': {
findConfiguration: () => ({
settings: {
mainField: 'name',
},
}),
},
},
},
},
};
const ctx = {
state: {
userAbility: {
can: jest.fn(() => true),
},
},
params: {
id: 1,
},
user: {},
body: {
data: {
actions: {
meta: {
total: 0,
totalHidden: 0,
},
},
meta: {},
},
},
};
it('throws an error if the release does not exists', async () => {
// @ts-expect-error partial context
expect(() => releaseController.findOne(ctx).rejects.toThrow('Release not found for id: 1'));
});
it('return the right meta object', async () => {
// We mock the count all actions
mockCountActions.mockResolvedValueOnce(2);
// We mock the count hidden actions
mockCountActions.mockResolvedValueOnce(1);
// @ts-expect-error partial context
await releaseController.findOne(ctx);
expect(ctx.body.data.actions.meta).toEqual({
total: 2,
totalHidden: 1,
});
});
});
}); });

View File

@ -1,7 +1,13 @@
import type Koa from 'koa'; import type Koa from 'koa';
import { UID } from '@strapi/types';
import { mapAsync } from '@strapi/utils';
import { validateReleaseAction } from './validation/release-action'; import { validateReleaseAction } from './validation/release-action';
import type { CreateReleaseAction } from '../../../shared/contracts/release-actions'; import type {
import { getService } from '../utils'; CreateReleaseAction,
GetReleaseActions,
ReleaseAction,
} from '../../../shared/contracts/release-actions';
import { getAllowedContentTypes, getService, getPermissionsChecker } from '../utils';
const releaseActionController = { const releaseActionController = {
async create(ctx: Koa.Context) { async create(ctx: Koa.Context) {
@ -17,6 +23,80 @@ const releaseActionController = {
data: releaseAction, data: releaseAction,
}; };
}, },
async findMany(ctx: Koa.Context) {
const releaseId: GetReleaseActions.Request['params']['releaseId'] = ctx.params.releaseId;
const allowedContentTypes = getAllowedContentTypes({
strapi,
userAbility: ctx.state.userAbility,
});
// We create an object with the permissionsChecker for each contentType, then we can reuse it for sanitization
const permissionsChecker: Record<UID.ContentType, any> = {};
// We create a populate object for polymorphic relations, so we considered custom conditions on permissions
const morphSanitizedPopulate: Record<UID.ContentType, any> = {};
for (const contentTypeUid of allowedContentTypes) {
const permissionChecker = await getPermissionsChecker({
strapi,
userAbility: ctx.state.userAbility,
model: contentTypeUid,
});
permissionsChecker[contentTypeUid] = permissionChecker;
morphSanitizedPopulate[contentTypeUid] = await permissionChecker.sanitizedQuery.read({});
}
const releaseService = getService('release', { strapi });
const { results, pagination } = await releaseService.findActions(
releaseId,
allowedContentTypes,
{
populate: {
entry: {
on: morphSanitizedPopulate,
},
},
}
);
const contentTypesMainFields = await releaseService.findReleaseContentTypesMainFields(
releaseId
);
// We loop over all the contentTypes mainfields to sanitize each mainField
// By default, if user doesn't have permission to read the field, we return null as fallback
for (const contentTypeUid of Object.keys(contentTypesMainFields)) {
if (
ctx.state.userAbility.cannot(
'plugin::content-manager.explorer.read',
contentTypeUid,
contentTypesMainFields[contentTypeUid].mainField
)
) {
contentTypesMainFields[contentTypeUid].mainField = null;
}
}
// Because this is a morphTo relation, we need to sanitize each entry separately based on its contentType
const sanitizedResults = await mapAsync(results, async (action: ReleaseAction) => {
const mainField = contentTypesMainFields[action.contentType].mainField;
return {
...action,
entry: action.entry && {
id: action.entry.id,
mainField: mainField ? action.entry[mainField] : null,
locale: action.entry.locale,
},
};
});
ctx.body = {
data: sanitizedResults,
meta: {
pagination,
},
};
},
}; };
export default releaseActionController; export default releaseActionController;

View File

@ -9,7 +9,7 @@ import type {
Release, Release,
} from '../../../shared/contracts/releases'; } from '../../../shared/contracts/releases';
import type { UserInfo } from '../../../shared/types'; import type { UserInfo } from '../../../shared/types';
import { getService } from '../utils'; import { getAllowedContentTypes, getService } from '../utils';
type ReleaseWithPopulatedActions = Release & { actions: { count: number } }; type ReleaseWithPopulatedActions = Release & { actions: { count: number } };
@ -56,22 +56,39 @@ const releaseController = {
async findOne(ctx: Koa.Context) { async findOne(ctx: Koa.Context) {
const id: GetRelease.Request['params']['id'] = ctx.params.id; const id: GetRelease.Request['params']['id'] = ctx.params.id;
const result = (await getService('release', { strapi }).findOne( const releaseService = getService('release', { strapi });
Number(id)
)) as ReleaseWithPopulatedActions | null;
if (!result) { const allowedContentTypes = getAllowedContentTypes({
strapi,
userAbility: ctx.state.userAbility,
});
const release = await releaseService.findOne(id);
const total = await releaseService.countActions({
filters: {
release: id,
},
});
const totalHidden = await releaseService.countActions({
filters: {
release: id,
contentType: {
$notIn: allowedContentTypes,
},
},
});
if (!release) {
throw new errors.NotFoundError(`Release not found for id: ${id}`); throw new errors.NotFoundError(`Release not found for id: ${id}`);
} }
const { actions, ...release } = result;
// Format the data object // Format the data object
const data = { const data = {
...release, ...release,
actions: { actions: {
meta: { meta: {
count: actions.count, total,
totalHidden,
}, },
}, },
}; };

View File

@ -17,5 +17,21 @@ export default {
], ],
}, },
}, },
{
method: 'GET',
path: '/:releaseId/actions',
handler: 'release-action.findMany',
config: {
policies: [
'admin::isAuthenticatedAdmin',
{
name: 'admin::hasPermissions',
config: {
actions: ['plugin::content-releases.read']
}
}
]
}
}
], ],
}; };

View File

@ -42,4 +42,20 @@ describe('release service', () => {
); );
}); });
}); });
describe('findActions', () => {
it('throws an error if the release does not exist', () => {
const strapiMock = {
...baseStrapiMock,
entityService: {
findOne: jest.fn().mockReturnValue(null),
},
};
// @ts-expect-error Ignore missing properties
const releaseService = createReleaseService({ strapi: strapiMock });
expect(() => releaseService.findActions(1, ['api::contentType.contentType'], {})).rejects.toThrow('No release found for id 1');
});
});
}); });

View File

@ -1,13 +1,17 @@
import { setCreatorFields, errors } from '@strapi/utils'; import { setCreatorFields, errors } from '@strapi/utils';
import type { LoadedStrapi } from '@strapi/types'; import type { LoadedStrapi, Common, EntityService, UID } from '@strapi/types';
import { RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID } from '../constants'; import { RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID } from '../constants';
import type { import type {
GetReleases, GetReleases,
CreateRelease, CreateRelease,
UpdateRelease, UpdateRelease,
GetRelease, GetRelease,
Release,
} from '../../../shared/contracts/releases'; } from '../../../shared/contracts/releases';
import type { CreateReleaseAction } from '../../../shared/contracts/release-actions'; import type {
CreateReleaseAction,
GetReleaseActions,
} from '../../../shared/contracts/release-actions';
import type { UserInfo } from '../../../shared/types'; import type { UserInfo } from '../../../shared/types';
import { getService } from '../utils'; import { getService } from '../utils';
@ -30,6 +34,9 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
}, },
}); });
}, },
findOne(id: GetRelease.Request['params']['id'], query = {}) {
return strapi.entityService.findOne(RELEASE_MODEL_UID, id, query);
},
findMany(query?: GetReleases.Request['query']) { findMany(query?: GetReleases.Request['query']) {
return strapi.entityService.findMany(RELEASE_MODEL_UID, { return strapi.entityService.findMany(RELEASE_MODEL_UID, {
...query, ...query,
@ -41,16 +48,6 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
}, },
}); });
}, },
findOne(id: GetRelease.Request['params']['id']) {
return strapi.entityService.findOne(RELEASE_MODEL_UID, id, {
populate: {
actions: {
// @ts-expect-error TS error on populate, is not considering count
count: true,
},
},
});
},
async update( async update(
id: number, id: number,
releaseData: UpdateRelease.Request['body'], releaseData: UpdateRelease.Request['body'],
@ -102,6 +99,67 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
populate: { release: { fields: ['id'] }, entry: { fields: ['id'] } }, populate: { release: { fields: ['id'] }, entry: { fields: ['id'] } },
}); });
}, },
async findActions(
releaseId: GetReleaseActions.Request['params']['releaseId'],
contentTypes: Common.UID.ContentType[],
query?: GetReleaseActions.Request['query']
) {
const result = await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId);
if (!result) {
throw new errors.NotFoundError(`No release found for id ${releaseId}`);
}
return strapi.entityService.findPage(RELEASE_ACTION_MODEL_UID, {
...query,
filters: {
release: releaseId,
contentType: {
$in: contentTypes,
},
},
});
},
async countActions(query: EntityService.Params.Pick<typeof RELEASE_ACTION_MODEL_UID, 'filters'>) {
return strapi.entityService.count(RELEASE_ACTION_MODEL_UID, query);
},
async findReleaseContentTypesMainFields(releaseId: Release['id']) {
const contentTypesFromReleaseActions: { contentType: UID.ContentType }[] = await strapi.db
.queryBuilder(RELEASE_ACTION_MODEL_UID)
.select('content_type')
.where({
$and: [
{
release: releaseId,
},
],
})
.groupBy('content_type')
.execute();
const contentTypesUids = contentTypesFromReleaseActions.map(
({ contentType: contentTypeUid }) => contentTypeUid
);
const contentManagerContentTypeService = strapi
.plugin('content-manager')
.service('content-types');
const contentTypesMeta: Record<UID.ContentType, { mainField: string }> = {};
for (const contentTypeUid of contentTypesUids) {
const contentTypeConfig = await contentManagerContentTypeService.findConfiguration({
uid: contentTypeUid,
});
if (contentTypeConfig) {
contentTypesMeta[contentTypeUid] = {
mainField: contentTypeConfig.settings.mainField,
};
}
}
return contentTypesMeta;
},
}); });
export default createReleaseService; export default createReleaseService;

View File

@ -1,6 +1,35 @@
import type { LoadedStrapi, UID } from '@strapi/types';
export const getService = ( export const getService = (
name: 'release' | 'release-validation', name: 'release' | 'release-validation',
{ strapi } = { strapi: global.strapi } { strapi } = { strapi: global.strapi }
) => { ) => {
return strapi.plugin('content-releases').service(name); return strapi.plugin('content-releases').service(name);
}; };
/**
* Gets the content types that have draft and publish enabled and that the user can read
*/
export const getAllowedContentTypes = ({ strapi, userAbility }: { strapi: LoadedStrapi, userAbility: any }) => {
const { contentTypes } = strapi;
const contentTypesWithDraftAndPublish = (Object.keys(contentTypes) as UID.ContentType[]).filter(
(contentTypeUid) => contentTypes[contentTypeUid].options?.draftAndPublish
);
const allowedContentTypes = contentTypesWithDraftAndPublish.filter(
(contentTypeUid) => {
return userAbility.can('plugin::content-manager.explorer.read', contentTypeUid);
}
);
return allowedContentTypes;
};
/**
* Gets the permissions checker for a given content type using the permission checker from content-manager
*/
export const getPermissionsChecker = ({ strapi, userAbility, model }: { strapi: LoadedStrapi, userAbility: any, model: UID.ContentType }) => {
return strapi
.plugin('content-manager')
.service('permission-checker')
.create({ userAbility, model });
};

View File

@ -1,5 +1,5 @@
import { Attribute, Common } from '@strapi/types'; import { Attribute, Common } from '@strapi/types';
import type { Release } from './releases'; import type { Release, Pagination } from './releases';
import type { Entity } from '../types'; import type { Entity } from '../types';
import type { errors } from '@strapi/utils'; import type { errors } from '@strapi/utils';
@ -9,7 +9,7 @@ type ReleaseActionEntry = Entity & {
[key: string]: Attribute.Any; [key: string]: Attribute.Any;
}; };
export interface ReleaseAction { export interface ReleaseAction extends Entity {
type: 'publish' | 'unpublish'; type: 'publish' | 'unpublish';
entry: ReleaseActionEntry; entry: ReleaseActionEntry;
contentType: Common.UID.ContentType; contentType: Common.UID.ContentType;
@ -38,3 +38,23 @@ export declare namespace CreateReleaseAction {
error?: errors.ApplicationError | errors.ValidationError | errors.NotFoundError; error?: errors.ApplicationError | errors.ValidationError | errors.NotFoundError;
} }
} }
/**
* GET /content-releases/:id/actions - Get all release actions
*/
export declare namespace GetReleaseActions {
export interface Request {
params: {
releaseId: Release['id'];
};
query?: Partial<Pick<Pagination, 'page' | 'pageSize'>>;
}
export interface Response {
data: ReleaseAction[];
meta: {
pagination: Pagination;
};
error?: errors.ApplicationError | errors.NotFoundError;
}
}

View File

@ -2,6 +2,7 @@ import type { Entity } from '../types';
import type { ReleaseAction } from './release-actions'; import type { ReleaseAction } from './release-actions';
import type { UserInfo } from '../types'; import type { UserInfo } from '../types';
import { errors } from '@strapi/utils'; import { errors } from '@strapi/utils';
import { UID } from '@strapi/types';
export interface Release extends Entity { export interface Release extends Entity {
name: string; name: string;
@ -9,7 +10,7 @@ export interface Release extends Entity {
actions: ReleaseAction[]; actions: ReleaseAction[];
} }
type Pagination = { export type Pagination = {
page: number; page: number;
pageSize: number; pageSize: number;
pageCount: number; pageCount: number;