diff --git a/src/core/ports/repositories/notification.repository.ts b/src/core/ports/repositories/notification.repository.ts index 27ae676..34ddf84 100644 --- a/src/core/ports/repositories/notification.repository.ts +++ b/src/core/ports/repositories/notification.repository.ts @@ -43,6 +43,18 @@ export interface INotificationRepository { */ countByUserId(userId: string): Promise; + /** + * Marks a single notification as read. + * + * Scoped to the recipient on purpose: a notification that belongs to + * somebody else must be indistinguishable from one that does not exist. + * + * @param notificationId - The unique identifier of the notification. + * @param recipientId - The user the notification must belong to. + * @returns True when a notification was updated, false when none matched. + */ + markAsRead(notificationId: string, recipientId: string): Promise; + /** * Marks all notifications for a specific user as read. * @param userId - The unique identifier of the user. diff --git a/src/core/use-cases/notification/mark-one/index.ts b/src/core/use-cases/notification/mark-one/index.ts new file mode 100644 index 0000000..a9ad256 --- /dev/null +++ b/src/core/use-cases/notification/mark-one/index.ts @@ -0,0 +1,6 @@ +/** + * This module contains the use case for marking a single notification as + * read, which is what a client calls when the user taps one. + */ +export { MarkNotificationAsReadUseCase } from "./mark-notification-as-read.usecase"; +export type { MarkNotificationAsReadUseCaseInput } from "./mark-notification-as-read-usecase.input"; diff --git a/src/core/use-cases/notification/mark-one/mark-notification-as-read-usecase.input.ts b/src/core/use-cases/notification/mark-one/mark-notification-as-read-usecase.input.ts new file mode 100644 index 0000000..896ed3c --- /dev/null +++ b/src/core/use-cases/notification/mark-one/mark-notification-as-read-usecase.input.ts @@ -0,0 +1,14 @@ +/** + * Input interface for marking a single notification as read. + */ +export interface MarkNotificationAsReadUseCaseInput { + /** + * The ID of the notification to mark as read. + */ + notificationId: string; + + /** + * The ID of the user the notification must belong to. + */ + userId: string; +} diff --git a/src/core/use-cases/notification/mark-one/mark-notification-as-read.usecase.ts b/src/core/use-cases/notification/mark-one/mark-notification-as-read.usecase.ts new file mode 100644 index 0000000..778745e --- /dev/null +++ b/src/core/use-cases/notification/mark-one/mark-notification-as-read.usecase.ts @@ -0,0 +1,41 @@ +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import { NotFoundError } from "@core/errors"; +import type { MarkNotificationAsReadUseCaseInput } from "./mark-notification-as-read-usecase.input"; + +/** + * Use case for marking one notification as read. + * + * This is what a client calls when the user taps a notification, so the + * unread badge drops by one instead of the whole list being cleared. + */ +export class MarkNotificationAsReadUseCase { + /** + * Creates a new instance of MarkNotificationAsReadUseCase. + * + * @param notificationRepository - Repository for managing notifications + */ + constructor( + private readonly notificationRepository: INotificationRepository, + ) {} + + /** + * Executes the mark as read process. + * + * @param input - The notification to mark and the user it must belong to + * @returns Promise - Resolves once the notification is read + * + * @throws NotFoundError - When the notification does not exist or belongs + * to somebody else. The two cases answer identically on purpose: telling + * them apart would confirm that a notification id is real. + */ + async execute(input: MarkNotificationAsReadUseCaseInput): Promise { + const updated = await this.notificationRepository.markAsRead( + input.notificationId, + input.userId, + ); + + if (!updated) { + throw new NotFoundError("Notification not found."); + } + } +} diff --git a/src/core/use-cases/notification/unread-count/get-unread-notification-count-usecase.input.ts b/src/core/use-cases/notification/unread-count/get-unread-notification-count-usecase.input.ts new file mode 100644 index 0000000..3aa2850 --- /dev/null +++ b/src/core/use-cases/notification/unread-count/get-unread-notification-count-usecase.input.ts @@ -0,0 +1,9 @@ +/** + * Input interface for reading a user's unread notification count. + */ +export interface GetUnreadNotificationCountInput { + /** + * The ID of the user whose unread notifications are counted. + */ + userId: string; +} diff --git a/src/core/use-cases/notification/unread-count/get-unread-notification-count.usecase.ts b/src/core/use-cases/notification/unread-count/get-unread-notification-count.usecase.ts new file mode 100644 index 0000000..0c206af --- /dev/null +++ b/src/core/use-cases/notification/unread-count/get-unread-notification-count.usecase.ts @@ -0,0 +1,29 @@ +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { GetUnreadNotificationCountInput } from "./get-unread-notification-count-usecase.input"; + +/** + * Use case for reading how many notifications a user has not read yet. + * + * Backs the unread badge, which would otherwise force the client to page + * through the whole list and count client-side. + */ +export class GetUnreadNotificationCountUseCase { + /** + * Creates a new instance of GetUnreadNotificationCountUseCase. + * + * @param notificationRepository - Repository for managing notifications + */ + constructor( + private readonly notificationRepository: INotificationRepository, + ) {} + + /** + * Executes the unread count lookup. + * + * @param input - The input containing the ID of the user to count for + * @returns Promise The number of unread notifications + */ + async execute(input: GetUnreadNotificationCountInput): Promise { + return await this.notificationRepository.getUnreadCount(input.userId); + } +} diff --git a/src/core/use-cases/notification/unread-count/index.ts b/src/core/use-cases/notification/unread-count/index.ts new file mode 100644 index 0000000..34b360f --- /dev/null +++ b/src/core/use-cases/notification/unread-count/index.ts @@ -0,0 +1,6 @@ +/** + * This module contains the use case for reading a user's unread + * notification count, which backs the unread badge. + */ +export { GetUnreadNotificationCountUseCase } from "./get-unread-notification-count.usecase"; +export type { GetUnreadNotificationCountInput } from "./get-unread-notification-count-usecase.input"; diff --git a/src/http/controllers/notification.controller.ts b/src/http/controllers/notification.controller.ts index 63ca1ce..501a5ff 100644 --- a/src/http/controllers/notification.controller.ts +++ b/src/http/controllers/notification.controller.ts @@ -2,12 +2,17 @@ import type { FastifyReply, FastifyRequest } from "fastify"; import type { GetUserNotificationUseCase } from "@core/use-cases/notification/get-user"; import type { GetNotificationsQuery } from "@typings/schemas/notification/get-notification.schema"; import type { MarkAllNotificationsAsReadUseCase } from "@core/use-cases/notification/mark-all"; +import type { MarkNotificationAsReadUseCase } from "@core/use-cases/notification/mark-one"; +import type { GetUnreadNotificationCountUseCase } from "@core/use-cases/notification/unread-count"; +import type { NotificationIdParams } from "@typings/schemas/notification/get-notification.schema"; import { NotificationPrismaMapper } from "@infrastructure/persistence/mappers/notification-prisma.mapper"; export class NotificationController { constructor( private readonly getUserNotificationsUseCase: GetUserNotificationUseCase, private readonly markAllReadUseCase: MarkAllNotificationsAsReadUseCase, + private readonly markNotificationReadUseCase: MarkNotificationAsReadUseCase, + private readonly getUnreadNotificationCountUseCase: GetUnreadNotificationCountUseCase, ) {} async getNotifications( @@ -42,13 +47,45 @@ export class NotificationController { }); } + async getUnreadCount( + request: FastifyRequest, + reply: FastifyReply, + ): Promise { + const userId = request.user.id; + + const count = await this.getUnreadNotificationCountUseCase.execute({ + userId, + }); + + return reply.status(200).send({ + data: { count }, + meta: { + timestamp: new Date().toISOString(), + }, + }); + } + + async markAsRead( + request: FastifyRequest<{ Params: NotificationIdParams }>, + reply: FastifyReply, + ): Promise { + const userId = request.user.id; + + await this.markNotificationReadUseCase.execute({ + notificationId: request.params.id, + userId, + }); + + return reply.status(204).send(); + } + async markAllAsRead( request: FastifyRequest, reply: FastifyReply, ): Promise { const userId = request.user.id; - await this.markAllReadUseCase.execute(userId); + await this.markAllReadUseCase.execute({ userId }); return reply.status(200).send({ meta: { diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index 92d4d13..68d0962 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -30,6 +30,8 @@ import { GetFollowersUseCase } from "@core/use-cases/follow-user/get-followers"; import { GetFollowingUseCase } from "@core/use-cases/follow-user/get-following"; import { GetUserNotificationUseCase } from "@core/use-cases/notification/get-user"; import { MarkAllNotificationsAsReadUseCase } from "@core/use-cases/notification/mark-all"; +import { MarkNotificationAsReadUseCase } from "@core/use-cases/notification/mark-one"; +import { GetUnreadNotificationCountUseCase } from "@core/use-cases/notification/unread-count"; import { PurgeExpiredNotificationsUseCase } from "@core/use-cases/notification/purge-expired"; import { CreatePostUseCase } from "@core/use-cases/post/create-post"; import { UploadPostMediaUseCase } from "@core/use-cases/post/upload-post-media"; @@ -292,6 +294,20 @@ export const useCasesModule = { GetUserNotificationUseCase, ).singleton(), + /** + * Use case for reading the unread notification count + */ + getUnreadNotificationCountUseCase: asClass( + GetUnreadNotificationCountUseCase, + ).singleton(), + + /** + * Use case for marking a single notification as read + */ + markNotificationReadUseCase: asClass( + MarkNotificationAsReadUseCase, + ).singleton(), + /** * Use case for marking all notifications as read */ diff --git a/src/http/routes/notification.routes.ts b/src/http/routes/notification.routes.ts index 3b659db..9429ffb 100644 --- a/src/http/routes/notification.routes.ts +++ b/src/http/routes/notification.routes.ts @@ -3,7 +3,8 @@ * * This module defines API endpoints for notification management including: * - Retrieving user notifications with pagination and filtering - * - Marking all notifications as read + * - Reading the unread count that backs the notification badge + * - Marking a single notification, or all of them, as read * * @author TDN Team * @version 1.0.0 @@ -16,6 +17,10 @@ import { GetNotificationsResponseSchema, type GetNotificationsResponse, MarkAllReadResponseSchema, + NotificationIdParamsSchema, + type NotificationIdParams, + UnreadCountResponseSchema, + type UnreadCountResponse, } from "@typings/schemas/notification/get-notification.schema"; import type { FastifyInstance } from "fastify"; @@ -46,6 +51,21 @@ export default function notificationRoutes(fastify: FastifyInstance): void { notificationController.getNotifications.bind(notificationController), ); + fastify.get<{ Reply: { 200: UnreadCountResponse } }>( + "/notifications/unread-count", + { + onRequest: [fastify.authenticate], + schema: { + response: { 200: UnreadCountResponseSchema }, + tags: ["Notification"], + }, + config: { rateLimit: RateLimitPolicies.STANDARD }, + }, + notificationController.getUnreadCount.bind(notificationController), + ); + + // Declared before "/notifications/:id/read" only for readability: the + // static "read-all" segment outscores the ":id" parameter either way. fastify.patch( "/notifications/read-all", { @@ -58,4 +78,17 @@ export default function notificationRoutes(fastify: FastifyInstance): void { }, notificationController.markAllAsRead.bind(notificationController), ); + + fastify.patch<{ Params: NotificationIdParams; Reply: { 204: void } }>( + "/notifications/:id/read", + { + onRequest: [fastify.authenticate], + schema: { + params: NotificationIdParamsSchema, + tags: ["Notification"], + }, + config: { rateLimit: RateLimitPolicies.STANDARD }, + }, + notificationController.markAsRead.bind(notificationController), + ); } diff --git a/src/http/types/fastify-awilix.d.ts b/src/http/types/fastify-awilix.d.ts index 1e78f8a..180fbb6 100644 --- a/src/http/types/fastify-awilix.d.ts +++ b/src/http/types/fastify-awilix.d.ts @@ -7,7 +7,7 @@ import type { RefreshTokenPurgeScheduler } from "@infrastructure/jobs/refresh-to import type { ProfileController } from "@services/profile.controller"; import type { FollowUserController } from "@services/follow-user.controller"; import type { WebSocketManager } from "@infrastructure/realtime/websocket/websocket-manager"; -import type NotificationController from "@services/notification.controller"; +import type { NotificationController } from "@controllers/notification.controller"; import type { NotificationPurgeScheduler } from "@infrastructure/jobs/notification/notification-purge.scheduler"; import type PostController from "@services/post.controller"; import type { CommentController } from "@controllers/comment.controller"; diff --git a/src/http/types/fastify-jwt.d.ts b/src/http/types/fastify-jwt.d.ts index 8f8a319..b167711 100644 --- a/src/http/types/fastify-jwt.d.ts +++ b/src/http/types/fastify-jwt.d.ts @@ -1,9 +1,17 @@ -import type { UserPayload } from "@core/interfaces/user-payload.interface"; +import type { + RecoveryPayload, + UserPayload, +} from "@core/ports/services/auth-token.port"; import "@fastify/jwt"; declare module "@fastify/jwt" { interface FastifyJWT { - payload: UserPayload; + // Two kinds of token are signed with the same secret: the access token + // that identifies a user, and the short-lived account recovery token. + payload: UserPayload | RecoveryPayload; + + // Only an access token ever reaches `request.user`, because the + // recovery token is verified explicitly by the recovery use case. user: UserPayload; } } diff --git a/src/http/types/schemas/notification/get-notification.schema.ts b/src/http/types/schemas/notification/get-notification.schema.ts index 552054e..955a89c 100644 --- a/src/http/types/schemas/notification/get-notification.schema.ts +++ b/src/http/types/schemas/notification/get-notification.schema.ts @@ -56,3 +56,18 @@ export type GetNotificationsResponse = Static< export const MarkAllReadResponseSchema = MetaOnlyResponseSchema; export type MarkAllReadResponse = Static; + +export const NotificationIdParamsSchema = Type.Object({ + id: Type.String({ format: "uuid", description: "Notification ID" }), +}); +export type NotificationIdParams = Static; + +export const UnreadCountResponseSchema = FBType.Object({ + data: FBType.Object({ + count: FBType.Number(), + }), + meta: FBType.Object({ + timestamp: FBType.String(), + }), +}); +export type UnreadCountResponse = Static; diff --git a/src/infrastructure/persistence/repositories/prisma-notification.repository.ts b/src/infrastructure/persistence/repositories/prisma-notification.repository.ts index f137abf..aa06a9f 100644 --- a/src/infrastructure/persistence/repositories/prisma-notification.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-notification.repository.ts @@ -87,6 +87,21 @@ export class PrismaNotificationRepository implements INotificationRepository { }); } + async markAsRead( + notificationId: string, + recipientId: string, + ): Promise { + // updateMany rather than update: it lets the recipient be part of the + // filter, so another user's notification simply matches nothing + // instead of being updated or leaking its existence through an error. + const result = await this.prisma.notification.updateMany({ + where: { id: notificationId, recipientId }, + data: { isRead: true }, + }); + + return result.count > 0; + } + async markAllAsRead(userId: string): Promise { await this.prisma.notification.updateMany({ where: { diff --git a/tests/e2e/notification/read-state.test.ts b/tests/e2e/notification/read-state.test.ts new file mode 100644 index 0000000..308a2b5 --- /dev/null +++ b/tests/e2e/notification/read-state.test.ts @@ -0,0 +1,193 @@ +import { authRequest, parseBody, request } from "../setup"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * E2E tests for the notification read state endpoints: + * GET /notifications/unread-count and PATCH /notifications/:id/read. + * + * Also covers the scoping guarantee both of them and read-all depend on: + * a user can only ever read their own notifications. + */ +describe("Notification read state", () => { + const ts = Date.now(); + const owner = { + email: `ntf-rs-a-${ts}@test.com`, + password: "password123", + username: `ntfrsa${ts}`, + }; + const follower = { + email: `ntf-rs-b-${ts}@test.com`, + password: "password123", + username: `ntfrsb${ts}`, + }; + const bystander = { + email: `ntf-rs-c-${ts}@test.com`, + password: "password123", + username: `ntfrsc${ts}`, + }; + + let ownerToken = ""; + let ownerId = ""; + let followerToken = ""; + let followerId = ""; + let bystanderToken = ""; + + 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 unreadCount(token: string): Promise { + const response = await authRequest(token, { + method: "GET", + url: "/notifications/unread-count", + }); + return parseBody<{ data: { count: number } }>(response).data.count; + } + + async function newestNotificationId(token: string): Promise { + const response = await authRequest(token, { + method: "GET", + url: "/notifications?limit=1", + }); + return parseBody<{ data: { id: string }[] }>(response).data[0].id; + } + + /** + * Leaves the owner with two unread notifications and the follower with + * one, so a cross-user leak in either direction is visible. + */ + beforeAll(async () => { + ownerId = await register(owner); + followerId = await register(follower); + await register(bystander); + + ownerToken = await login(owner); + followerToken = await login(follower); + bystanderToken = await login(bystander); + + await authRequest(followerToken, { + method: "POST", + url: "/follows", + payload: { targetId: ownerId }, + }); + await authRequest(bystanderToken, { + method: "POST", + url: "/follows", + payload: { targetId: ownerId }, + }); + await authRequest(ownerToken, { + method: "POST", + url: "/follows", + payload: { targetId: followerId }, + }); + }); + + describe("GET /notifications/unread-count", () => { + it("should count only the caller's unread notifications", async () => { + expect(await unreadCount(ownerToken)).toBe(2); + expect(await unreadCount(followerToken)).toBe(1); + }); + + it("should return 401 when not authenticated", async () => { + const response = await request({ + method: "GET", + url: "/notifications/unread-count", + }); + + expect(response.statusCode).toBe(401); + }); + }); + + describe("PATCH /notifications/:id/read", () => { + it("should mark one notification as read and drop the count by one", async () => { + const id = await newestNotificationId(ownerToken); + + const response = await authRequest(ownerToken, { + method: "PATCH", + url: `/notifications/${id}/read`, + }); + + expect(response.statusCode).toBe(204); + expect(await unreadCount(ownerToken)).toBe(1); + }); + + it("should stay at 204 when the notification is already read", async () => { + const id = await newestNotificationId(ownerToken); + + const response = await authRequest(ownerToken, { + method: "PATCH", + url: `/notifications/${id}/read`, + }); + + expect(response.statusCode).toBe(204); + expect(await unreadCount(ownerToken)).toBe(1); + }); + + it("should answer 404 for a notification belonging to somebody else", async () => { + const id = await newestNotificationId(ownerToken); + + const response = await authRequest(followerToken, { + method: "PATCH", + url: `/notifications/${id}/read`, + }); + + expect(response.statusCode).toBe(404); + expect(await unreadCount(followerToken)).toBe(1); + }); + + it("should answer 404 for an unknown notification", async () => { + const response = await authRequest(ownerToken, { + method: "PATCH", + url: "/notifications/00000000-0000-0000-0000-000000000000/read", + }); + + expect(response.statusCode).toBe(404); + }); + + it("should return 401 when not authenticated", async () => { + const id = await newestNotificationId(ownerToken); + + const response = await request({ + method: "PATCH", + url: `/notifications/${id}/read`, + }); + + expect(response.statusCode).toBe(401); + }); + }); + + describe("PATCH /notifications/read-all", () => { + it("should clear only the caller's notifications", async () => { + const response = await authRequest(ownerToken, { + method: "PATCH", + url: "/notifications/read-all", + }); + + expect(response.statusCode).toBe(200); + expect(await unreadCount(ownerToken)).toBe(0); + expect(await unreadCount(followerToken)).toBe(1); + }); + }); +}); diff --git a/tests/integration/persistence/repositories/prisma-notification.repository.test.ts b/tests/integration/persistence/repositories/prisma-notification.repository.test.ts index 39aea86..265bb70 100644 --- a/tests/integration/persistence/repositories/prisma-notification.repository.test.ts +++ b/tests/integration/persistence/repositories/prisma-notification.repository.test.ts @@ -167,6 +167,71 @@ describe("PrismaNotificationRepository (integration)", () => { }); }); + describe("markAsRead()", () => { + it("should mark a single notification as read for its recipient", async () => { + await notifRepo.create( + Notification.create( + recipientId, + issuerId, + NotificationType.FOLLOW, + ), + ); + + const [latest] = await notifRepo.findAllByUserId({ + userId: recipientId, + take: 1, + skip: 0, + }); + + const updated = await notifRepo.markAsRead(latest.id!, recipientId); + + expect(updated).toBe(true); + + const row = await prisma.notification.findUnique({ + where: { id: latest.id! }, + }); + expect(row?.isRead).toBe(true); + }); + + it("should refuse to mark a notification the user does not own", async () => { + await notifRepo.create( + Notification.create( + recipientId, + issuerId, + NotificationType.FOLLOW, + ), + ); + + const [latest] = await notifRepo.findAllByUserId({ + userId: recipientId, + take: 1, + skip: 0, + }); + await prisma.notification.update({ + where: { id: latest.id! }, + data: { isRead: false }, + }); + + const updated = await notifRepo.markAsRead(latest.id!, issuerId); + + expect(updated).toBe(false); + + const row = await prisma.notification.findUnique({ + where: { id: latest.id! }, + }); + expect(row?.isRead).toBe(false); + }); + + it("should report no match for an unknown notification", async () => { + const updated = await notifRepo.markAsRead( + "00000000-0000-0000-0000-000000000000", + recipientId, + ); + + expect(updated).toBe(false); + }); + }); + describe("markAllAsRead()", () => { it("should set isRead=true for all recipient notifications", async () => { await notifRepo.markAllAsRead(recipientId); diff --git a/tests/unit/core/use-cases/notification/get-unread-notification-count.usecase.test.ts b/tests/unit/core/use-cases/notification/get-unread-notification-count.usecase.test.ts new file mode 100644 index 0000000..6b663b1 --- /dev/null +++ b/tests/unit/core/use-cases/notification/get-unread-notification-count.usecase.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetUnreadNotificationCountUseCase } from "@core/use-cases/notification/unread-count"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; + +describe("GetUnreadNotificationCountUseCase", () => { + let useCase: GetUnreadNotificationCountUseCase; + let notificationRepo: Pick; + + beforeEach(() => { + notificationRepo = { + getUnreadCount: vi.fn(), + }; + useCase = new GetUnreadNotificationCountUseCase( + notificationRepo as INotificationRepository, + ); + }); + + it("should return the count for the given user", async () => { + vi.mocked(notificationRepo.getUnreadCount).mockResolvedValue(7); + + const result = await useCase.execute({ userId: "user-1" }); + + expect(result).toBe(7); + expect(notificationRepo.getUnreadCount).toHaveBeenCalledWith("user-1"); + }); + + it("should return zero when nothing is unread", async () => { + vi.mocked(notificationRepo.getUnreadCount).mockResolvedValue(0); + + await expect(useCase.execute({ userId: "user-1" })).resolves.toBe(0); + }); + + it("should propagate repository errors", async () => { + vi.mocked(notificationRepo.getUnreadCount).mockRejectedValue( + new Error("Database connection lost"), + ); + + await expect(useCase.execute({ userId: "user-1" })).rejects.toThrow( + "Database connection lost", + ); + }); +}); diff --git a/tests/unit/core/use-cases/notification/mark-notification-as-read.usecase.test.ts b/tests/unit/core/use-cases/notification/mark-notification-as-read.usecase.test.ts new file mode 100644 index 0000000..53348bf --- /dev/null +++ b/tests/unit/core/use-cases/notification/mark-notification-as-read.usecase.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MarkNotificationAsReadUseCase } from "@core/use-cases/notification/mark-one"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import { NotFoundError } from "@core/errors"; + +describe("MarkNotificationAsReadUseCase", () => { + let useCase: MarkNotificationAsReadUseCase; + let notificationRepo: Pick; + + beforeEach(() => { + notificationRepo = { + markAsRead: vi.fn(), + }; + useCase = new MarkNotificationAsReadUseCase( + notificationRepo as INotificationRepository, + ); + }); + + it("should mark the notification as read for its recipient", async () => { + vi.mocked(notificationRepo.markAsRead).mockResolvedValue(true); + + await useCase.execute({ + notificationId: "notif-1", + userId: "user-1", + }); + + expect(notificationRepo.markAsRead).toHaveBeenCalledWith( + "notif-1", + "user-1", + ); + }); + + it("should throw NotFoundError when nothing matched", async () => { + vi.mocked(notificationRepo.markAsRead).mockResolvedValue(false); + + await expect( + useCase.execute({ notificationId: "notif-1", userId: "user-1" }), + ).rejects.toThrow(NotFoundError); + }); + + it("should answer identically for another user's notification", async () => { + // The repository scopes the update by recipient, so somebody else's + // notification matches nothing and must not be distinguishable from + // one that does not exist. + vi.mocked(notificationRepo.markAsRead).mockResolvedValue(false); + + await expect( + useCase.execute({ notificationId: "notif-1", userId: "intruder" }), + ).rejects.toThrow("Notification not found."); + }); + + it("should propagate repository errors", async () => { + vi.mocked(notificationRepo.markAsRead).mockRejectedValue( + new Error("Database connection lost"), + ); + + await expect( + useCase.execute({ notificationId: "notif-1", userId: "user-1" }), + ).rejects.toThrow("Database connection lost"); + }); +});