diff --git a/src/core/ports/repositories/notification.repository.ts b/src/core/ports/repositories/notification.repository.ts index 34ddf843..464acab2 100644 --- a/src/core/ports/repositories/notification.repository.ts +++ b/src/core/ports/repositories/notification.repository.ts @@ -1,4 +1,5 @@ import type { Notification } from "@core/domain/entities/notification.entity"; +import type { NotificationType } from "@core/domain/enums/notification-type.enum"; /** * Parameters for paginated notification retrieval. @@ -9,6 +10,34 @@ export interface FindNotificationsInput { skip: number; } +/** + * Identifies the notification a single undone action produced. + * + * Every field participates in the match, and a target left out must be null + * in the row: a like on a post and a like on an article are the same type + * from the same issuer to the same recipient, and only the target tells them + * apart. + */ +export interface DeleteNotificationInput { + /** The user the notification was delivered to. */ + recipientId: string; + + /** The user whose action produced it. */ + issuerId: string; + + /** The kind of notification to remove. */ + type: NotificationType; + + /** The post it points at, when it points at one. */ + postId?: string; + + /** The article it points at, when it points at one. */ + articleId?: string; + + /** The comment it points at, when it points at one. */ + commentId?: string; +} + /** * Repository interface for managing Notification entities. * Following Clean Architecture principles, this interface defines the contract @@ -43,6 +72,18 @@ export interface INotificationRepository { */ countByUserId(userId: string): Promise; + /** + * Deletes the notification an undone action had produced. + * + * Unliking or unfollowing must take its notification back with it, + * otherwise the recipient keeps a notification for something that no + * longer happened, and toggling the action piles up duplicates. + * + * @param input - The exact notification to remove. + * @returns The number of notifications deleted, zero when none matched. + */ + deleteByTarget(input: DeleteNotificationInput): Promise; + /** * Marks a single notification as read. * diff --git a/src/core/use-cases/article/unlike-article/unlike-article.usecase.ts b/src/core/use-cases/article/unlike-article/unlike-article.usecase.ts index b2f72906..c23c21b2 100644 --- a/src/core/use-cases/article/unlike-article/unlike-article.usecase.ts +++ b/src/core/use-cases/article/unlike-article/unlike-article.usecase.ts @@ -1,6 +1,7 @@ import type { TransactionPort } from "@core/ports/services/transaction.port"; import { NotFoundError } from "@core/errors"; import type { UnlikeArticleUseCaseInput } from "./unlike-article-usecase.input"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; /** * Use case for removing a like from an article. @@ -42,6 +43,13 @@ export class UnlikeArticleUseCase { input.userId, ); await ctx.articleLikeRepository.decrementLikeCount(input.articleId); + + await ctx.notificationRepository.deleteByTarget({ + recipientId: article.author.id, + issuerId: input.userId, + type: NotificationType.LIKE, + articleId: input.articleId, + }); }); } } diff --git a/src/core/use-cases/comment/unlike-comment/unlike-comment.usecase.ts b/src/core/use-cases/comment/unlike-comment/unlike-comment.usecase.ts index ab376975..b6c77bdb 100644 --- a/src/core/use-cases/comment/unlike-comment/unlike-comment.usecase.ts +++ b/src/core/use-cases/comment/unlike-comment/unlike-comment.usecase.ts @@ -1,6 +1,7 @@ import type { TransactionPort } from "@core/ports/services/transaction.port"; import { NotFoundError } from "@core/errors"; import type { UnlikeCommentUseCaseInput } from "./unlike-comment-usecase.input"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; /** * Use case for unliking a comment. This use case handles the logic for removing a like from a comment, including checking if the comment exists, verifying that the user has previously liked the comment, updating the like count, and ensuring that all operations are executed within a transaction to maintain data integrity. @@ -34,6 +35,15 @@ export class UnlikeCommentUseCase { input.userId, ); await ctx.commentRepository.decrementLikeCount(input.commentId); + + await ctx.notificationRepository.deleteByTarget({ + recipientId: comment.authorId, + issuerId: input.userId, + type: NotificationType.COMMENT_LIKE, + commentId: input.commentId, + postId: comment.postId ?? undefined, + articleId: comment.articleId ?? undefined, + }); }); } } diff --git a/src/core/use-cases/follow-user/unfollow-user/unfollow-user.usecase.ts b/src/core/use-cases/follow-user/unfollow-user/unfollow-user.usecase.ts index 604eae56..6a9ee0fd 100644 --- a/src/core/use-cases/follow-user/unfollow-user/unfollow-user.usecase.ts +++ b/src/core/use-cases/follow-user/unfollow-user/unfollow-user.usecase.ts @@ -1,6 +1,8 @@ import { BadRequestError, NotFoundError } from "@core/errors"; import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; import type { UnFollowUserUseCaseInput, UnFollowUserUseCaseOutput } from "./"; /** @@ -15,10 +17,12 @@ export class UnfollowUserUseCase { * * @param followUserRepository - Repository for managing follow relationships * @param profileRepository - Repository for managing user profiles + * @param notificationRepository - Repository for managing notifications */ constructor( private readonly followUserRepository: IFollowRepository, private readonly profileRepository: IProfileRepository, + private readonly notificationRepository: INotificationRepository, ) {} /** @@ -50,6 +54,15 @@ export class UnfollowUserUseCase { currentUserId, targetProfile.userId, ); + + // The follow notification goes with the follow: leaving it behind + // tells the target someone follows them who no longer does, and + // re-following would stack a second one on top. + await this.notificationRepository.deleteByTarget({ + recipientId: targetProfile.userId, + issuerId: currentUserId, + type: NotificationType.FOLLOW, + }); } const followersCount = diff --git a/src/core/use-cases/post/unlike-post/unlike-post.usecase.ts b/src/core/use-cases/post/unlike-post/unlike-post.usecase.ts index d7a1be3a..922c059c 100644 --- a/src/core/use-cases/post/unlike-post/unlike-post.usecase.ts +++ b/src/core/use-cases/post/unlike-post/unlike-post.usecase.ts @@ -2,6 +2,7 @@ import type { TransactionPort } from "@core/ports/services/transaction.port"; import { NotFoundError } from "@core/errors"; import type { UnlikePostUseCaseInput } from "./unlike-post-usecase.input"; import type { CachePort } from "@core/ports/services/cache.port"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; /** * Use case for unliking a post @@ -30,7 +31,8 @@ export class UnlikePostUseCase { * @remarks * This method first validates that the post exists, then checks if the user * has previously liked the post. If both conditions are met, it removes the - * like relationship and decrements the like count. If the user hasn't liked the post, + * like relationship, decrements the like count and takes back the + * notification the like had produced. If the user hasn't liked the post, * the operation is silently ignored (no error thrown). */ async execute(input: UnlikePostUseCaseInput): Promise { @@ -49,6 +51,13 @@ export class UnlikePostUseCase { if (hasLiked) { await ctx.postLikeRepository.unlike(input.postId, input.userId); await ctx.postLikeRepository.decrementLikeCount(input.postId); + + await ctx.notificationRepository.deleteByTarget({ + recipientId: post.author.id, + issuerId: input.userId, + type: NotificationType.LIKE, + postId: input.postId, + }); } }); await this.cacheService.deleteByPattern( diff --git a/src/infrastructure/persistence/repositories/prisma-notification.repository.ts b/src/infrastructure/persistence/repositories/prisma-notification.repository.ts index aa06a9f9..8adb0e51 100644 --- a/src/infrastructure/persistence/repositories/prisma-notification.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-notification.repository.ts @@ -1,8 +1,10 @@ import type { INotificationRepository, FindNotificationsInput, + DeleteNotificationInput, } from "@core/ports/repositories/notification.repository"; import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; +import type { NotificationType as PrismaNotificationType } from "@generated/prisma/client"; import type { Notification } from "@core/domain/entities/notification.entity"; import { NotificationPrismaMapper } from "@infrastructure/persistence/mappers/notification-prisma.mapper"; @@ -87,6 +89,24 @@ export class PrismaNotificationRepository implements INotificationRepository { }); } + async deleteByTarget(input: DeleteNotificationInput): Promise { + // The unset targets are matched as explicit NULLs rather than left + // out: Prisma drops an undefined filter, which would let a post like + // delete the article like sitting next to it. + const result = await this.prisma.notification.deleteMany({ + where: { + recipientId: input.recipientId, + issuerId: input.issuerId, + type: input.type as unknown as PrismaNotificationType, + postId: input.postId ?? null, + articleId: input.articleId ?? null, + commentId: input.commentId ?? null, + }, + }); + + return result.count; + } + async markAsRead( notificationId: string, recipientId: string, diff --git a/tests/e2e/notification/cleanup-on-undo.test.ts b/tests/e2e/notification/cleanup-on-undo.test.ts new file mode 100644 index 00000000..744a687b --- /dev/null +++ b/tests/e2e/notification/cleanup-on-undo.test.ts @@ -0,0 +1,177 @@ +import { authRequest, parseBody, request } from "../setup"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * E2E tests for taking a notification back when its action is undone. + * + * Unliking or unfollowing must remove the notification it produced, so the + * recipient is never left with a notification for something that no longer + * happened, and toggling the action cannot pile up duplicates. + */ +describe("Notification cleanup on undo", () => { + const ts = Date.now(); + const owner = { + email: `ntf-cu-a-${ts}@test.com`, + password: "password123", + username: `ntfcua${ts}`, + }; + const actor = { + email: `ntf-cu-b-${ts}@test.com`, + password: "password123", + username: `ntfcub${ts}`, + }; + + let ownerToken = ""; + let ownerId = ""; + let actorToken = ""; + let postId = ""; + let ownerCommentId = ""; + + async function register(user: { + email: string; + password: string; + username: string; + }): Promise { + const response = await request({ + method: "POST", + url: "/auth/register", + payload: user, + }); + return parseBody<{ data: { id: string } }>(response).data.id; + } + + async function login(user: { + email: string; + password: string; + }): Promise { + const response = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + return parseBody<{ data: { accessToken: string } }>(response).data + .accessToken; + } + + async function notificationTypes(token: string): Promise { + const response = await authRequest(token, { + method: "GET", + url: "/notifications?limit=50", + }); + return parseBody<{ data: { type: string }[] }>(response).data.map( + (n) => n.type, + ); + } + + async function unreadCount(token: string): Promise { + const response = await authRequest(token, { + method: "GET", + url: "/notifications/unread-count", + }); + return parseBody<{ data: { count: number } }>(response).data.count; + } + + beforeAll(async () => { + ownerId = await register(owner); + await register(actor); + + ownerToken = await login(owner); + actorToken = await login(actor); + + const createdPost = await authRequest(ownerToken, { + method: "POST", + url: "/posts", + payload: { content: "Undo cleanup target" }, + }); + postId = parseBody<{ data: { id: string } }>(createdPost).data.id; + + const ownerComment = await authRequest(ownerToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Owner's own comment" }, + }); + ownerCommentId = parseBody<{ data: { id: string } }>(ownerComment).data + .id; + }); + + it("should remove the like notification when the post is unliked", async () => { + await authRequest(actorToken, { + method: "POST", + url: `/posts/${postId}/like`, + }); + expect(await notificationTypes(ownerToken)).toContain("LIKE"); + + await authRequest(actorToken, { + method: "DELETE", + url: `/posts/${postId}/unlike`, + }); + + expect(await notificationTypes(ownerToken)).not.toContain("LIKE"); + }); + + it("should not stack duplicates when a like is toggled", async () => { + for (let i = 0; i < 3; i++) { + await authRequest(actorToken, { + method: "POST", + url: `/posts/${postId}/like`, + }); + await authRequest(actorToken, { + method: "DELETE", + url: `/posts/${postId}/unlike`, + }); + } + + await authRequest(actorToken, { + method: "POST", + url: `/posts/${postId}/like`, + }); + + const likes = (await notificationTypes(ownerToken)).filter( + (type) => type === "LIKE", + ); + expect(likes).toHaveLength(1); + + await authRequest(actorToken, { + method: "DELETE", + url: `/posts/${postId}/unlike`, + }); + }); + + it("should remove the comment like notification when the comment is unliked", async () => { + await authRequest(actorToken, { + method: "POST", + url: `/comments/${ownerCommentId}/like`, + }); + expect(await notificationTypes(ownerToken)).toContain("COMMENT_LIKE"); + + await authRequest(actorToken, { + method: "DELETE", + url: `/comments/${ownerCommentId}/unlike`, + }); + + expect(await notificationTypes(ownerToken)).not.toContain( + "COMMENT_LIKE", + ); + }); + + it("should remove the follow notification when the follow is undone", async () => { + await authRequest(actorToken, { + method: "POST", + url: "/follows", + payload: { targetId: ownerId }, + }); + expect(await notificationTypes(ownerToken)).toContain("FOLLOW"); + + await authRequest(actorToken, { + method: "DELETE", + url: "/follows", + payload: { targetId: ownerId }, + }); + + expect(await notificationTypes(ownerToken)).not.toContain("FOLLOW"); + }); + + it("should leave the owner with nothing unread once everything is undone", async () => { + expect(await unreadCount(ownerToken)).toBe(0); + }); +}); diff --git a/tests/integration/persistence/repositories/prisma-notification.repository.test.ts b/tests/integration/persistence/repositories/prisma-notification.repository.test.ts index 265bb70e..ede58746 100644 --- a/tests/integration/persistence/repositories/prisma-notification.repository.test.ts +++ b/tests/integration/persistence/repositories/prisma-notification.repository.test.ts @@ -167,6 +167,117 @@ describe("PrismaNotificationRepository (integration)", () => { }); }); + describe("deleteByTarget()", () => { + it("should remove only the notification for the given target", async () => { + const post = await prisma.post.create({ + data: { content: "Liked post", authorId: recipientId }, + }); + const article = await prisma.article.create({ + data: { + slug: "liked-article", + title: "Liked article", + body: "body", + authorId: recipientId, + }, + }); + + await notifRepo.create( + Notification.create( + recipientId, + issuerId, + NotificationType.LIKE, + { postId: post.id }, + ), + ); + await notifRepo.create( + Notification.create( + recipientId, + issuerId, + NotificationType.LIKE, + { articleId: article.id }, + ), + ); + + const deleted = await notifRepo.deleteByTarget({ + recipientId, + issuerId, + type: NotificationType.LIKE, + postId: post.id, + }); + + expect(deleted).toBe(1); + expect( + await prisma.notification.count({ + where: { postId: post.id }, + }), + ).toBe(0); + // The article like sits next to it with the same type, recipient + // and issuer - only the target keeps them apart. + expect( + await prisma.notification.count({ + where: { articleId: article.id }, + }), + ).toBe(1); + + await prisma.post.delete({ where: { id: post.id } }); + await prisma.article.delete({ where: { id: article.id } }); + }); + + it("should remove a follow notification, which has no target", async () => { + await notifRepo.create( + Notification.create( + recipientId, + issuerId, + NotificationType.FOLLOW, + ), + ); + + const deleted = await notifRepo.deleteByTarget({ + recipientId, + issuerId, + type: NotificationType.FOLLOW, + }); + + expect(deleted).toBeGreaterThanOrEqual(1); + expect( + await prisma.notification.count({ + where: { + recipientId, + issuerId, + type: NotificationType.FOLLOW, + }, + }), + ).toBe(0); + }); + + it("should leave another issuer's notification alone", async () => { + await notifRepo.create( + Notification.create( + recipientId, + issuerId, + NotificationType.FOLLOW, + ), + ); + + const deleted = await notifRepo.deleteByTarget({ + recipientId, + issuerId: recipientId, + type: NotificationType.FOLLOW, + }); + + expect(deleted).toBe(0); + expect( + await prisma.notification.count({ + where: { + recipientId, + issuerId, + type: NotificationType.FOLLOW, + }, + }), + ).toBe(1); + }); + }); + describe("markAsRead()", () => { it("should mark a single notification as read for its recipient", async () => { await notifRepo.create( diff --git a/tests/unit/core/use-cases/article/article-interactions.usecase.test.ts b/tests/unit/core/use-cases/article/article-interactions.usecase.test.ts index 3e92e773..b894214f 100644 --- a/tests/unit/core/use-cases/article/article-interactions.usecase.test.ts +++ b/tests/unit/core/use-cases/article/article-interactions.usecase.test.ts @@ -25,7 +25,10 @@ describe("article like and bookmark use cases", () => { let articleRepo: Pick; let likeRepo: IArticleLikeRepository; let bookmarkRepo: IArticleBookmarkRepository; - let notificationRepo: Pick; + let notificationRepo: Pick< + INotificationRepository, + "create" | "deleteByTarget" + >; let realtimeSvc: Pick; let transactionSvc: Pick; @@ -50,7 +53,10 @@ describe("article like and bookmark use cases", () => { remove: vi.fn().mockResolvedValue(undefined), isBookmarked: vi.fn().mockResolvedValue(false), }; - notificationRepo = { create: vi.fn() }; + notificationRepo = { + create: vi.fn(), + deleteByTarget: vi.fn().mockResolvedValue(1), + }; realtimeSvc = { emitToUser: vi.fn() }; transactionSvc = { runInTransaction: vi.fn().mockImplementation(async (work) => @@ -175,6 +181,27 @@ describe("article like and bookmark use cases", () => { expect(likeRepo.unlike).not.toHaveBeenCalled(); expect(likeRepo.decrementLikeCount).not.toHaveBeenCalled(); }); + + it("should take back the notification the like had produced", async () => { + vi.mocked(likeRepo.isLiked).mockResolvedValue(true); + + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + expect(notificationRepo.deleteByTarget).toHaveBeenCalledWith({ + recipientId: AUTHOR, + issuerId: READER, + type: NotificationType.LIKE, + articleId: ARTICLE, + }); + }); + + it("should not touch notifications when there was no like to undo", async () => { + vi.mocked(likeRepo.isLiked).mockResolvedValue(false); + + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + expect(notificationRepo.deleteByTarget).not.toHaveBeenCalled(); + }); }); describe("SaveArticleBookmarkUseCase", () => { diff --git a/tests/unit/core/use-cases/comment/unlike-comment.usecase.test.ts b/tests/unit/core/use-cases/comment/unlike-comment.usecase.test.ts index 3afb7ce5..ba2a0008 100644 --- a/tests/unit/core/use-cases/comment/unlike-comment.usecase.test.ts +++ b/tests/unit/core/use-cases/comment/unlike-comment.usecase.test.ts @@ -7,6 +7,8 @@ import type { } from "@core/ports/services/transaction.port"; import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; import { buildComment } from "../../../helpers/mock-factories"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; describe("UnlikeCommentUseCase", () => { let useCase: UnlikeCommentUseCase; @@ -15,12 +17,15 @@ describe("UnlikeCommentUseCase", () => { ICommentRepository, "findById" | "hasUserLiked" | "removeLike" | "decrementLikeCount" >; + let txNotificationRepo: Pick; const input = { commentId: "comment-1", userId: "user-1" }; const buildTransactionContext = (): TransactionContext => ({ commentRepository: txCommentRepo as ICommentRepository, + notificationRepository: + txNotificationRepo as INotificationRepository, }) as TransactionContext; beforeEach(() => { @@ -30,6 +35,7 @@ describe("UnlikeCommentUseCase", () => { removeLike: vi.fn(), decrementLikeCount: vi.fn(), }; + txNotificationRepo = { deleteByTarget: vi.fn().mockResolvedValue(1) }; transactionSvc = { runInTransaction: vi.fn() }; vi.mocked(transactionSvc.runInTransaction).mockImplementation( @@ -73,4 +79,35 @@ describe("UnlikeCommentUseCase", () => { "comment-1", ); }); + + it("should take back the notification the like had produced", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ authorId: "comment-author", postId: "post-1" }), + ); + vi.mocked(txCommentRepo.hasUserLiked).mockResolvedValue(true); + vi.mocked(txCommentRepo.removeLike).mockResolvedValue(undefined); + vi.mocked(txCommentRepo.decrementLikeCount).mockResolvedValue( + undefined, + ); + + await useCase.execute(input); + + expect(txNotificationRepo.deleteByTarget).toHaveBeenCalledWith({ + recipientId: "comment-author", + issuerId: "user-1", + type: NotificationType.COMMENT_LIKE, + commentId: "comment-1", + postId: "post-1", + articleId: undefined, + }); + }); + + it("should not touch notifications when there was no like to undo", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue(buildComment()); + vi.mocked(txCommentRepo.hasUserLiked).mockResolvedValue(false); + + await useCase.execute(input); + + expect(txNotificationRepo.deleteByTarget).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/core/use-cases/follow-user/unfollow-user.usecase.test.ts b/tests/unit/core/use-cases/follow-user/unfollow-user.usecase.test.ts index 1e5a5502..9c233ff8 100644 --- a/tests/unit/core/use-cases/follow-user/unfollow-user.usecase.test.ts +++ b/tests/unit/core/use-cases/follow-user/unfollow-user.usecase.test.ts @@ -3,6 +3,8 @@ import { UnfollowUserUseCase } from "@core/use-cases/follow-user/unfollow-user/u import { BadRequestError, NotFoundError } from "@core/errors"; import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; import { buildProfile } from "../../../helpers/mock-factories"; describe("UnfollowUserUseCase", () => { @@ -12,6 +14,7 @@ describe("UnfollowUserUseCase", () => { "checkIsFollowing" | "unfollowUser" | "getFollowersCount" >; let profileRepo: Pick; + let notificationRepo: Pick; beforeEach(() => { followRepo = { @@ -20,10 +23,12 @@ describe("UnfollowUserUseCase", () => { getFollowersCount: vi.fn().mockResolvedValue(10), }; profileRepo = { findByUserId: vi.fn() }; + notificationRepo = { deleteByTarget: vi.fn().mockResolvedValue(1) }; useCase = new UnfollowUserUseCase( followRepo as IFollowRepository, profileRepo as IProfileRepository, + notificationRepo as INotificationRepository, ); }); @@ -75,6 +80,33 @@ describe("UnfollowUserUseCase", () => { expect(followRepo.unfollowUser).not.toHaveBeenCalled(); }); + it("should take the follow notification back with the follow", async () => { + vi.mocked(profileRepo.findByUserId).mockResolvedValue( + buildProfile({ userId: "user-2" }), + ); + vi.mocked(followRepo.checkIsFollowing).mockResolvedValue(true); + vi.mocked(followRepo.unfollowUser).mockResolvedValue(undefined); + + await useCase.execute({ currentUserId: "user-1", targetId: "user-2" }); + + expect(notificationRepo.deleteByTarget).toHaveBeenCalledWith({ + recipientId: "user-2", + issuerId: "user-1", + type: NotificationType.FOLLOW, + }); + }); + + it("should not delete notifications when there was no follow to undo", async () => { + vi.mocked(profileRepo.findByUserId).mockResolvedValue( + buildProfile({ userId: "user-2" }), + ); + vi.mocked(followRepo.checkIsFollowing).mockResolvedValue(false); + + await useCase.execute({ currentUserId: "user-1", targetId: "user-2" }); + + expect(notificationRepo.deleteByTarget).not.toHaveBeenCalled(); + }); + it("should always return followersCount regardless of follow state", async () => { vi.mocked(profileRepo.findByUserId).mockResolvedValue( buildProfile({ userId: "user-2" }), diff --git a/tests/unit/core/use-cases/post/unlike-post.usecase.test.ts b/tests/unit/core/use-cases/post/unlike-post.usecase.test.ts index dda86750..c04ed837 100644 --- a/tests/unit/core/use-cases/post/unlike-post.usecase.test.ts +++ b/tests/unit/core/use-cases/post/unlike-post.usecase.test.ts @@ -7,6 +7,7 @@ import type { import type { CachePort } from "@core/ports/services/cache.port"; import { NotFoundError } from "@core/errors"; import { buildPost } from "../../../helpers/mock-factories"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; describe("UnlikePostUseCase", () => { let useCase: UnlikePostUseCase; @@ -14,7 +15,7 @@ describe("UnlikePostUseCase", () => { let cacheService: Pick; let mockCtx: Pick< TransactionContext, - "postRepository" | "postLikeRepository" + "postRepository" | "postLikeRepository" | "notificationRepository" >; beforeEach(() => { @@ -27,6 +28,9 @@ describe("UnlikePostUseCase", () => { unlike: vi.fn().mockResolvedValue(undefined), decrementLikeCount: vi.fn().mockResolvedValue(undefined), } as unknown as TransactionContext["postLikeRepository"], + notificationRepository: { + deleteByTarget: vi.fn().mockResolvedValue(1), + } as unknown as TransactionContext["notificationRepository"], }; transactionService = { runInTransaction: vi @@ -104,4 +108,35 @@ describe("UnlikePostUseCase", () => { useCase.execute({ postId: "post-1", userId: "user-1" }), ).rejects.toThrow("Transaction failed"); }); + + it("should take back the notification the like had produced", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue( + buildPost({ id: "post-1", author: { id: "author-1" } }), + ); + vi.mocked(mockCtx.postLikeRepository.isLiked).mockResolvedValue(true); + + await useCase.execute({ postId: "post-1", userId: "liker-99" }); + + expect( + mockCtx.notificationRepository.deleteByTarget, + ).toHaveBeenCalledWith({ + recipientId: "author-1", + issuerId: "liker-99", + type: NotificationType.LIKE, + postId: "post-1", + }); + }); + + it("should not touch notifications when there was no like to undo", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue( + buildPost({ id: "post-1", author: { id: "author-1" } }), + ); + vi.mocked(mockCtx.postLikeRepository.isLiked).mockResolvedValue(false); + + await useCase.execute({ postId: "post-1", userId: "liker-99" }); + + expect( + mockCtx.notificationRepository.deleteByTarget, + ).not.toHaveBeenCalled(); + }); });