From 43d8bb8515edfdea517fd444a87768527a03f7c0 Mon Sep 17 00:00:00 2001 From: Mikolaj Lewandowski Date: Sun, 15 Mar 2026 14:06:36 +0100 Subject: [PATCH 1/2] fix: pass missing query params in findAllPerAuthor to findAllFlat --- .../services/__tests__/common.service.test.ts | 240 +++++++----- server/src/services/common.service.ts | 351 +++++++++++------- 2 files changed, 354 insertions(+), 237 deletions(-) diff --git a/server/src/services/__tests__/common.service.test.ts b/server/src/services/__tests__/common.service.test.ts index 02c8301..1363e06 100644 --- a/server/src/services/__tests__/common.service.test.ts +++ b/server/src/services/__tests__/common.service.test.ts @@ -48,15 +48,16 @@ describe('common.service', () => { caster(getStoreRepository).mockReturnValue(mockStoreRepository); }); - const getStrapi = () => caster({ - strapi: { - documents: () => ({ - findOne: mockFindOne, - findMany: mockFindMany, - }), - plugin: () => null - } - }); + const getStrapi = () => + caster({ + strapi: { + documents: () => ({ + findOne: mockFindOne, + findMany: mockFindMany, + }), + plugin: () => null, + }, + }); const getService = (strapi: StrapiContext) => commonService(strapi); @@ -64,7 +65,7 @@ describe('common.service', () => { it('should return full config when no prop is specified', async () => { const strapi = getStrapi(); const service = getService(strapi); - const mockConfig: Partial = { + const mockConfig: Partial = { isValidationEnabled: true, moderatorRoles: ['admin'], }; @@ -79,7 +80,7 @@ describe('common.service', () => { it('should return specific config prop when specified', async () => { const strapi = getStrapi(); const service = getService(strapi); - const mockConfig: Partial = { + const mockConfig: Partial = { moderatorRoles: ['admin'], }; @@ -248,7 +249,7 @@ describe('common.service', () => { pagination: { total: 2 }, }); caster(getOrderBy).mockReturnValue(['createdAt', 'desc']); - + mockStoreRepository.getConfig.mockResolvedValue([]); const result = await service.findAllFlat({ @@ -273,7 +274,7 @@ describe('common.service', () => { pagination: { total: 2 }, }); caster(getOrderBy).mockReturnValue(['createdAt', 'desc']); - + mockStoreRepository.getConfig.mockResolvedValue([]); const result = await service.findAllFlat({ @@ -283,10 +284,10 @@ describe('common.service', () => { populate: { authorUser: { avatar: { - populate: true - } - } - } + populate: true, + }, + }, + }, }); expect(result.data).toHaveLength(2); @@ -295,45 +296,43 @@ describe('common.service', () => { }); describe('modifiedNestedNestedComments', () => { - describe('when nested entries don\'t have relation', () => { - it('should modify nested comments recursively', async () => { - const strapi = getStrapi(); - const service = getService(strapi); - const mockComments = [ - { id: 2, threadOf: 1 }, - { id: 3, threadOf: 1 }, - ]; - - mockCommentRepository.findMany - .mockResolvedValue(mockComments) - .mockResolvedValueOnce([]) - mockCommentRepository.updateMany.mockResolvedValue({ count: 2 }); - - const result = await service.modifiedNestedNestedComments(1, 'removed', true); - - expect(result).toBe(true); - expect(mockCommentRepository.updateMany).toHaveBeenCalled(); - }); - }) + describe("when nested entries don't have relation", () => { + it('should modify nested comments recursively', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const mockComments = [ + { id: 2, threadOf: 1 }, + { id: 3, threadOf: 1 }, + ]; + + mockCommentRepository.findMany.mockResolvedValue(mockComments).mockResolvedValueOnce([]); + mockCommentRepository.updateMany.mockResolvedValue({ count: 2 }); + + const result = await service.modifiedNestedNestedComments(1, 'removed', true); + + expect(result).toBe(true); + expect(mockCommentRepository.updateMany).toHaveBeenCalled(); + }); + }); describe('when nested entries have relation', () => { - it('should change entries to the deepLimit', async () => { - const strapi = getStrapi(); - const service = getService(strapi); - const mockComments = [ - { id: 2, threadOf: 1 }, - { id: 3, threadOf: 1 }, - ]; + it('should change entries to the deepLimit', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const mockComments = [ + { id: 2, threadOf: 1 }, + { id: 3, threadOf: 1 }, + ]; - mockCommentRepository.findMany.mockResolvedValue(mockComments) - mockCommentRepository.updateMany.mockResolvedValue({ count: 2 }); + mockCommentRepository.findMany.mockResolvedValue(mockComments); + mockCommentRepository.updateMany.mockResolvedValue({ count: 2 }); - const result = await service.modifiedNestedNestedComments(1, 'removed', true); + const result = await service.modifiedNestedNestedComments(1, 'removed', true); - expect(result).toBe(true); - expect(mockCommentRepository.updateMany).toHaveBeenCalled(); - }); - }) + expect(result).toBe(true); + expect(mockCommentRepository.updateMany).toHaveBeenCalled(); + }); + }); it('should return false on update failure', async () => { const strapi = getStrapi(); @@ -352,10 +351,10 @@ describe('common.service', () => { const strapi = getStrapi(); const service = getService(strapi); const mockComments = [ - { id: 1, content: "Parent 1", threadOf: null }, - { id: 2, content: "Child 1", threadOf: "1" }, - { id: 3, content: "Child 2", threadOf: "1" }, - { id: 4, content: "Grandchild 1", threadOf: "2" }, + { id: 1, content: 'Parent 1', threadOf: null }, + { id: 2, content: 'Child 1', threadOf: '1' }, + { id: 3, content: 'Child 2', threadOf: '1' }, + { id: 4, content: 'Grandchild 1', threadOf: '2' }, ]; mockCommentRepository.findMany.mockResolvedValue(mockComments); @@ -406,20 +405,17 @@ describe('common.service', () => { const strapi = getStrapi(); const service = getService(strapi); const mockComments = [ - { id: 2, content: "Child 1", threadOf: "1" }, - { id: 3, content: "Child 2", threadOf: "1" }, - { id: 4, content: "Grandchild 1", threadOf: "2" }, - { id: 5, content: "Grandchild 2", threadOf: "2" }, - { id: 6, content: "Grandchild 3", threadOf: "4" }, + { id: 2, content: 'Child 1', threadOf: '1' }, + { id: 3, content: 'Child 2', threadOf: '1' }, + { id: 4, content: 'Grandchild 1', threadOf: '2' }, + { id: 5, content: 'Grandchild 2', threadOf: '2' }, + { id: 6, content: 'Grandchild 3', threadOf: '4' }, ]; mockCommentRepository.findMany.mockResolvedValue(mockComments); caster(getOrderBy).mockReturnValue(['createdAt', 'desc']); mockCommentRepository.findWithCount.mockImplementation(async (args) => { - const threadOf = - args?.where?.threadOf?.$eq ?? - args?.where?.threadOf.toString() ?? - null; + const threadOf = args?.where?.threadOf?.$eq ?? args?.where?.threadOf.toString() ?? null; const filtered = mockComments.filter((c) => c.threadOf === threadOf); return { results: filtered, @@ -443,7 +439,13 @@ describe('common.service', () => { const strapi = getStrapi(); const service = getService(strapi); const mockComments = [ - { id: 1, content: 'Parent 1', threadOf: null, dropBlockedThreads: true, blockedThread: true }, + { + id: 1, + content: 'Parent 1', + threadOf: null, + dropBlockedThreads: true, + blockedThread: true, + }, { id: 2, content: 'Child 1', threadOf: '1', dropBlockedThreads: false }, { id: 3, content: 'Child 2', threadOf: '1', dropBlockedThreads: false }, { id: 4, content: 'Grandchild 1', threadOf: '2', dropBlockedThreads: false }, @@ -495,7 +497,9 @@ describe('common.service', () => { mockCommentRepository.update.mockRejectedValue(new Error('Update failed')); - await expect(service.updateComment({ id: 1 }, { content: 'Updated content' })).rejects.toThrow('Update failed'); + await expect( + service.updateComment({ id: 1 }, { content: 'Updated content' }) + ).rejects.toThrow('Update failed'); }); }); @@ -543,9 +547,7 @@ describe('common.service', () => { const strapi = getStrapi(); const service = getService(strapi); const comment = { id: 1, related: 'api::test.test:1' }; - const relatedEntities = [ - { uid: 'api::test.test', documentId: '1', title: 'Test Title' }, - ]; + const relatedEntities = [{ uid: 'api::test.test', documentId: '1', title: 'Test Title' }]; const result = service.mergeRelatedEntityTo(comment, relatedEntities); @@ -571,7 +573,6 @@ describe('common.service', () => { }); mockStoreRepository.getConfig.mockResolvedValue([]); - caster(getOrderBy).mockReturnValue(['createdAt', 'desc']); const result = await service.findAllPerAuthor({ @@ -580,19 +581,19 @@ describe('common.service', () => { }); expect(result.data).toHaveLength(2); - expect(result.data.every(item => !item.authorUser)).toBeTruthy(); + expect(result.data.every((item) => !item.authorUser)).toBeTruthy(); expect(mockCommentRepository.findWithCount).toHaveBeenCalledWith({ - pageSize: 10, + pageSize: 10, page: 1, - populate: { + populate: { authorUser: { populate: true, avatar: { populate: true }, - }, - }, - select: ["id", "content", "related"], - orderBy: { createdAt: "desc" }, - where: { authorId: 1 } + }, + }, + select: ['id', 'content', 'related'], + orderBy: { createdAt: 'desc' }, + where: { authorId: 1 }, }); }); @@ -622,13 +623,16 @@ describe('common.service', () => { mockStoreRepository.getConfig.mockResolvedValue([]); - const result = await service.findAllPerAuthor({ - authorId: 1, - fields: ['id', 'content'], - }, true); + const result = await service.findAllPerAuthor( + { + authorId: 1, + fields: ['id', 'content'], + }, + true + ); expect(result.data).toHaveLength(2); - expect(result.data.every(item => !item.authorUser)).toBeTruthy(); + expect(result.data.every((item) => !item.authorUser)).toBeTruthy(); expect(mockCommentRepository.findWithCount).toHaveBeenCalledWith({ where: { authorUser: { id: 1 } }, pageSize: 10, @@ -643,6 +647,44 @@ describe('common.service', () => { }, }); }); + it('should pass all query params to findAllPerAuthor', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + + const mockComments = [ + { id: 1, content: 'Comment 1', authorId: 1, related: 'api::test.test:1' }, + { id: 2, content: 'Comment 2', authorId: 1, related: 'api::test.test:1' }, + ]; + + mockCommentRepository.findWithCount.mockResolvedValue({ + results: mockComments, + pagination: { total: mockComments.length }, + }); + + const result = await service.findAllPerAuthor({ + authorId: 1, + omit: ['related'], + locale: 'en', + sort: 'createdAt:desc', + limit: 5, + skip: 10, + }); + + expect(mockCommentRepository.findWithCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + authorId: 1, + locale: 'en', + }), + pageSize: 5, + page: 2, + }) + ); + + expect(result.data).toHaveLength(2); + expect(result.data[0].related).toBeUndefined(); + expect(result.data[1].related).toBeUndefined(); + }); }); describe('findRelatedEntitiesFor', () => { @@ -651,9 +693,14 @@ describe('common.service', () => { const service = getService(strapi); const mockComments = [ { id: 1, related: 'api::test.test:1', locale: 'en' }, - { id: 1, related: 'api::test.test:1', locale: 'en' } + { id: 1, related: 'api::test.test:1', locale: 'en' }, ]; - const mockRelatedEntities = { uid: 'api::test.test', documentId: '1', locale: 'en', title: 'Test Title 1' }; + const mockRelatedEntities = { + uid: 'api::test.test', + documentId: '1', + locale: 'en', + title: 'Test Title 1', + }; mockFindOne.mockResolvedValue(mockRelatedEntities); @@ -666,9 +713,7 @@ describe('common.service', () => { it('should return an empty array if no related entities are found', async () => { const strapi = getStrapi(); const service = getService(strapi); - const mockComments = [ - { id: 1, related: 'api::test.test:1', locale: 'en' }, - ]; + const mockComments = [{ id: 1, related: 'api::test.test:1', locale: 'en' }]; mockFindOne.mockResolvedValue(undefined); @@ -684,17 +729,24 @@ describe('common.service', () => { const service = getService(strapi); const mockComments = [ { id: 1, related: 'api::test.test:1', locale: 'en' }, - { id: 1, related: 'api::test.test:1', locale: 'en' } + { id: 1, related: 'api::test.test:1', locale: 'en' }, ]; - const mockRelatedEntities = { uid: 'api::test.test', documentId: '1', locale: 'en', title: 'Test Title 1' }; + const mockRelatedEntities = { + uid: 'api::test.test', + documentId: '1', + locale: 'en', + title: 'Test Title 1', + }; - mockCommentRepository.findMany.mockResolvedValue(mockComments) + mockCommentRepository.findMany.mockResolvedValue(mockComments); mockCommentRepository.updateMany.mockResolvedValue({ count: 2 }); - const result = await service.perRemove([mockRelatedEntities.uid, mockRelatedEntities.documentId].join(':')); + const result = await service.perRemove( + [mockRelatedEntities.uid, mockRelatedEntities.documentId].join(':') + ); - expect(result).toEqual({ count: 2}); + expect(result).toEqual({ count: 2 }); expect(mockCommentRepository.updateMany).toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/server/src/services/common.service.ts b/server/src/services/common.service.ts index e076ce4..78c7764 100644 --- a/server/src/services/common.service.ts +++ b/server/src/services/common.service.ts @@ -1,16 +1,8 @@ import { Params } from '@strapi/database/dist/entity-manager/types'; import { UID } from '@strapi/strapi'; -import { - first, - get, - isNil, - isObject, - isString, - omit as filterItem, - parseInt, - uniq, -} from 'lodash'; +import { omit as filterItem, first, get, isNil, isObject, isString, parseInt, uniq } from 'lodash'; import { isProfane, replaceProfanities } from 'no-profanity'; +import sanitizeHtml from 'sanitize-html'; import { Id, PathTo, PathValue, RelatedEntity, StrapiContext } from '../@types'; import { CommentsPluginConfig } from '../config'; import { ContentTypesUUIDs } from '../content-types'; @@ -22,13 +14,10 @@ import { client as clientValidator } from '../validators/api'; import { Comment, CommentRelated, CommentWithRelated } from '../validators/repositories'; import { Pagination } from '../validators/repositories/utils'; import { buildAuthorModel, filterOurResolvedReports, getRelatedGroups } from './utils/functions'; -import sanitizeHtml from 'sanitize-html'; - const PAGE_SIZE = 10; const REQUIRED_FIELDS = ['id']; - type ParsedRelation = { uid: UID.ContentType; relatedId: string; @@ -37,18 +26,27 @@ type ParsedRelation = { type Created = PathTo; const commonService = ({ strapi }: StrapiContext) => ({ - async getConfig(prop?: T, defaultValue?: PathValue, useLocal = false): Promise> { + async getConfig( + prop?: T, + defaultValue?: PathValue, + useLocal = false + ): Promise> { const storeRepository = getStoreRepository(strapi); const config = await storeRepository.getConfig(); if (prop && !useLocal) { return get(config, prop, defaultValue) as PathValue; } if (useLocal) { - return storeRepository.getLocalConfig(prop, defaultValue) as PathValue; + return storeRepository.getLocalConfig(prop, defaultValue) as PathValue< + CommentsPluginConfig, + T + >; } return config as PathValue; }, - parseRelationString(relation: `${string}::${string}.${string}:${string}` | string): ParsedRelation { + parseRelationString( + relation: `${string}::${string}.${string}:${string}` | string + ): ParsedRelation { const [uid, relatedStringId] = getRelatedGroups(relation); return { uid: uid as UID.ContentType, relatedId: relatedStringId }; }, @@ -56,18 +54,28 @@ const commonService = ({ strapi }: StrapiContext) => ({ return user ? user.id != undefined : true; }, - sanitizeCommentEntity(entity: Comment | CommentWithRelated, blockedAuthors: string[], omitProps: Array = [], populate: any = {}): Comment { + sanitizeCommentEntity( + entity: Comment | CommentWithRelated, + blockedAuthors: string[], + omitProps: Array = [], + populate: any = {} + ): Comment { const fieldsToPopulate = Array.isArray(populate) ? populate : Object.keys(populate || {}); - return filterItem({ - ...buildAuthorModel( - { - ...entity, - threadOf: isObject(entity.threadOf) ? buildAuthorModel(entity.threadOf, blockedAuthors, fieldsToPopulate) : entity.threadOf, - }, - blockedAuthors, - fieldsToPopulate, - ), - }, omitProps) as Comment; + return filterItem( + { + ...buildAuthorModel( + { + ...entity, + threadOf: isObject(entity.threadOf) + ? buildAuthorModel(entity.threadOf, blockedAuthors, fieldsToPopulate) + : entity.threadOf, + }, + blockedAuthors, + fieldsToPopulate + ), + }, + omitProps + ) as Comment; }, // Find comments in the flat structure @@ -84,7 +92,7 @@ const commonService = ({ strapi }: StrapiContext) => ({ filters = {}, locale, }: clientValidator.FindAllFlatSchema, - relatedEntity?: any, + relatedEntity?: any ): Promise<{ data: Array; pagination?: Pagination; @@ -93,19 +101,19 @@ const commonService = ({ strapi }: StrapiContext) => ({ const defaultSelect = (['id', 'related'] as const).filter((field) => !omit.includes(field)); const populateClause: clientValidator.FindAllFlatSchema['populate'] = { - authorUser: { + authorUser: { populate: true, - avatar: { populate: true } + avatar: { populate: true }, }, ...(isObject(populate) ? populate : {}), }; - const doNotPopulateAuthor = isAdmin ? [] : await this.getConfig(CONFIG_PARAMS.AUTHOR_BLOCKED_PROPS, []); + const doNotPopulateAuthor = isAdmin + ? [] + : await this.getConfig(CONFIG_PARAMS.AUTHOR_BLOCKED_PROPS, []); const [operator, direction] = getOrderBy(sort); const fieldsQuery = { orderBy: { [operator]: direction }, - select: Array.isArray(fields) - ? uniq([...fields, defaultSelect].flat()) - : fields, + select: Array.isArray(fields) ? uniq([...fields, defaultSelect].flat()) : fields, }; const params = { @@ -119,12 +127,15 @@ const commonService = ({ strapi }: StrapiContext) => ({ page: pagination?.page || (skip ? Math.floor(skip / limit) : 1) || 1, }; - const { results: entries, pagination: resultPaginationData } = await getCommentRepository(strapi).findWithCount(params); + const { results: entries, pagination: resultPaginationData } = + await getCommentRepository(strapi).findWithCount(params); const entriesWithThreads = await Promise.all( entries.map(async (_) => { - const { results, pagination: { total } } = await getCommentRepository(strapi) - .findWithCount({ + const { + results, + pagination: { total }, + } = await getCommentRepository(strapi).findWithCount({ where: { threadOf: _.id, }, @@ -134,18 +145,30 @@ const commonService = ({ strapi }: StrapiContext) => ({ itemsInTread: total, firstThreadItemId: first(results)?.id, }; - }), + }) ); - const relatedEntities = omit.includes('related') ? [] : relatedEntity !== null ? [relatedEntity] : await this.findRelatedEntitiesFor([...entries]); + const relatedEntities = omit.includes('related') + ? [] + : relatedEntity !== null + ? [relatedEntity] + : await this.findRelatedEntitiesFor([...entries]); const hasRelatedEntitiesToMap = relatedEntities.filter((_: RelatedEntity) => _).length > 0; const result = entries.map((_) => { const threadedItem = entriesWithThreads.find((item) => item.id === _.id); - const parsedThreadOf = 'threadOf' in filters ? (isString(filters.threadOf) ? parseInt(filters.threadOf) : filters.threadOf) : null; + const parsedThreadOf = + 'threadOf' in filters + ? isString(filters.threadOf) + ? parseInt(filters.threadOf) + : filters.threadOf + : null; let authorUserPopulate = {}; if (isObject(populate?.authorUser)) { - authorUserPopulate = 'populate' in populate.authorUser ? (populate.authorUser.populate) : populateClause.authorUser; + authorUserPopulate = + 'populate' in populate.authorUser + ? populate.authorUser.populate + : populateClause.authorUser; } const primitiveThreadOf = typeof parsedThreadOf === 'number' ? parsedThreadOf : null; @@ -159,12 +182,14 @@ const commonService = ({ strapi }: StrapiContext) => ({ }, doNotPopulateAuthor, omit as Array, - authorUserPopulate, + authorUserPopulate ); }); return { - data: hasRelatedEntitiesToMap ? result.map((_) => this.mergeRelatedEntityTo(_, relatedEntities)) : result, + data: hasRelatedEntitiesToMap + ? result.map((_) => this.mergeRelatedEntityTo(_, relatedEntities)) + : result, pagination: resultPaginationData, }; }, @@ -183,7 +208,7 @@ const commonService = ({ strapi }: StrapiContext) => ({ entry: Comment | CommentWithRelated, relatedEntity?: any, dropBlockedThreads = false, - blockNestedThreads = false, + blockNestedThreads = false ) { if (!entry.gotThread) { return { @@ -209,7 +234,7 @@ const commonService = ({ strapi }: StrapiContext) => ({ locale, limit: Number.MAX_SAFE_INTEGER, }, - relatedEntity, + relatedEntity ); const allChildren = entry.blockedThread && dropBlockedThreads @@ -229,9 +254,9 @@ const commonService = ({ strapi }: StrapiContext) => ({ }, child, relatedEntity, - dropBlockedThreads, - ), - ), + dropBlockedThreads + ) + ) ); return { @@ -258,14 +283,12 @@ const commonService = ({ strapi }: StrapiContext) => ({ limit, pagination, }: clientValidator.FindAllInHierarchyValidatorSchema, - relatedEntity?: any, + relatedEntity?: any ) { const rootEntries = await this.findAllFlat( { filters: { - threadOf: startingFromId - ? { $eq: startingFromId.toString() } - : { $null: true }, + threadOf: startingFromId ? { $eq: startingFromId.toString() } : { $null: true }, ...filters, }, pagination, @@ -277,7 +300,7 @@ const commonService = ({ strapi }: StrapiContext) => ({ locale, limit, }, - relatedEntity, + relatedEntity ); const rootEntriesWithChildren = await Promise.all( @@ -295,9 +318,9 @@ const commonService = ({ strapi }: StrapiContext) => ({ }, entry, relatedEntity, - dropBlockedThreads, - ), - ), + dropBlockedThreads + ) + ) ); return rootEntriesWithChildren; @@ -315,7 +338,10 @@ const commonService = ({ strapi }: StrapiContext) => ({ if (!entity) { throw new PluginError(400, 'Comment does not exist. Check your payload please.'); } - const doNotPopulateAuthor: Array = await this.getConfig(CONFIG_PARAMS.AUTHOR_BLOCKED_PROPS, []); + const doNotPopulateAuthor: Array = await this.getConfig( + CONFIG_PARAMS.AUTHOR_BLOCKED_PROPS, + [] + ); const item = this.sanitizeCommentEntity(entity, doNotPopulateAuthor); return filterOurResolvedReports(item); }, @@ -329,55 +355,67 @@ const commonService = ({ strapi }: StrapiContext) => ({ }, // Find all for author - async findAllPerAuthor({ + async findAllPerAuthor( + { filters = {}, populate = {}, pagination, sort, + omit = [], fields, isAdmin = false, authorId, + limit, + locale, + skip, }: clientValidator.FindAllPerAuthorValidatorSchema, - isStrapiAuthor: boolean = false, + isStrapiAuthor: boolean = false ) { - { - if (isNil(authorId)) { - return { - data: [], - }; - } - - const authorQuery = isStrapiAuthor ? { - authorUser: { - id: authorId, - }, - } : { - authorId, - }; - - const response = await this.findAllFlat({ - filters: { - ...filterItem(filters, ['related']), - ...authorQuery, - }, - pagination, - populate, - sort, - fields, - isAdmin, - }); - + if (isNil(authorId)) { return { - ...response, - data: response.data.map(({ author, ...rest }) => rest), + data: [], }; } + + const authorQuery = isStrapiAuthor + ? { + authorUser: { + id: authorId, + }, + } + : { + authorId, + }; + + const response = await this.findAllFlat({ + filters: { + ...filterItem(filters, ['related']), + ...authorQuery, + }, + pagination, + populate, + sort, + fields, + isAdmin, + omit, + limit, + skip, + locale, + }); + + return { + ...response, + data: response.data.map(({ author, ...rest }) => rest), + }; }, // Find all related entiries async findRelatedEntitiesFor(entries: Array): Promise> { const data = entries.reduce( - (acc: { [key: string]: { documentIds: Array, locale?: Array } }, curr: Comment) => { + ( + acc: { [key: string]: { documentIds: Array; locale?: Array } }, + curr: Comment + ) => { const [relatedUid, relatedStringId] = getRelatedGroups(curr.related); return { ...acc, @@ -388,47 +426,56 @@ const commonService = ({ strapi }: StrapiContext) => ({ }, }; }, - {}, + {} ); return Promise.all( - Object.entries(data).map( - async ([relatedUid, { documentIds, locale }]) => { - return Promise.all( - documentIds.map((documentId, index) => - strapi.documents(relatedUid as ContentTypesUUIDs).findOne({ - documentId: documentId.toString(), - locale: !isNil(locale[index]) ? locale[index] : undefined, - status: 'published', - }), - ), - ).then((relatedEntities) => relatedEntities - .filter(_ => _).map((_) => ({ + Object.entries(data).map(async ([relatedUid, { documentIds, locale }]) => { + return Promise.all( + documentIds.map((documentId, index) => + strapi.documents(relatedUid as ContentTypesUUIDs).findOne({ + documentId: documentId.toString(), + locale: !isNil(locale[index]) ? locale[index] : undefined, + status: 'published', + }) + ) + ).then((relatedEntities) => + relatedEntities + .filter((_) => _) + .map((_) => ({ ..._, uid: relatedUid, - })), - ); - }, - ), + })) + ); + }) ).then((result) => result.flat(2)); }, // Merge related entity with comment - mergeRelatedEntityTo(entity: Comment, relatedEntities: Array = []): CommentWithRelated { + mergeRelatedEntityTo( + entity: Comment, + relatedEntities: Array = [] + ): CommentWithRelated { return { ...entity, - related: relatedEntities.find( - (relatedEntity) => { - if (relatedEntity.locale && entity.locale) { - return entity.related === `${relatedEntity.uid}:${relatedEntity.documentId}` && entity.locale === relatedEntity.locale; - } - return entity.related === `${relatedEntity.uid}:${relatedEntity.documentId}`; - }, - ), + related: relatedEntities.find((relatedEntity) => { + if (relatedEntity.locale && entity.locale) { + return ( + entity.related === `${relatedEntity.uid}:${relatedEntity.documentId}` && + entity.locale === relatedEntity.locale + ); + } + return entity.related === `${relatedEntity.uid}:${relatedEntity.documentId}`; + }), }; }, // TODO: we need to add deepLimit to the function to prevent infinite loops - async modifiedNestedNestedComments(id: Id, fieldName: T, value: Comment[T], deepLimit: number = 10): Promise { + async modifiedNestedNestedComments( + id: Id, + fieldName: T, + value: Comment[T], + deepLimit: number = 10 + ): Promise { if (deepLimit === 0) { return true; } @@ -441,7 +488,8 @@ const commonService = ({ strapi }: StrapiContext) => ({ if (entities.length === changedEntries.count && changedEntries.count > 0) { const nestedTransactions = await Promise.all( entities.map((item) => - this.modifiedNestedNestedComments(item.id, fieldName, value, deepLimit - 1)), + this.modifiedNestedNestedComments(item.id, fieldName, value, deepLimit - 1) + ) ); return nestedTransactions.length === changedEntries.count; } @@ -455,49 +503,68 @@ const commonService = ({ strapi }: StrapiContext) => ({ const config = await this.getConfig(CONFIG_PARAMS.BAD_WORDS, true); if (config) { if (content && isProfane({ testString: content })) { - throw new PluginError( - 400, - "Bad language used! Please polite your comment...", - { - content: { - original: content, - filtered: content && replaceProfanities({ testString: content }), - }, + throw new PluginError(400, 'Bad language used! Please polite your comment...', { + content: { + original: content, + filtered: content && replaceProfanities({ testString: content }), }, - ); + }); } } return content; }, async perRemove(related: string, locale?: string) { - const defaultLocale = await strapi.plugin('i18n')?.service('locales').getDefaultLocale() || null; - return getCommentRepository(strapi) - .updateMany({ + const defaultLocale = + (await strapi.plugin('i18n')?.service('locales').getDefaultLocale()) || null; + return getCommentRepository(strapi).updateMany({ where: { related, - $or: [{ locale }, defaultLocale === locale ? { locale: { $eq: null } } : null].filter(Boolean) + $or: [{ locale }, defaultLocale === locale ? { locale: { $eq: null } } : null].filter( + Boolean + ), }, data: { removed: true, - } + }, }); }, sanitizeCommentContent(content: string) { return sanitizeHtml(content, { allowedTags: [ - 'p', 'br', 'hr', - 'div', 'span', - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'strong', 'i', 'em', 'del', + 'p', + 'br', + 'hr', + 'div', + 'span', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'strong', + 'i', + 'em', + 'del', 'blockquote', - 'ul', 'ol', 'li', - 'pre', 'code', + 'ul', + 'ol', + 'li', + 'pre', + 'code', 'a', 'img', - 'table', 'thead', 'tbody', 'tr', 'th', 'td', - 'video', 'audio', 'source', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'video', + 'audio', + 'source', ], allowedAttributes: { '*': ['href', 'align', 'alt', 'center', 'width', 'height', 'type', 'controls', 'target'], @@ -510,11 +577,9 @@ const commonService = ({ strapi }: StrapiContext) => ({ }); }, - registerLifecycleHook(/*{ callback, contentTypeName, hookName }*/) { - }, + registerLifecycleHook(/*{ callback, contentTypeName, hookName }*/) {}, - async runLifecycleHook(/*{ contentTypeName, event, hookName }*/) { - }, + async runLifecycleHook(/*{ contentTypeName, event, hookName }*/) {}, }); type CommonService = ReturnType; From fc7b5a61921fd7c1cba9bfaabb750a97c051a93e Mon Sep 17 00:00:00 2001 From: Mikolaj Lewandowski Date: Sun, 15 Mar 2026 15:50:09 +0100 Subject: [PATCH 2/2] test: improve common.service coverage --- .../services/__tests__/common.service.test.ts | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/server/src/services/__tests__/common.service.test.ts b/server/src/services/__tests__/common.service.test.ts index 1363e06..260093e 100644 --- a/server/src/services/__tests__/common.service.test.ts +++ b/server/src/services/__tests__/common.service.test.ts @@ -173,6 +173,15 @@ describe('common.service', () => { expect(result).toBe(false); }); + + it('should return true when no user is provided', () => { + const strapi = getStrapi(); + const service = getService(strapi); + + const result = service.isValidUserContext(); + + expect(result).toBe(true); + }); }); describe('findOne', () => { @@ -293,6 +302,171 @@ describe('common.service', () => { expect(result.data).toHaveLength(2); expect(mockCommentRepository.findWithCount).toHaveBeenCalled(); }); + it('should merge related entity when relatedEntity is provided', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const mockComments = [{ id: 1, content: 'Comment 1', related: 'api::test.test:1' }]; + + const relatedEntity = { + documentId: '1', + uid: 'api::test.test', + title: 'Test', + }; + + mockCommentRepository.findWithCount.mockResolvedValue({ + results: mockComments, + pagination: { total: 1 }, + }); + mockStoreRepository.getConfig.mockResolvedValue([]); + caster(getOrderBy).mockReturnValue(['createdAt', 'desc']); + + const result = await service.findAllFlat( + { + fields: ['id', 'content'], + }, + relatedEntity + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].related).toEqual(relatedEntity); + }); + it('should handle authorUser populate and threadOf filter', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const mockComments = [{ id: 1, content: 'Comment 1', threadOf: '5' }]; + + mockCommentRepository.findWithCount.mockResolvedValue({ + results: mockComments, + pagination: { total: 1 }, + }); + mockStoreRepository.getConfig.mockResolvedValue([]); + caster(getOrderBy).mockReturnValue(['createdAt', 'desc']); + + const result = await service.findAllFlat({ + fields: ['id', 'content'], + filters: { threadOf: '5' }, + populate: { + authorUser: { + populate: true, + }, + }, + }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].threadOf).toBe(5); + }); + it('should use pagination pageSize and page when provided', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + + mockCommentRepository.findWithCount.mockResolvedValue({ + results: [], + pagination: { total: 0 }, + }); + mockStoreRepository.getConfig.mockResolvedValue([]); + caster(getOrderBy).mockReturnValue(['createdAt', 'desc']); + + await service.findAllFlat({ + fields: ['id', 'content'], + pagination: { pageSize: 20, page: 3 }, + }); + + expect(mockCommentRepository.findWithCount).toHaveBeenCalledWith( + expect.objectContaining({ + pageSize: 20, + page: 3, + }) + ); + }); + it('should fetch related entities when relatedEntity is null', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const mockComments = [ + { id: 1, content: 'Comment 1', related: 'api::test.test:1', locale: 'en' }, + ]; + + mockCommentRepository.findWithCount.mockResolvedValue({ + results: mockComments, + pagination: { total: 1 }, + }); + + mockFindOne.mockResolvedValue({ + documentId: '1', + uid: 'api::test.test', + title: 'Test', + locale: 'en', + }); + + const result = await service.findAllFlat({ fields: ['id', 'content'] }, null); + + expect(result.data).toHaveLength(1); + expect(mockFindOne).toHaveBeenCalled(); + }); + it('should skip blocked author props when isAdmin is true', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + + mockCommentRepository.findWithCount.mockResolvedValue({ + results: [], + pagination: { total: 0 }, + }); + caster(getOrderBy).mockReturnValue(['createdAt', 'desc']); + + await service.findAllFlat({ + fields: ['id', 'content'], + isAdmin: true, + }); + + expect(mockStoreRepository.getConfig).not.toHaveBeenCalled(); + }); + it('should handle populated threadOf object', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const mockComments = [ + { + id: 1, + content: 'Reply', + threadOf: { id: 5, content: 'Parent', authorName: 'John' }, + }, + ]; + + mockCommentRepository.findWithCount.mockResolvedValue({ + results: mockComments, + pagination: { total: 1 }, + }); + mockStoreRepository.getConfig.mockResolvedValue([]); + caster(getOrderBy).mockReturnValue(['createdAt', 'desc']); + + const result = await service.findAllFlat({ + fields: ['id', 'content'], + }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].threadOf).toBeDefined(); + }); + }); + + describe('sanitizeCommentEntity', () => { + it('should handle populate as an array', () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { id: 1, content: 'Test', authorName: 'John' }; + + const result = service.sanitizeCommentEntity(entity, [], [], ['authorUser']); + + expect(result).toBeDefined(); + expect(result.id).toBe(1); + }); + it('should handle populate as an object', () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { id: 1, content: 'Test', authorName: 'John' }; + + const result = service.sanitizeCommentEntity(entity, [], [], { authorUser: true }); + + expect(result).toBeDefined(); + expect(result.id).toBe(1); + }); }); describe('modifiedNestedNestedComments', () => { @@ -721,6 +895,26 @@ describe('common.service', () => { expect(result).toHaveLength(0); }); + + it('should handle comments with null locale', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const mockComments = [{ id: 1, related: 'api::test.test:1', locale: null }]; + + mockFindOne.mockResolvedValue({ + documentId: '1', + uid: 'api::test.test', + title: 'Test', + }); + + await service.findRelatedEntitiesFor(mockComments); + + expect(mockFindOne).toHaveBeenCalledWith( + expect.objectContaining({ + locale: undefined, + }) + ); + }); }); describe('Handle entity updates', () => { @@ -748,5 +942,47 @@ describe('common.service', () => { expect(result).toEqual({ count: 2 }); expect(mockCommentRepository.updateMany).toHaveBeenCalled(); }); + it('should mark comments as removed for given relation and locale', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + + mockCommentRepository.updateMany.mockResolvedValue({ count: 1 }); + + await service.perRemove('api::test.test:1', 'en'); + + expect(mockCommentRepository.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + related: 'api::test.test:1', + }), + data: { removed: true }, + }) + ); + }); + it('should include null locale filter when locale matches default locale', async () => { + const strapiWithI18n = caster({ + strapi: { + documents: () => ({ + findOne: mockFindOne, + findMany: mockFindMany, + }), + plugin: (name: string) => + name === 'i18n' ? { service: () => ({ getDefaultLocale: () => 'en' }) } : null, + }, + }); + const service = getService(strapiWithI18n); + + mockCommentRepository.updateMany.mockResolvedValue({ count: 1 }); + + await service.perRemove('api::test.test:1', 'en'); + + expect(mockCommentRepository.updateMany).toHaveBeenCalledWith({ + where: { + related: 'api::test.test:1', + $or: [{ locale: 'en' }, { locale: { $eq: null } }], + }, + data: { removed: true }, + }); + }); }); });