@@ -3,6 +3,9 @@ import { getRepositoryToken } from '@nestjs/typeorm';
33import { ForbiddenException , NotFoundException } from '@nestjs/common' ;
44import { CommentsService } from './comments.service' ;
55import { Comment } from './entities/comment.entity' ;
6+ import { CommentAuditLog } from './entities/comment-audit-log.entity' ;
7+ import { EventBus } from '../events/event-bus' ;
8+ import { CommentDeletedEvent } from '../events/domain-events' ;
69
710const makeComment = ( overrides : Partial < Comment > = { } ) : Comment =>
811 ( {
@@ -13,6 +16,8 @@ const makeComment = (overrides: Partial<Comment> = {}): Comment =>
1316 parentId : null ,
1417 createdAt : new Date ( '2026-01-01T00:00:00Z' ) ,
1518 updatedAt : new Date ( '2026-01-01T00:00:00Z' ) ,
19+ deletedAt : null ,
20+ deletedBy : null ,
1621 ...overrides ,
1722 } ) as Comment ;
1823
@@ -24,7 +29,10 @@ describe('CommentsService', () => {
2429 findOne : jest . Mock ;
2530 findAndCount : jest . Mock ;
2631 remove : jest . Mock ;
32+ delete : jest . Mock ;
2733 } ;
34+ let auditRepo : { create : jest . Mock ; save : jest . Mock } ;
35+ let eventBus : { publish : jest . Mock } ;
2836
2937 beforeEach ( async ( ) => {
3038 repo = {
@@ -33,12 +41,20 @@ describe('CommentsService', () => {
3341 findOne : jest . fn ( ) ,
3442 findAndCount : jest . fn ( ) ,
3543 remove : jest . fn ( ) ,
44+ delete : jest . fn ( ) ,
3645 } ;
46+ auditRepo = {
47+ create : jest . fn ( ( data : unknown ) => data ) ,
48+ save : jest . fn ( ( e : unknown ) => Promise . resolve ( e ) ) ,
49+ } ;
50+ eventBus = { publish : jest . fn ( ) } ;
3751
3852 const module : TestingModule = await Test . createTestingModule ( {
3953 providers : [
4054 CommentsService ,
4155 { provide : getRepositoryToken ( Comment ) , useValue : repo } ,
56+ { provide : getRepositoryToken ( CommentAuditLog ) , useValue : auditRepo } ,
57+ { provide : EventBus , useValue : eventBus } ,
4258 ] ,
4359 } ) . compile ( ) ;
4460
@@ -143,6 +159,19 @@ describe('CommentsService', () => {
143159 expect ( result . limit ) . toBe ( 20 ) ;
144160 } ) ;
145161
162+ it ( 'filters by deletedAt: IsNull()' , async ( ) => {
163+ repo . findAndCount . mockResolvedValue ( [ [ ] , 0 ] ) ;
164+
165+ await service . findAll ( { page : 1 , limit : 20 } ) ;
166+
167+ expect ( repo . findAndCount ) . toHaveBeenCalledWith (
168+ expect . objectContaining ( {
169+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- jest matcher typings are `any`
170+ where : expect . objectContaining ( { deletedAt : expect . anything ( ) } ) ,
171+ } ) ,
172+ ) ;
173+ } ) ;
174+
146175 it ( 'returns empty list when there are no comments' , async ( ) => {
147176 repo . findAndCount . mockResolvedValue ( [ [ ] , 0 ] ) ;
148177
@@ -186,7 +215,7 @@ describe('CommentsService', () => {
186215 // ── findByPost ───────────────────────────────────────────────────────────────
187216
188217 describe ( 'findByPost' , ( ) => {
189- it ( 'returns comments filtered by postId' , async ( ) => {
218+ it ( 'returns comments filtered by postId and deletedAt: IsNull() ' , async ( ) => {
190219 const comment = makeComment ( { postId : 'post-42' } ) ;
191220 repo . findAndCount . mockResolvedValue ( [ [ comment ] , 1 ] ) ;
192221
@@ -196,7 +225,13 @@ describe('CommentsService', () => {
196225 } ) ;
197226
198227 expect ( repo . findAndCount ) . toHaveBeenCalledWith (
199- expect . objectContaining ( { where : { postId : 'post-42' } } ) ,
228+ expect . objectContaining ( {
229+ where : expect . objectContaining ( {
230+ postId : 'post-42' ,
231+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- jest matcher typings are `any`
232+ deletedAt : expect . anything ( ) ,
233+ } ) ,
234+ } ) ,
200235 ) ;
201236 expect ( result . data ) . toHaveLength ( 1 ) ;
202237 expect ( result . data [ 0 ] . postId ) . toBe ( 'post-42' ) ;
@@ -244,7 +279,11 @@ describe('CommentsService', () => {
244279
245280 const result = await service . findOne ( 'comment-1' ) ;
246281
247- expect ( repo . findOne ) . toHaveBeenCalledWith ( { where : { id : 'comment-1' } } ) ;
282+ expect ( repo . findOne ) . toHaveBeenCalledWith (
283+ expect . objectContaining ( {
284+ where : expect . objectContaining ( { id : 'comment-1' } ) ,
285+ } ) ,
286+ ) ;
248287 expect ( result . id ) . toBe ( 'comment-1' ) ;
249288 expect ( result . content ) . toBe ( 'Great post!' ) ;
250289 } ) ;
@@ -262,6 +301,14 @@ describe('CommentsService', () => {
262301
263302 await expect ( service . findOne ( 'missing-id' ) ) . rejects . toThrow ( 'missing-id' ) ;
264303 } ) ;
304+
305+ it ( 'throws NotFoundException for a soft-deleted comment (filtered by IsNull)' , async ( ) => {
306+ repo . findOne . mockResolvedValue ( null ) ;
307+
308+ await expect ( service . findOne ( 'comment-1' ) ) . rejects . toBeInstanceOf (
309+ NotFoundException ,
310+ ) ;
311+ } ) ;
265312 } ) ;
266313
267314 // ── update ───────────────────────────────────────────────────────────────────
@@ -289,7 +336,11 @@ describe('CommentsService', () => {
289336
290337 await service . update ( 'comment-1' , { content : 'New content' } , 'author-1' ) ;
291338
292- expect ( repo . findOne ) . toHaveBeenCalledWith ( { where : { id : 'comment-1' } } ) ;
339+ expect ( repo . findOne ) . toHaveBeenCalledWith (
340+ expect . objectContaining ( {
341+ where : expect . objectContaining ( { id : 'comment-1' } ) ,
342+ } ) ,
343+ ) ;
293344 } ) ;
294345
295346 it ( 'throws NotFoundException when comment does not exist' , async ( ) => {
@@ -335,23 +386,64 @@ describe('CommentsService', () => {
335386 } ) ;
336387 } ) ;
337388
338- // ── remove ────────────── ─────────────────────────────────────────────────────
389+ // ── remove (soft delete) ─────────────────────────────────────────────────────
339390
340391 describe ( 'remove' , ( ) => {
341- it ( 'removes the comment when it exists ' , async ( ) => {
342- const comment = makeComment ( ) ;
392+ it ( 'sets deletedAt and deletedBy then saves ' , async ( ) => {
393+ const comment = makeComment ( { authorId : 'author-1' } ) ;
343394 repo . findOne . mockResolvedValue ( comment ) ;
344- repo . remove . mockResolvedValue ( undefined ) ;
395+ repo . save . mockResolvedValue ( {
396+ ...comment ,
397+ deletedAt : new Date ( ) ,
398+ deletedBy : 'author-1' ,
399+ } ) ;
345400
346401 await service . remove ( 'comment-1' , 'author-1' ) ;
347402
348- expect ( repo . remove ) . toHaveBeenCalledWith ( comment ) ;
403+ expect ( repo . save ) . toHaveBeenCalledWith (
404+ expect . objectContaining ( { deletedBy : 'author-1' } ) ,
405+ ) ;
406+ expect ( comment . deletedAt ) . not . toBeNull ( ) ;
407+ expect ( repo . remove ) . not . toHaveBeenCalled ( ) ;
408+ } ) ;
409+
410+ it ( 'persists an audit log row with correct fields' , async ( ) => {
411+ const comment = makeComment ( { authorId : 'author-1' } ) ;
412+ repo . findOne . mockResolvedValue ( comment ) ;
413+ repo . save . mockResolvedValue ( comment ) ;
414+
415+ await service . remove ( 'comment-1' , 'author-1' ) ;
416+
417+ expect ( auditRepo . create ) . toHaveBeenCalledWith (
418+ expect . objectContaining ( {
419+ commentId : 'comment-1' ,
420+ deletedBy : 'author-1' ,
421+ action : 'soft_delete' ,
422+ } ) ,
423+ ) ;
424+ expect ( auditRepo . save ) . toHaveBeenCalled ( ) ;
425+ } ) ;
426+
427+ it ( 'emits CommentDeletedEvent with correct commentId and deletedBy' , async ( ) => {
428+ const comment = makeComment ( { authorId : 'author-1' } ) ;
429+ repo . findOne . mockResolvedValue ( comment ) ;
430+ repo . save . mockResolvedValue ( comment ) ;
431+
432+ await service . remove ( 'comment-1' , 'author-1' ) ;
433+
434+ expect ( eventBus . publish ) . toHaveBeenCalledWith (
435+ expect . objectContaining ( {
436+ type : 'comment.deleted' ,
437+ commentId : 'comment-1' ,
438+ deletedBy : 'author-1' ,
439+ } ) ,
440+ ) ;
349441 } ) ;
350442
351443 it ( 'returns void on success' , async ( ) => {
352444 const comment = makeComment ( ) ;
353445 repo . findOne . mockResolvedValue ( comment ) ;
354- repo . remove . mockResolvedValue ( undefined ) ;
446+ repo . save . mockResolvedValue ( comment ) ;
355447
356448 const result = await service . remove ( 'comment-1' , 'author-1' ) ;
357449
@@ -364,14 +456,37 @@ describe('CommentsService', () => {
364456 await expect (
365457 service . remove ( 'missing' , 'author-1' ) ,
366458 ) . rejects . toBeInstanceOf ( NotFoundException ) ;
459+ expect ( eventBus . publish ) . not . toHaveBeenCalled ( ) ;
460+ expect ( auditRepo . save ) . not . toHaveBeenCalled ( ) ;
367461 } ) ;
368462
369- it ( 'does not call remove when comment is not found' , async ( ) => {
463+ it ( 'does not emit event or write audit log when comment is already soft-deleted' , async ( ) => {
464+ // IsNull() filter means the repo returns null for already-deleted comments
370465 repo . findOne . mockResolvedValue ( null ) ;
371466
372- await expect ( service . remove ( 'missing' , 'author-1' ) ) . rejects . toThrow ( ) ;
467+ await expect (
468+ service . remove ( 'comment-1' , 'author-1' ) ,
469+ ) . rejects . toBeInstanceOf ( NotFoundException ) ;
470+ expect ( eventBus . publish ) . not . toHaveBeenCalled ( ) ;
471+ expect ( auditRepo . save ) . not . toHaveBeenCalled ( ) ;
472+ } ) ;
373473
374- expect ( repo . remove ) . not . toHaveBeenCalled ( ) ;
474+ it ( 'audit log is written before event is published' , async ( ) => {
475+ const order : string [ ] = [ ] ;
476+ const comment = makeComment ( { authorId : 'author-1' } ) ;
477+ repo . findOne . mockResolvedValue ( comment ) ;
478+ repo . save . mockResolvedValue ( comment ) ;
479+ auditRepo . save . mockImplementation ( ( e : unknown ) => {
480+ order . push ( 'audit' ) ;
481+ return Promise . resolve ( e ) ;
482+ } ) ;
483+ eventBus . publish . mockImplementation ( ( ) => {
484+ order . push ( 'event' ) ;
485+ } ) ;
486+
487+ await service . remove ( 'comment-1' , 'author-1' ) ;
488+
489+ expect ( order ) . toEqual ( [ 'audit' , 'event' ] ) ;
375490 } ) ;
376491
377492 it ( 'throws ForbiddenException when the requester is not the author' , async ( ) => {
@@ -381,7 +496,47 @@ describe('CommentsService', () => {
381496 await expect (
382497 service . remove ( 'comment-1' , 'someone-else' ) ,
383498 ) . rejects . toBeInstanceOf ( ForbiddenException ) ;
499+ expect ( repo . save ) . not . toHaveBeenCalled ( ) ;
500+ expect ( auditRepo . save ) . not . toHaveBeenCalled ( ) ;
501+ expect ( eventBus . publish ) . not . toHaveBeenCalled ( ) ;
384502 expect ( repo . remove ) . not . toHaveBeenCalled ( ) ;
385503 } ) ;
386504 } ) ;
505+
506+ // ── hardDelete ───────────────────────────────────────────────────────────────
507+
508+ describe ( 'hardDelete' , ( ) => {
509+ it ( 'is not the default deletion path (hard-deletes only when called explicitly)' , async ( ) => {
510+ repo . delete . mockResolvedValue ( { affected : 1 } ) ;
511+
512+ await service . hardDelete ( 'comment-1' ) ;
513+
514+ expect ( repo . delete ) . toHaveBeenCalledWith ( 'comment-1' ) ;
515+ } ) ;
516+
517+ it ( 'throws NotFoundException when nothing was deleted' , async ( ) => {
518+ repo . delete . mockResolvedValue ( { affected : 0 } ) ;
519+
520+ await expect ( service . hardDelete ( 'missing' ) ) . rejects . toBeInstanceOf (
521+ NotFoundException ,
522+ ) ;
523+ } ) ;
524+ } ) ;
525+
526+ // ── CommentDeletedEvent ──────────────────────────────────────────────────────
527+
528+ describe ( 'CommentDeletedEvent' , ( ) => {
529+ it ( 'has the correct type discriminant' , ( ) => {
530+ const event = new CommentDeletedEvent ( 'c1' , 'u1' ) ;
531+ expect ( event . type ) . toBe ( 'comment.deleted' ) ;
532+ expect ( event . commentId ) . toBe ( 'c1' ) ;
533+ expect ( event . deletedBy ) . toBe ( 'u1' ) ;
534+ } ) ;
535+
536+ it ( 'records a timestamp' , ( ) => {
537+ const before = Date . now ( ) ;
538+ const event = new CommentDeletedEvent ( 'c1' , 'u1' ) ;
539+ expect ( event . timestamp ) . toBeGreaterThanOrEqual ( before ) ;
540+ } ) ;
541+ } ) ;
387542} ) ;
0 commit comments