@sync-in/server
Version:
The secure, open-source platform for file storage, sharing, collaboration, and sync
451 lines (450 loc) • 18.4 kB
JavaScript
/*
* Copyright (C) 2012-2025 Johan Legrand <johan.legrand@sync-in.com>
* This file is part of Sync-in | The open source file sync and share solution
* See the LICENSE file for licensing details
*/ "use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
const _common = require("@nestjs/common");
const _testing = require("@nestjs/testing");
const _cacheservice = require("../../../infrastructure/cache/services/cache.service");
const _contextmanagerservice = require("../../../infrastructure/context/services/context-manager.service");
const _constants = require("../../../infrastructure/database/constants");
const _filesqueriesservice = require("../../files/services/files-queries.service");
const _files = require("../../files/utils/files");
const _notificationsmanagerservice = require("../../notifications/services/notifications-manager.service");
const _sharesqueriesservice = require("../../shares/services/shares-queries.service");
const _spacesqueriesservice = require("../../spaces/services/spaces-queries.service");
const _commentsmanagerservice = require("./comments-manager.service");
const _commentsqueriesservice = require("./comments-queries.service");
// Mocks of the file utilities used by the service
jest.mock('../../files/utils/files', ()=>({
isPathExists: jest.fn(),
getProps: jest.fn(),
dirName: jest.fn(),
fileName: jest.fn()
}));
describe(_commentsmanagerservice.CommentsManager.name, ()=>{
let commentsManager;
let contextManager;
let commentQueries;
let filesQueries;
let notificationsManager;
const user = {
id: 42,
email: 'john@doe.tld'
};
const makeSpace = (overrides = {})=>({
realPath: '/real/path',
url: '/space/folder/file.txt',
dbFile: {
path: 'folder',
ownerId: 42,
spaceExternalRootId: null,
shareExternalId: null
},
...overrides
});
beforeAll(async ()=>{
commentQueries = {
getComments: jest.fn(),
createComment: jest.fn(),
updateComment: jest.fn(),
deleteComment: jest.fn(),
getRecentsFromUser: jest.fn(),
membersToNotify: jest.fn()
};
filesQueries = {
getSpaceFileId: jest.fn(),
getOrCreateSpaceFile: jest.fn()
};
notificationsManager = {
create: jest.fn().mockResolvedValue(undefined)
};
contextManager = {
headerOriginUrl: jest.fn().mockReturnValue('https://app.local/path')
};
const module = await _testing.Test.createTestingModule({
providers: [
{
provide: _constants.DB_TOKEN_PROVIDER,
useValue: {}
},
{
provide: _cacheservice.Cache,
useValue: {}
},
{
provide: _notificationsmanagerservice.NotificationsManager,
useValue: notificationsManager
},
{
provide: _contextmanagerservice.ContextManager,
useValue: contextManager
},
{
provide: _commentsmanagerservice.CommentsManager,
useClass: _commentsmanagerservice.CommentsManager
},
{
provide: _commentsqueriesservice.CommentsQueries,
useValue: commentQueries
},
{
provide: _filesqueriesservice.FilesQueries,
useValue: filesQueries
},
{
provide: _spacesqueriesservice.SpacesQueries,
useValue: {}
},
{
provide: _sharesqueriesservice.SharesQueries,
useValue: {}
}
]
}).compile();
commentsManager = module.get(_commentsmanagerservice.CommentsManager);
});
beforeEach(()=>{
jest.clearAllMocks();
_files.isPathExists.mockResolvedValue(true);
_files.getProps.mockResolvedValue({
name: 'file.txt',
path: 'folder'
});
_files.dirName.mockReturnValue('/space/folder');
_files.fileName.mockReturnValue('file.txt');
});
it('should be defined', ()=>{
expect(commentsManager).toBeDefined();
});
describe('getComments', ()=>{
it('returns [] if no fileId', async ()=>{
filesQueries.getSpaceFileId.mockResolvedValue(0);
const res = await commentsManager.getComments(user, makeSpace());
expect(res).toEqual([]);
expect(filesQueries.getSpaceFileId).toHaveBeenCalledTimes(1);
expect(commentQueries.getComments).not.toHaveBeenCalled();
});
it('returns comments if fileId is valid', async ()=>{
filesQueries.getSpaceFileId.mockResolvedValue(123);
const expected = [
{
id: 1
},
{
id: 2
}
];
commentQueries.getComments.mockResolvedValue(expected);
const res = await commentsManager.getComments(user, makeSpace());
expect(filesQueries.getSpaceFileId).toHaveBeenCalled();
expect(commentQueries.getComments).toHaveBeenCalledWith(42, true, 123);
expect(res).toBe(expected);
});
it('throws NOT_FOUND if path does not exist', async ()=>{
;
_files.isPathExists.mockResolvedValue(false);
await expect(commentsManager.getComments(user, makeSpace())).rejects.toThrow(_common.HttpException);
await expect(commentsManager.getComments(user, makeSpace())).rejects.toMatchObject({
status: _common.HttpStatus.NOT_FOUND
});
});
});
describe('createComment', ()=>{
it("rejects on external root/share at path '.'", async ()=>{
const space = makeSpace({
dbFile: {
path: '.',
ownerId: 42,
spaceExternalRootId: 'ext',
shareExternalId: null
}
});
await expect(commentsManager.createComment(user, space, {
fileId: 0,
content: 'Hi'
})).rejects.toMatchObject({
status: _common.HttpStatus.BAD_REQUEST
});
const space2 = makeSpace({
dbFile: {
path: '.',
ownerId: 42,
spaceExternalRootId: null,
shareExternalId: 'shr'
}
});
await expect(commentsManager.createComment(user, space2, {
fileId: 0,
content: 'Hi'
})).rejects.toMatchObject({
status: _common.HttpStatus.BAD_REQUEST
});
});
it('rejects BAD_REQUEST if provided fileId mismatches', async ()=>{
filesQueries.getSpaceFileId.mockResolvedValue(100);
await expect(commentsManager.createComment(user, makeSpace(), {
fileId: 101,
content: 'x'
})).rejects.toMatchObject({
status: _common.HttpStatus.BAD_REQUEST
});
});
it('uses getOrCreate when fileId < 0', async ()=>{
filesQueries.getOrCreateSpaceFile.mockResolvedValue(555);
commentQueries.createComment.mockResolvedValue(777);
commentQueries.getComments.mockResolvedValue([
{
id: 777,
fileId: 555,
content: 'hello'
}
]);
// Force a rejection in notify() to cover the catch attached to this.notify(...) in createComment
commentQueries.membersToNotify.mockRejectedValueOnce(new Error('members failed'));
const loggerSpy = jest.spyOn(commentsManager['logger'], 'error').mockImplementation(()=>undefined);
const res = await commentsManager.createComment(user, makeSpace(), {
fileId: -1,
content: 'hello'
});
// Let the microtask run the catch of createComment
await new Promise((r)=>setImmediate(r));
expect(filesQueries.getOrCreateSpaceFile).toHaveBeenCalled();
expect(filesQueries.getSpaceFileId).not.toHaveBeenCalled();
expect(commentQueries.createComment).toHaveBeenCalledWith(42, 555, 'hello');
expect(notificationsManager.create).not.toHaveBeenCalled();
expect(res).toEqual({
id: 777,
fileId: 555,
content: 'hello'
});
// Verify that the catch of createComment logged the error
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('createComment'));
loggerSpy.mockRestore();
});
it('notifies members when present', async ()=>{
filesQueries.getSpaceFileId.mockResolvedValue(10);
commentQueries.createComment.mockResolvedValue(1);
commentQueries.getComments.mockResolvedValue([
{
id: 1,
fileId: 10,
content: 'c'
}
]);
commentQueries.membersToNotify.mockResolvedValue([
{
id: 2,
email: 'a@b.c'
}
]);
// Force rejection of notification creation to trigger the catch in notify()
notificationsManager.create.mockRejectedValueOnce(new Error('notify failed'));
const loggerSpy = jest.spyOn(commentsManager['logger'], 'error').mockImplementation(()=>undefined);
await commentsManager.createComment(user, makeSpace(), {
fileId: 10,
content: 'c'
});
// Let the microtask execute the internal catch of notify()
await new Promise((r)=>setImmediate(r));
expect(notificationsManager.create).toHaveBeenCalledTimes(1);
notificationsManager.create.mockClear();
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('notify'));
loggerSpy.mockRestore();
const space = makeSpace();
await commentsManager.createComment(user, space, {
fileId: 10,
content: 'c'
});
expect(commentQueries.membersToNotify).toHaveBeenCalledWith(42, 10);
expect(notificationsManager.create).toHaveBeenCalledTimes(1);
const [members, notification, data] = notificationsManager.create.mock.calls[0];
expect(members).toEqual([
{
id: 2,
email: 'a@b.c'
}
]);
expect(notification).toMatchObject({
app: expect.anything(),
event: expect.anything(),
element: 'file.txt',
url: '/space/folder'
});
expect(_files.fileName).toHaveBeenCalledWith(space.url);
expect(_files.dirName).toHaveBeenCalledWith(space.url);
expect(data).toMatchObject({
author: user,
currentUrl: 'https://app.local/path',
content: 'c'
});
});
it('logs an error if notificationsManager.create rejects (covers catch in notify)', async ()=>{
filesQueries.getSpaceFileId.mockResolvedValue(10);
commentQueries.createComment.mockResolvedValue(1);
commentQueries.getComments.mockResolvedValue([
{
id: 1,
fileId: 10,
content: 'c'
}
]);
commentQueries.membersToNotify.mockResolvedValue([
{
id: 2,
email: 'a@b.c'
}
]);
// Force rejection to trigger the catch in notify()
notificationsManager.create.mockRejectedValueOnce(new Error('notify failed'));
const loggerSpy = jest.spyOn(commentsManager['logger'], 'error').mockImplementation(()=>undefined);
await commentsManager.createComment(user, makeSpace(), {
fileId: 10,
content: 'c'
});
// Allow the microtask to run the internal catch of notify()
await new Promise((r)=>setImmediate(r));
expect(notificationsManager.create).toHaveBeenCalledTimes(1);
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('notify'));
loggerSpy.mockRestore();
});
it('does not notify if no members', async ()=>{
filesQueries.getSpaceFileId.mockResolvedValue(10);
commentQueries.createComment.mockResolvedValue(1);
commentQueries.getComments.mockResolvedValue([
{
id: 1
}
]);
commentQueries.membersToNotify.mockResolvedValue([]);
await commentsManager.createComment(user, makeSpace(), {
fileId: 10,
content: 'c'
});
expect(notificationsManager.create).not.toHaveBeenCalled();
});
});
describe('updateComment', ()=>{
it('rejects NOT_FOUND if target comment is not found', async ()=>{
commentQueries.getComments.mockResolvedValue([]);
await expect(commentsManager.updateComment(user, makeSpace(), {
commentId: 99,
fileId: 1,
content: 'z'
})).rejects.toMatchObject({
status: _common.HttpStatus.NOT_FOUND
});
});
it('rejects BAD_REQUEST if fileId mismatches', async ()=>{
commentQueries.getComments.mockResolvedValue([
{
id: 50,
fileId: 123
}
]);
filesQueries.getSpaceFileId.mockResolvedValue(999);
await expect(commentsManager.updateComment(user, makeSpace(), {
commentId: 50,
fileId: 123,
content: 'z'
})).rejects.toMatchObject({
status: _common.HttpStatus.BAD_REQUEST
});
});
it('rejects INTERNAL_SERVER_ERROR if update fails', async ()=>{
commentQueries.getComments.mockResolvedValueOnce([
{
id: 50,
fileId: 5
}
]);
filesQueries.getSpaceFileId.mockResolvedValue(5);
commentQueries.updateComment.mockResolvedValue(false);
await expect(commentsManager.updateComment(user, makeSpace(), {
commentId: 50,
fileId: 5,
content: 'z'
})).rejects.toMatchObject({
status: _common.HttpStatus.INTERNAL_SERVER_ERROR
});
});
it('returns the comment after update', async ()=>{
commentQueries.getComments.mockResolvedValueOnce([
{
id: 50,
fileId: 5
}
]) // initial fetch
.mockResolvedValueOnce([
{
id: 50,
fileId: 5,
content: 'updated'
}
]); // fetch after update -> include content
filesQueries.getSpaceFileId.mockResolvedValue(5);
commentQueries.updateComment.mockResolvedValue(true);
const res = await commentsManager.updateComment(user, makeSpace(), {
commentId: 50,
fileId: 5,
content: 'updated'
});
expect(commentQueries.updateComment).toHaveBeenCalledWith(42, 50, 5, 'updated');
// allow additional fields via toMatchObject
expect(res).toMatchObject({
id: 50,
fileId: 5,
content: 'updated'
});
});
});
describe('deleteComment', ()=>{
it('rejects BAD_REQUEST if fileId mismatches', async ()=>{
filesQueries.getSpaceFileId.mockResolvedValue(10);
await expect(commentsManager.deleteComment(user, makeSpace(), {
commentId: 1,
fileId: 11
})).rejects.toMatchObject({
status: _common.HttpStatus.BAD_REQUEST
});
});
it('rejects FORBIDDEN if deletion is denied', async ()=>{
filesQueries.getSpaceFileId.mockResolvedValue(10);
commentQueries.deleteComment.mockResolvedValue(false);
await expect(commentsManager.deleteComment(user, makeSpace(), {
commentId: 1,
fileId: 10
})).rejects.toMatchObject({
status: _common.HttpStatus.FORBIDDEN
});
});
it('resolves when deletion succeeds', async ()=>{
filesQueries.getSpaceFileId.mockResolvedValue(10);
commentQueries.deleteComment.mockResolvedValue(true);
await expect(commentsManager.deleteComment(user, makeSpace(), {
commentId: 1,
fileId: 10
})).resolves.toBeUndefined();
});
});
describe('getRecents', ()=>{
it('delegates to commentQueries.getRecentsFromUser', async ()=>{
const recents = [
{
id: 1
},
{
id: 2
}
];
commentQueries.getRecentsFromUser.mockResolvedValue(recents);
const res = await commentsManager.getRecents(user, 5);
expect(commentQueries.getRecentsFromUser).toHaveBeenCalledWith(user, 5);
expect(res).toBe(recents);
});
});
});
//# sourceMappingURL=comments-manager.service.spec.js.map