test(server): batch 5 authorization, transclusion, search & comment coverage
Test-only. Fills the authorization / data-integrity gaps from the strategy report. Full server suite: 100 suites / 1031 passed + 1 todo, green. Authorization (privilege-escalation catches): - workspace/space ability factories: exact can/cannot per (action,subject) — admin cannot Manage Audit, writer/reader cannot Manage Settings/Member, etc. - findHighestUserSpaceRole, isAdminActingOnOwner. - WorkspaceService role guards: last-owner lockout, admin-over-owner, self-target. - SpaceMemberService.validateLastAdmin: never orphan a space without an admin. - GroupService: default-group immutability, name uniqueness. Access / data integrity: - PageAccessService: restriction-vs-space-ability branches for view/edit/comment. - TransclusionService.unsyncReference: cross-workspace/NotFound boundary asserts NO attachment write or ref-row delete on rejection; lookupWithAccessSet positional status mapping; listReferences drops private/cross-ws/deleted refs; syncPageTransclusions/References diff (no-op on unchanged content). - SearchService.searchPage: query-mode scoping; leakage modes return empty before executing the query. - CommentService: reply-to-reply guard, agent provenance, self-mention filter, no double-notify. Pure helpers: - prosemirror extractors (mention dedup-key id-vs-entityId, attachment UUID validation, removeMarkTypeFromDoc), collaboration.util (getPageId, isEmptyParagraphDoc, stripUnknownNodes unwrap, prosemirrorNodeToYElement). Reviewed (APPROVE WITH SUGGESTIONS): mutation-resistant, not vacuous. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import { QueueJob } from '../../integrations/queue/constants';
|
||||
|
||||
/**
|
||||
* Behavioral coverage for CommentService (comment.service.ts):
|
||||
* - create() @53
|
||||
* - resolveComment() @223
|
||||
* - queueCommentNotification() @292 (exercised through create/update)
|
||||
*
|
||||
* The service is constructed directly with jest-mocked repos / gateway / queues
|
||||
* (the @InjectQueue tokens cannot be resolved by Test.createTestingModule — see
|
||||
* the existing comment.service.spec.ts note). Every async dep returns a resolved
|
||||
* promise so the real control flow runs end-to-end.
|
||||
*
|
||||
* These specs catch: the thread-depth invariant (no reply-to-a-reply, parent
|
||||
* must live on the same page), mis-attributed AI provenance (created_source /
|
||||
* resolved_source / ai_chat_id), and notification correctness (self-mention and
|
||||
* re-notify spam, plus missed reply / resolve notifications).
|
||||
*/
|
||||
describe('CommentService — behavior', () => {
|
||||
// ProseMirror-ish doc containing a single user mention. extractUserMentionIds
|
||||
// FromJson walks `content[]` for nodes of type 'mention' with
|
||||
// attrs.entityType==='user' and returns attrs.entityId.
|
||||
const docMentioning = (...entityIds: string[]) => ({
|
||||
type: 'doc',
|
||||
content: entityIds.map((entityId) => ({
|
||||
type: 'mention',
|
||||
attrs: { entityType: 'user', entityId },
|
||||
})),
|
||||
});
|
||||
|
||||
function makeService(overrides?: {
|
||||
insertedId?: string;
|
||||
parentComment?: any;
|
||||
}) {
|
||||
const insertedId = overrides?.insertedId ?? 'comment-new';
|
||||
|
||||
const commentRepo: any = {
|
||||
// findById is used both for parent lookup (create) and the post-write
|
||||
// re-read. Default: the parent lookup result is configurable; the re-read
|
||||
// returns a minimal hydrated comment carrying the inserted id.
|
||||
findById: jest.fn(async (id: string) => {
|
||||
if (
|
||||
overrides &&
|
||||
'parentComment' in overrides &&
|
||||
id === overrides.parentComment?.id
|
||||
) {
|
||||
return overrides.parentComment;
|
||||
}
|
||||
return { id, content: {}, spaceId: 'space-1', pageId: 'page-1' };
|
||||
}),
|
||||
insertComment: jest.fn(async () => ({ id: insertedId })),
|
||||
updateComment: jest.fn(async () => undefined),
|
||||
};
|
||||
const pageRepo: any = {};
|
||||
const wsService: any = { emitCommentEvent: jest.fn() };
|
||||
const collaborationGateway: any = {
|
||||
handleYjsEvent: jest.fn(async () => undefined),
|
||||
};
|
||||
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
|
||||
const notificationQueue: any = { add: jest.fn(async () => undefined) };
|
||||
|
||||
const service = new CommentService(
|
||||
commentRepo,
|
||||
pageRepo,
|
||||
wsService,
|
||||
collaborationGateway,
|
||||
generalQueue,
|
||||
notificationQueue,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
commentRepo,
|
||||
wsService,
|
||||
generalQueue,
|
||||
notificationQueue,
|
||||
};
|
||||
}
|
||||
|
||||
const page = (over?: Partial<any>): any => ({
|
||||
id: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
...over,
|
||||
});
|
||||
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
|
||||
|
||||
describe('create — thread-depth invariant & provenance', () => {
|
||||
it('rejects a reply whose parent is itself a reply: "You cannot reply to a reply"', async () => {
|
||||
const parentComment = {
|
||||
id: 'parent-1',
|
||||
pageId: 'page-1',
|
||||
// A non-null parentCommentId means the "parent" is already a reply.
|
||||
parentCommentId: 'grandparent-1',
|
||||
};
|
||||
const { service, commentRepo } = makeService({ parentComment });
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user() },
|
||||
{
|
||||
content: JSON.stringify(docMentioning()),
|
||||
parentCommentId: 'parent-1',
|
||||
} as any,
|
||||
),
|
||||
).rejects.toThrow(new BadRequestException('You cannot reply to a reply'));
|
||||
|
||||
// The depth check happens before any write.
|
||||
expect(commentRepo.insertComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a reply when the parent lives on a different page: "Parent comment not found"', async () => {
|
||||
const parentComment = {
|
||||
id: 'parent-1',
|
||||
pageId: 'OTHER-page',
|
||||
parentCommentId: null,
|
||||
};
|
||||
const { service, commentRepo } = makeService({ parentComment });
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user() },
|
||||
{
|
||||
content: JSON.stringify(docMentioning()),
|
||||
parentCommentId: 'parent-1',
|
||||
} as any,
|
||||
),
|
||||
).rejects.toThrow(new BadRequestException('Parent comment not found'));
|
||||
|
||||
expect(commentRepo.insertComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stamps createdSource:"agent" + aiChatId when the actor is an agent', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
|
||||
await service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user() },
|
||||
{ content: JSON.stringify(docMentioning()) } as any,
|
||||
{ actor: 'agent', aiChatId: 'chat-99' },
|
||||
);
|
||||
|
||||
const insertArg = commentRepo.insertComment.mock.calls[0][0];
|
||||
expect(insertArg.createdSource).toBe('agent');
|
||||
expect(insertArg.aiChatId).toBe('chat-99');
|
||||
// Provenance only annotates the source — the human stays the creator.
|
||||
expect(insertArg.creatorId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves source default (no agent stamp) for a normal user', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
|
||||
await service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user() },
|
||||
{ content: JSON.stringify(docMentioning()) } as any,
|
||||
// Normal user provenance.
|
||||
{ actor: 'user', aiChatId: null },
|
||||
);
|
||||
|
||||
const insertArg = commentRepo.insertComment.mock.calls[0][0];
|
||||
expect(insertArg).not.toHaveProperty('createdSource');
|
||||
expect(insertArg).not.toHaveProperty('aiChatId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveComment — provenance & resolve notifications', () => {
|
||||
it('stamps resolvedSource:"agent" when an agent resolves', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'user-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await service.resolveComment(comment, true, user({ id: 'user-1' }), {
|
||||
actor: 'agent',
|
||||
aiChatId: 'chat-1',
|
||||
});
|
||||
|
||||
const [patch] = commentRepo.updateComment.mock.calls[0];
|
||||
expect(patch.resolvedSource).toBe('agent');
|
||||
expect(patch.resolvedById).toBe('user-1');
|
||||
expect(patch.resolvedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('clears resolvedAt/resolvedById/resolvedSource to null on unresolve', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'user-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
// Unresolve as an agent — the agent marker must still clear, not persist.
|
||||
await service.resolveComment(comment, false, user({ id: 'user-2' }), {
|
||||
actor: 'agent',
|
||||
aiChatId: 'chat-1',
|
||||
});
|
||||
|
||||
const [patch] = commentRepo.updateComment.mock.calls[0];
|
||||
expect(patch).toEqual({
|
||||
resolvedAt: null,
|
||||
resolvedById: null,
|
||||
resolvedSource: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies the author when SOMEONE ELSE resolves their comment", async () => {
|
||||
const { service, notificationQueue } = makeService();
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'author-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await service.resolveComment(comment, true, user({ id: 'resolver-2' }));
|
||||
|
||||
expect(notificationQueue.add).toHaveBeenCalledTimes(1);
|
||||
const [jobName, jobData] = notificationQueue.add.mock.calls[0];
|
||||
expect(jobName).toBe(QueueJob.COMMENT_RESOLVED_NOTIFICATION);
|
||||
expect(jobData).toMatchObject({
|
||||
commentId: 'c-1',
|
||||
commentCreatorId: 'author-1',
|
||||
actorId: 'resolver-2',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT notify when resolving your OWN comment', async () => {
|
||||
const { service, notificationQueue } = makeService();
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'self-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await service.resolveComment(comment, true, user({ id: 'self-1' }));
|
||||
|
||||
expect(notificationQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueCommentNotification — via create/update', () => {
|
||||
// Find the COMMENT_NOTIFICATION job among notificationQueue.add calls.
|
||||
const notifJob = (notificationQueue: any) =>
|
||||
notificationQueue.add.mock.calls.find(
|
||||
(c: any[]) => c[0] === QueueJob.COMMENT_NOTIFICATION,
|
||||
);
|
||||
|
||||
it('filters out a self-mention on create (no notification job)', async () => {
|
||||
const { service, notificationQueue } = makeService();
|
||||
|
||||
// A brand-new top-level comment that mentions only its own author. The
|
||||
// self id is filtered, no watchers branch reachable here because the only
|
||||
// potential job is from the mention set... but create() passes
|
||||
// notifyWatchers=true for a top-level comment, so a job WILL fire — we
|
||||
// assert the self id was scrubbed from mentionedUserIds.
|
||||
await service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user({ id: 'me' }) },
|
||||
{ content: JSON.stringify(docMentioning('me')) } as any,
|
||||
);
|
||||
|
||||
const job = notifJob(notificationQueue);
|
||||
expect(job).toBeDefined();
|
||||
// Self-mention must never appear in the recipients list.
|
||||
expect(job[1].mentionedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not re-notify an already-mentioned id on edit', async () => {
|
||||
const { service, notificationQueue } = makeService();
|
||||
|
||||
// The comment already mentioned 'bob' (oldMentionIds). The edited content
|
||||
// mentions bob again plus nobody new, top-level (notifyWatchers=false on
|
||||
// update) → no new mentions, no watchers, no parent → NO job.
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'editor-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
content: docMentioning('bob'),
|
||||
};
|
||||
|
||||
await service.update(
|
||||
comment,
|
||||
{ content: JSON.stringify(docMentioning('bob')) } as any,
|
||||
user({ id: 'editor-1' }),
|
||||
);
|
||||
|
||||
expect(notifJob(notificationQueue)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('enqueues newly added mentions on edit (re-notify guard does not over-suppress)', async () => {
|
||||
const { service, notificationQueue } = makeService();
|
||||
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'editor-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
content: docMentioning('bob'),
|
||||
};
|
||||
|
||||
// Edit adds 'carol' while keeping 'bob' → only 'carol' is new.
|
||||
await service.update(
|
||||
comment,
|
||||
{ content: JSON.stringify(docMentioning('bob', 'carol')) } as any,
|
||||
user({ id: 'editor-1' }),
|
||||
);
|
||||
|
||||
const job = notifJob(notificationQueue);
|
||||
expect(job).toBeDefined();
|
||||
expect(job[1].mentionedUserIds).toEqual(['carol']);
|
||||
});
|
||||
|
||||
it('enqueues NO job when no new mentions, not notifying watchers and no parent (edit)', async () => {
|
||||
const { service, notificationQueue } = makeService();
|
||||
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'editor-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
content: docMentioning(),
|
||||
};
|
||||
|
||||
// Plain edit with no mentions at all: update() passes notifyWatchers=false
|
||||
// and no parentCommentId → the early return in queueCommentNotification.
|
||||
await service.update(
|
||||
comment,
|
||||
{ content: JSON.stringify(docMentioning()) } as any,
|
||||
user({ id: 'editor-1' }),
|
||||
);
|
||||
|
||||
expect(notifJob(notificationQueue)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('enqueues a reply notification (parentCommentId) even with no new mentions', async () => {
|
||||
const parentComment = {
|
||||
id: 'parent-1',
|
||||
pageId: 'page-1',
|
||||
parentCommentId: null,
|
||||
};
|
||||
const { service, notificationQueue } = makeService({ parentComment });
|
||||
|
||||
// A reply with no mentions: notifyWatchers is false (!isReply) but the
|
||||
// parentCommentId keeps the job alive → reply notifications are not missed.
|
||||
await service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user({ id: 'replier' }) },
|
||||
{
|
||||
content: JSON.stringify(docMentioning()),
|
||||
parentCommentId: 'parent-1',
|
||||
} as any,
|
||||
);
|
||||
|
||||
const job = notifJob(notificationQueue);
|
||||
expect(job).toBeDefined();
|
||||
expect(job[1]).toMatchObject({
|
||||
parentCommentId: 'parent-1',
|
||||
notifyWatchers: false,
|
||||
mentionedUserIds: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user