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.
This commit is contained in:
Ronald Langeveld 2024-11-26 16:56:17 +08:00 committed by GitHub
parent 63c210199f
commit 781bfdd60f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 624 additions and 5 deletions

View File

@ -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",

View File

@ -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 {

View File

@ -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');
});
});

View File

@ -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;
}
};

View File

@ -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: '<p>This is comment 1</p>'
});
mockedApi.addComment({
html: '<p>This is comment 2</p>'
});
mockedApi.addComment({
html: '<p>This is comment 3</p>'
});
mockedApi.addComment({
html: '<p>This is comment 4</p>'
});
mockedApi.addComment({
html: '<p>This is comment 5</p>'
});
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: '<p>This is comment 1</p>'
});
mockedApi.addComment({
html: '<p>This is comment 2</p>',
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');
});
});

View File

@ -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, '&gt;');
}
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: '<p>This is a comment</p>'
}
});
} catch (err) {
respond(err, null);
}
return;
}
// Other actions: return empty object
try {
respond(null, {});

View File

@ -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(

View File

@ -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);
}
}
};

View File

@ -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;
}

View File

@ -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));

View File

@ -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",

View File

@ -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({