From 781bfdd60f1f93be5824d1c9c5802fb077c4ff34 Mon Sep 17 00:00:00 2001 From: Ronald Langeveld Date: Tue, 26 Nov 2024 16:56:17 +0800 Subject: [PATCH] Wired up admin api for hidden comments (#21724) ref PLG-270 - Updated the getCommentByID service to filter out hidden and deleted replies. - Ensured all replies are loaded before applying the filter. - Simplified logic to handle non-paginated routes by directly removing unwanted replies. - Wired up new Admin Endpoint that shows hidden replies but not deleted replies. - Updated comments-ui client - Added unit tests for mocking apiClient event listeners. - added eventlistener playwright tests to ensure it fires on UI clicks. --- apps/comments-ui/package.json | 2 +- apps/comments-ui/src/actions.ts | 8 +- apps/comments-ui/src/utils/adminAPI.test.ts | 333 ++++++++++++++++++ apps/comments-ui/src/utils/adminApi.ts | 5 + apps/comments-ui/test/e2e/auth-frame.test.ts | 102 +++++- apps/comments-ui/test/utils/e2e.ts | 28 +- .../src/admin-auth/message-handler.js | 11 + .../server/api/endpoints/comment-replies.js | 23 ++ .../services/comments/CommentsService.js | 23 ++ .../server/web/api/endpoints/admin/routes.js | 1 + ghost/core/core/shared/config/defaults.json | 2 +- .../core/test/e2e-api/admin/comments.test.js | 91 +++++ 12 files changed, 624 insertions(+), 5 deletions(-) create mode 100644 apps/comments-ui/src/utils/adminAPI.test.ts diff --git a/apps/comments-ui/package.json b/apps/comments-ui/package.json index a0cf7157ae..8cad235173 100644 --- a/apps/comments-ui/package.json +++ b/apps/comments-ui/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/comments-ui", - "version": "0.22.4", + "version": "0.23.1", "license": "MIT", "repository": "git@github.com:TryGhost/comments-ui.git", "author": "Ghost Foundation", diff --git a/apps/comments-ui/src/actions.ts b/apps/comments-ui/src/actions.ts index 5a88266f8a..3993f83e75 100644 --- a/apps/comments-ui/src/actions.ts +++ b/apps/comments-ui/src/actions.ts @@ -139,7 +139,13 @@ async function showComment({state, api, data: comment}: {state: EditableAppConte } // We need to refetch the comment, to make sure we have an up to date HTML content // + all relations are loaded as the current member (not the admin) - const data = await api.comments.read(comment.id); + let data; + if (state.admin && state.adminApi && state.labs.commentImprovements) { + data = await state.adminApi.read({commentId: comment.id}); + } else { + data = await api.comments.read(comment.id); + } + const updatedComment = data.comments[0]; return { diff --git a/apps/comments-ui/src/utils/adminAPI.test.ts b/apps/comments-ui/src/utils/adminAPI.test.ts new file mode 100644 index 0000000000..61404fff18 --- /dev/null +++ b/apps/comments-ui/src/utils/adminAPI.test.ts @@ -0,0 +1,333 @@ +import * as vi from 'vitest'; +import {setupAdminAPI} from './adminApi'; + +describe('setupAdminAPI', () => { + let addEventListenerSpy: vi.SpyInstance; + let postMessageMock: vi.Mock; + let frame: HTMLIFrameElement; + + beforeEach(() => { + frame = document.createElement('iframe'); + frame.dataset.frame = 'admin-auth'; + Object.defineProperty(frame, 'contentWindow', { + value: { + postMessage: vi.vitest.fn() + }, + writable: false + }); + + document.body.appendChild(frame); + + // Mock window.addEventListener - at runtime this gets injected into the theme. + // from here https://github.com/TryGhost/Ghost/blob/main/ghost/core/core/frontend/src/admin-auth/message-handler.js + // In which case, we have to mock it in order to test it. + addEventListenerSpy = vi.vitest.spyOn(window, 'addEventListener'); + postMessageMock = frame.contentWindow!.postMessage as vi.Mock; + }); + + afterEach(() => { + // Restore mocks and remove iframe + vi.vitest.restoreAllMocks(); + frame.remove(); + }); + + it('can call getUser', async () => { + const adminUrl = 'https://example.com'; + const api = setupAdminAPI({adminUrl}); + + const apiPromise = api.getUser(); + + const eventHandler = addEventListenerSpy.mock.calls.find( + ([eventType]) => eventType === 'message' + )?.[1]; + + const mockEvent = new MessageEvent('message', { + origin: new URL(adminUrl).origin, + data: JSON.stringify({ + uid: 2, + result: { + users: [{id: 1, name: 'Test User'}] + } + }) + }); + + eventHandler!(mockEvent); + + const user = await apiPromise; + + expect(user).toEqual({id: 1, name: 'Test User'}); + + expect(postMessageMock).toHaveBeenCalledWith( + JSON.stringify({uid: 2, action: 'getUser'}), + new URL(adminUrl).origin + ); + }); + + it('can call hideComment', async () => { + const adminUrl = 'https://example.com'; + const api = setupAdminAPI({adminUrl}); + + const apiPromise = api.hideComment('123'); + + const eventHandler = addEventListenerSpy.mock.calls.find( + ([eventType]) => eventType === 'message' + )?.[1]; + + const mockEvent = new MessageEvent('message', { + origin: new URL(adminUrl).origin, + data: JSON.stringify({ + uid: 2, + result: {success: true} // not the actual endpoint, we're just testing the event listener + }) + }); + + eventHandler!(mockEvent); + + const result = await apiPromise; + + expect(result).toEqual({success: true}); + + expect(postMessageMock).toHaveBeenCalledWith( + JSON.stringify({uid: 2, action: 'hideComment', id: '123'}), + new URL(adminUrl).origin + ); + }); + + it('can call showComment', async () => { + const adminUrl = 'https://example.com'; + const api = setupAdminAPI({adminUrl}); + + const apiPromise = api.showComment('123'); + + const eventHandler = addEventListenerSpy.mock.calls.find( + ([eventType]) => eventType === 'message' + )?.[1]; + + const mockEvent = new MessageEvent('message', { + origin: new URL(adminUrl).origin, + data: JSON.stringify({ + uid: 2, + result: {success: true} // not the actual data, we're just testing the event listener and functions execution + }) + }); + + eventHandler!(mockEvent); + + const result = await apiPromise; + + expect(result).toEqual({success: true}); + + expect(postMessageMock).toHaveBeenCalledWith( + JSON.stringify({uid: 2, action: 'showComment', id: '123'}), + new URL(adminUrl).origin + ); + }); + + it('can call browse', async () => { + const adminUrl = 'https://example.com'; + const api = setupAdminAPI({adminUrl}); + + const apiPromise = api.browse({page: 1, postId: '123', order: 'asc'}); + + const eventHandler = addEventListenerSpy.mock.calls.find( + ([eventType]) => eventType === 'message' + )?.[1]; + + const mockEvent = new MessageEvent('message', { + origin: new URL(adminUrl).origin, + data: JSON.stringify({ + uid: 2, + result: { + comments: [{id: 1, body: 'Test Comment'}], + meta: { + pagination: { + page: 1, + limit: 15, + pages: 1, + total: 1 + } + } + } + }) + }); + + eventHandler!(mockEvent); + + const result = await apiPromise; + + expect(result).toEqual({ + comments: [{id: 1, body: 'Test Comment'}], + meta: { + pagination: { + page: 1, + limit: 15, + pages: 1, + total: 1 + } + } + }); + + expect(postMessageMock).toHaveBeenCalledWith( + JSON.stringify({uid: 2, action: 'browseComments', postId: '123', params: 'limit=20&page=1&order=asc'}), + new URL(adminUrl).origin + ); + }); + + it('can call replies', async () => { + const adminUrl = 'https://example.com'; + const api = setupAdminAPI({adminUrl}); + + const apiPromise = api.replies({commentId: '123', afterReplyId: '456', limit: 10}); + + const eventHandler = addEventListenerSpy.mock.calls.find( + ([eventType]) => eventType === 'message' + )?.[1]; + + const mockEvent = new MessageEvent('message', { + origin: new URL(adminUrl).origin, + data: JSON.stringify({ + uid: 2, + result: { + comments: [{id: 1, body: 'Test Reply'}] + } + }) + }); + + eventHandler!(mockEvent); + + const result = await apiPromise; + + expect(result).toEqual({ + comments: [{id: 1, body: 'Test Reply'}] + }); + + expect(postMessageMock).toHaveBeenCalledWith( + JSON.stringify({ + uid: 2, + action: 'getReplies', + commentId: '123', + params: 'limit=10&filter=id%3A%3E%27456%27' + }), + new URL(adminUrl).origin + ); + }); + + it('can call read', async () => { + const adminUrl = 'https://example.com'; + const api = setupAdminAPI({adminUrl}); + + const apiPromise = api.read({commentId: '123'}); + + const eventHandler = addEventListenerSpy.mock.calls.find( + ([eventType]) => eventType === 'message' + )?.[1]; + + const mockEvent = new MessageEvent('message', { + origin: new URL(adminUrl).origin, + data: JSON.stringify({ + uid: 2, + result: { + comments: [{id: 1, body: 'Test Comment'}] + } + }) + }); + + eventHandler!(mockEvent); + + const result = await apiPromise; + + expect(result).toEqual({comments: [{id: 1, body: 'Test Comment'}]}); + + expect(postMessageMock).toHaveBeenCalledWith( + JSON.stringify({uid: 2, action: 'readComment', commentId: '123'}), + new URL(adminUrl).origin + ); + }); + + it('should call postMessage with the correct data on API call', async () => { + const adminUrl = 'https://example.com'; + const api = setupAdminAPI({adminUrl}); + + // Simulate an API call + const apiPromise = api.getUser(); + + // Simulate a message event to resolve the promise + const eventHandler = addEventListenerSpy.mock.calls.find( + ([eventType]) => eventType === 'message' + )?.[1]; + + const mockEvent = new MessageEvent('message', { + origin: new URL(adminUrl).origin, + data: JSON.stringify({ + uid: 2, // Mock UID from the handler + result: { + users: [{id: 1, name: 'Test User'}] + } + }) + }); + + // Trigger the event handler manually + eventHandler!(mockEvent); + + // Await the result + const user = await apiPromise; + + expect(user).toEqual({id: 1, name: 'Test User'}); + expect(postMessageMock).toHaveBeenCalledWith( + JSON.stringify({uid: 2, action: 'getUser'}), + new URL(adminUrl).origin + ); + }); + + it('should reject the promise if an error occurs', async () => { + const adminUrl = 'https://example.com'; + const api = setupAdminAPI({adminUrl}); + + // Simulate an API call + const apiPromise = api.getUser(); + + // Simulate a message event with an error + const eventHandler = addEventListenerSpy.mock.calls.find( + ([eventType]) => eventType === 'message' + )?.[1]; + + const mockEvent = new MessageEvent('message', { + origin: new URL(adminUrl).origin, + data: JSON.stringify({ + uid: 2, // Mock UID from the handler + error: {message: 'Test Error'} + }) + }); + + // Trigger the event handler manually + eventHandler!(mockEvent); + + await expect(apiPromise).rejects.toEqual({message: 'Test Error'}); + }); + + it('should ignore messages from an invalid origin', async () => { + const adminUrl = 'https://example.com'; + const api = setupAdminAPI({adminUrl}); + + const apiPromise = api.getUser(); + + // Simulate a message event from an invalid origin + const eventHandler = addEventListenerSpy.mock.calls.find( + ([eventType]) => eventType === 'message' + )?.[1]; + + const mockEvent = new MessageEvent('message', { + origin: 'https://invalid.com', + data: JSON.stringify({ + uid: 2, + result: {users: [{id: 1, name: 'Invalid User'}]} + }) + }); + + // Trigger the event handler manually + eventHandler!(mockEvent); + + // Ensure the promise doesn't resolve + await expect(Promise.race([apiPromise, Promise.resolve('unresolved')])).resolves.toBe('unresolved'); + }); +}); diff --git a/apps/comments-ui/src/utils/adminApi.ts b/apps/comments-ui/src/utils/adminApi.ts index 07f7638189..3ed39e174a 100644 --- a/apps/comments-ui/src/utils/adminApi.ts +++ b/apps/comments-ui/src/utils/adminApi.ts @@ -105,6 +105,11 @@ export function setupAdminAPI({adminUrl}: {adminUrl: string}) { const response = await callApi('getReplies', {commentId, params: params.toString()}); + return response; + }, + + async read({commentId}: {commentId: string}) { + const response = await callApi('readComment', {commentId}); return response; } }; diff --git a/apps/comments-ui/test/e2e/auth-frame.test.ts b/apps/comments-ui/test/e2e/auth-frame.test.ts index bd7aa082a3..7344493b5c 100644 --- a/apps/comments-ui/test/e2e/auth-frame.test.ts +++ b/apps/comments-ui/test/e2e/auth-frame.test.ts @@ -240,5 +240,105 @@ test.describe('Auth Frame', async () => { await moreButtons.nth(1).getByText('Show comment').click(); await expect(secondComment).toContainText('This is comment 2'); }); -}); + test('authFrameMain fires getUser (exposed function)', async ({page}) => { + const mockedApi = new MockedApi({}); + mockedApi.addComment({ + html: '

This is comment 1

' + }); + mockedApi.addComment({ + html: '

This is comment 2

' + }); + mockedApi.addComment({ + html: '

This is comment 3

' + }); + mockedApi.addComment({ + html: '

This is comment 4

' + }); + mockedApi.addComment({ + html: '

This is comment 5

' + }); + + const actions: string[] = []; + + await page.exposeFunction('__testHelper', (action: string) => { + actions.push(action); + }); + + await mockAdminAuthFrame({ + admin, + page + }); + + await initialize({ + mockedApi, + page, + publication: 'Publisher Weekly', + admin, + labs: { + commentImprovements: true + } + }); + + // Trigger the message event + await page.evaluate(() => { + const event = new MessageEvent('message', { + data: JSON.stringify({uid: 'test', action: 'getUser'}), + origin: 'https://localhost:1234' + }); + window.dispatchEvent(event); + }); + + // Validate that "getUser" was captured + expect(actions).toContain('getUser'); + }); + + test('fires admin read when making a hidden comment visible', async ({page}) => { + const mockedApi = new MockedApi({}); + + mockedApi.addComment({ + html: '

This is comment 1

' + }); + mockedApi.addComment({ + html: '

This is comment 2

', + status: 'hidden' + }); + + const actions: string[] = []; + + await page.exposeFunction('__testHelper', (action: string) => { + actions.push(action); + }); + + await mockAdminAuthFrame({ + admin, + page + }); + + const {frame} = await initialize({ + mockedApi, + page, + publication: 'Publisher Weekly', + admin, + labs: { + commentImprovements: true + } + }); + + const iframeElement = await page.locator('iframe[data-frame="admin-auth"]'); + await expect(iframeElement).toHaveCount(1); + + // Check if more actions button is visible on each comment + const comments = await frame.getByTestId('comment-component'); + await expect(comments).toHaveCount(2); + + const moreButtons = await frame.getByTestId('more-button'); + await expect(moreButtons).toHaveCount(2); + + // Click the 2nd button + await moreButtons.nth(1).click(); + await moreButtons.nth(1).getByText('Show comment').click(); + + await expect(actions).toContain('readComment'); + }); +}); diff --git a/apps/comments-ui/test/utils/e2e.ts b/apps/comments-ui/test/utils/e2e.ts index 151ecfd16c..e522338133 100644 --- a/apps/comments-ui/test/utils/e2e.ts +++ b/apps/comments-ui/test/utils/e2e.ts @@ -1,6 +1,6 @@ import {E2E_PORT} from '../../playwright.config'; -import {LabsType, MockedApi} from './MockedApi'; import {Locator, Page} from '@playwright/test'; +import {MockedApi} from './MockedApi'; import {expect} from '@playwright/test'; export const MOCKED_SITE_URL = 'https://localhost:1234'; @@ -21,6 +21,12 @@ function escapeHtml(unsafe: string) { .replace(/>/g, '>'); } +declare global { + interface Window { + __testHelper?: (action: string) => void; + } +} + function authFrameMain() { window.addEventListener('message', function (event) { let d = null; @@ -44,6 +50,9 @@ function authFrameMain() { } if (data.action === 'getUser') { + if (window.__testHelper) { + window.__testHelper('getUser'); + } try { respond(null, { users: [ @@ -58,6 +67,23 @@ function authFrameMain() { return; } + if (data.action === 'readComment') { + if (window.__testHelper) { + window.__testHelper('readComment'); + } + try { + respond(null, { + comment: { + id: 'comment-id', + html: '

This is a comment

' + } + }); + } catch (err) { + respond(err, null); + } + return; + } + // Other actions: return empty object try { respond(null, {}); diff --git a/ghost/core/core/frontend/src/admin-auth/message-handler.js b/ghost/core/core/frontend/src/admin-auth/message-handler.js index bfc06843ea..b108959f4a 100644 --- a/ghost/core/core/frontend/src/admin-auth/message-handler.js +++ b/ghost/core/core/frontend/src/admin-auth/message-handler.js @@ -49,6 +49,17 @@ window.addEventListener('message', async function (event) { } } + if (data.action === 'readComment') { + try { + const {commentId} = data; + const res = await fetch(adminUrl + '/comments/' + commentId + '/'); + const json = await res.json(); + respond(null, json); + } catch (err) { + respond(err, null); + } + } + if (data.action === 'getUser') { try { const res = await fetch( diff --git a/ghost/core/core/server/api/endpoints/comment-replies.js b/ghost/core/core/server/api/endpoints/comment-replies.js index d44eb6d56a..662d6d5a48 100644 --- a/ghost/core/core/server/api/endpoints/comment-replies.js +++ b/ghost/core/core/server/api/endpoints/comment-replies.js @@ -1,6 +1,7 @@ // This is a new endpoint for the admin API to return replies to a comment with pagination const commentsService = require('../../services/comments'); +const ALLOWED_INCLUDES = ['member', 'replies', 'replies.member', 'replies.count.likes', 'replies.liked', 'count.replies', 'count.likes', 'liked', 'post', 'parent']; /** @type {import('@tryghost/api-framework').Controller} */ const controller = { @@ -30,6 +31,28 @@ const controller = { query(frame) { return commentsService.controller.adminReplies(frame); } + }, + read: { + headers: { + cacheInvalidate: false + }, + options: [ + 'include' + ], + data: [ + 'id', + 'email' + ], + validation: { + options: { + include: ALLOWED_INCLUDES + } + }, + permissions: true, + query(frame) { + frame.options.isAdmin = true; + return commentsService.controller.read(frame); + } } }; diff --git a/ghost/core/core/server/services/comments/CommentsService.js b/ghost/core/core/server/services/comments/CommentsService.js index 9aa160deb0..61863870dc 100644 --- a/ghost/core/core/server/services/comments/CommentsService.js +++ b/ghost/core/core/server/services/comments/CommentsService.js @@ -2,6 +2,7 @@ const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); const {MemberCommentEvent} = require('@tryghost/member-events'); const DomainEvents = require('@tryghost/domain-events'); +const labs = require('../../../shared/labs'); const messages = { commentNotFound: 'Comment could not be found', @@ -208,6 +209,28 @@ class CommentsService { }); } + if (labs.isSet('commentImprovements')) { + const replies = model.related('replies'); // Get the loaded replies relation + await replies.fetch({withRelated: ['member', 'count.likes']}); // Fetch all replies + + if (replies && replies.length > 0) { + // Filter out deleted replies for all, and hidden replies for non-admins + replies.remove( + replies.filter((reply) => { + const status = reply.get('status'); + if (status === 'deleted') { + return true; + } // Always remove deleted replies + if (!options.isAdmin && status === 'hidden') { + return true; + } // Remove hidden replies for non-admins + return false; // Keep others + }) + ); + } + } + + // this route does not need to handle pagination, so we can remove hidden/deleted replies here return model; } diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index a6bb06ee11..de241ce036 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -51,6 +51,7 @@ module.exports = function apiRoutes() { router.get('/mentions', mw.authAdminApi, http(api.mentions.browse)); + router.get('/comments/:id', mw.authAdminApi, http(api.commentReplies.read)); router.get('/comments/:id/replies', mw.authAdminApi, http(api.commentReplies.browse)); router.get('/comments/post/:post_id', mw.authAdminApi, http(api.comments.browse)); router.put('/comments/:id', mw.authAdminApi, http(api.comments.edit)); diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index 7ab03bada9..20da96f7d8 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -210,7 +210,7 @@ }, "comments": { "url": "https://cdn.jsdelivr.net/ghost/comments-ui@~{version}/umd/comments-ui.min.js", - "version": "0.22" + "version": "0.23" }, "signupForm": { "url": "https://cdn.jsdelivr.net/ghost/signup-form@~{version}/umd/signup-form.min.js", diff --git a/ghost/core/test/e2e-api/admin/comments.test.js b/ghost/core/test/e2e-api/admin/comments.test.js index 01858ae172..362f8eb5dc 100644 --- a/ghost/core/test/e2e-api/admin/comments.test.js +++ b/ghost/core/test/e2e-api/admin/comments.test.js @@ -458,6 +458,97 @@ describe('Admin Comments API', function () { assert.equal(res2.body.comments[0].html, 'Reply 4'); }); + it('Does not return deleted replies', async function () { + const post = fixtureManager.get('posts', 1); + await mockManager.mockLabsEnabled('commentImprovements'); + const {parent} = await dbFns.addCommentWithReplies({ + post_id: post.id, + member_id: fixtureManager.get('members', 0).id, + replies: [{ + member_id: fixtureManager.get('members', 1).id, + status: 'hidden' + }, { + member_id: fixtureManager.get('members', 2).id, + status: 'deleted' + }, + { + member_id: fixtureManager.get('members', 3).id, + status: 'hidden' + }, + { + member_id: fixtureManager.get('members', 4).id, + status: 'published' + } + ] + }); + + const res = await adminApi.get(`/comments/${parent.get('id')}/`); + res.body.comments[0].replies.length.should.eql(3); + + res.body.comments[0].replies[0].member.should.be.an.Object().with.properties('id', 'uuid', 'name', 'avatar_image'); + + res.body.comments[0].replies[0].should.be.an.Object().with.properties('id', 'html', 'status', 'created_at', 'member', 'count'); + }); + + it('Does return published replies', async function () { + const post = fixtureManager.get('posts', 1); + await mockManager.mockLabsEnabled('commentImprovements'); + const {parent} = await dbFns.addCommentWithReplies({ + post_id: post.id, + member_id: fixtureManager.get('members', 0).id, + replies: [{ + member_id: fixtureManager.get('members', 1).id, + status: 'published' + }, { + member_id: fixtureManager.get('members', 2).id, + status: 'published' + }, + { + member_id: fixtureManager.get('members', 3).id, + status: 'published' + } + ] + }); + + const res = await adminApi.get(`/comments/${parent.get('id')}/`); + res.body.comments[0].replies.length.should.eql(3); + res.body.comments[0].replies[0].member.should.be.an.Object().with.properties('id', 'uuid', 'name', 'avatar_image'); + res.body.comments[0].replies[0].should.be.an.Object().with.properties('id', 'html', 'status', 'created_at', 'member', 'count'); + }); + + it('Does return published and hidden replies but not deleted', async function () { + const post = fixtureManager.get('posts', 1); + await mockManager.mockLabsEnabled('commentImprovements'); + const {parent} = await dbFns.addCommentWithReplies({ + post_id: post.id, + member_id: fixtureManager.get('members', 0).id, + replies: [{ + member_id: fixtureManager.get('members', 1).id, + status: 'published' + }, { + member_id: fixtureManager.get('members', 2).id, + status: 'published' + }, + { + member_id: fixtureManager.get('members', 3).id, + status: 'published' + }, + { + member_id: fixtureManager.get('members', 4).id, + status: 'hidden' + }, + { + member_id: fixtureManager.get('members', 5).id, + status: 'deleted' + } + ] + }); + const res = await adminApi.get(`/comments/${parent.get('id')}/`); + res.body.comments[0].replies.length.should.eql(4); + res.body.comments[0].replies[0].member.should.be.an.Object().with.properties('id', 'uuid', 'name', 'avatar_image'); + res.body.comments[0].replies[0].should.be.an.Object().with.properties('id', 'html', 'status', 'created_at', 'member', 'count'); + }); + it('ensure replies are always ordered from oldest to newest', async function () { const post = fixtureManager.get('posts', 1); const {parent} = await dbFns.addCommentWithReplies({