From 47a8ef78cf20409a177f4852e601a1c62d7e5ab8 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 27 Aug 2026 07:18:08 +0300 Subject: [PATCH] fix(notification): take the notification back when its action is undone Unliking and unfollowing left their notification behind. The recipient kept being told about something that no longer happened, and because liking only notifies on the transition into liked, toggling the action piled up a fresh notification every round. Adds deleteByTarget() to the notification repository and calls it from unlike-post, unlike-article, unlike-comment and unfollow-user. The unset targets are matched as explicit NULLs rather than left out of the filter: Prisma drops an undefined one, which would let a post like delete the article like sitting next to it, since the two share their type, issuer and recipient and differ only in the target. No unique constraint is needed on top. A second notification could only ever appear after an undo, and the undo now removes the first, so the existing already-liked and already-following guards keep it at one. Deleted comments were already covered by the cascading foreign keys added with the deep-link targets. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PwzkQ5YGFXSB9jWCZKzX4H --- .../repositories/notification.repository.ts | 41 ++++ .../unlike-article/unlike-article.usecase.ts | 8 + .../unlike-comment/unlike-comment.usecase.ts | 10 + .../unfollow-user/unfollow-user.usecase.ts | 13 ++ .../post/unlike-post/unlike-post.usecase.ts | 11 +- .../prisma-notification.repository.ts | 20 ++ .../e2e/notification/cleanup-on-undo.test.ts | 177 ++++++++++++++++++ .../prisma-notification.repository.test.ts | 111 +++++++++++ .../article-interactions.usecase.test.ts | 31 ++- .../comment/unlike-comment.usecase.test.ts | 37 ++++ .../follow-user/unfollow-user.usecase.test.ts | 32 ++++ .../post/unlike-post.usecase.test.ts | 37 +++- 12 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/notification/cleanup-on-undo.test.ts 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(); + }); });