From 627e3868026b37d031ddbedfa46713ffa260af2c Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 27 Aug 2026 06:11:23 +0300 Subject: [PATCH] feat(notification): add unread count and per-notification read The badge had no endpoint behind it: getUnreadCount() existed on the repository and the port but nothing ever called it, so a client had to page through the whole list and count. And read state was all or nothing - tapping one notification could only clear every one of them, because the notification id never left Prisma. Adds GET /notifications/unread-count and PATCH /notifications/:id/read. The repository scopes the single update by recipient with updateMany, so another user's notification matches nothing and answers 404 rather than confirming that the id is real. Fixes three defects found on the way: - PATCH /notifications/read-all marked EVERY user's notifications as read. The controller passed a bare string where the use case takes { userId }, so it destructured to undefined and Prisma dropped the recipientId filter from updateMany entirely. - src/http/types/fastify-jwt.d.ts imported UserPayload from a path that has not existed since it moved to the token port. skipLibCheck hid the dangling import, request.user degraded to any project-wide, and that is why the call above type-checked. The sign payload is now a union with RecoveryPayload, which the recovery token needs. - src/http/types/fastify-awilix.d.ts imported NotificationController from the non-existent @services alias, leaving it any in the Cradle. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PwzkQ5YGFXSB9jWCZKzX4H --- .../repositories/notification.repository.ts | 12 ++ .../use-cases/notification/mark-one/index.ts | 6 + ...mark-notification-as-read-usecase.input.ts | 14 ++ .../mark-notification-as-read.usecase.ts | 41 ++++ ...unread-notification-count-usecase.input.ts | 9 + .../get-unread-notification-count.usecase.ts | 29 +++ .../notification/unread-count/index.ts | 6 + .../controllers/notification.controller.ts | 39 +++- src/http/plugins/di/use-cases.di.ts | 16 ++ src/http/routes/notification.routes.ts | 35 +++- src/http/types/fastify-awilix.d.ts | 2 +- src/http/types/fastify-jwt.d.ts | 12 +- .../notification/get-notification.schema.ts | 15 ++ .../prisma-notification.repository.ts | 15 ++ tests/e2e/notification/read-state.test.ts | 193 ++++++++++++++++++ .../prisma-notification.repository.test.ts | 65 ++++++ ...-unread-notification-count.usecase.test.ts | 42 ++++ .../mark-notification-as-read.usecase.test.ts | 61 ++++++ 18 files changed, 607 insertions(+), 5 deletions(-) create mode 100644 src/core/use-cases/notification/mark-one/index.ts create mode 100644 src/core/use-cases/notification/mark-one/mark-notification-as-read-usecase.input.ts create mode 100644 src/core/use-cases/notification/mark-one/mark-notification-as-read.usecase.ts create mode 100644 src/core/use-cases/notification/unread-count/get-unread-notification-count-usecase.input.ts create mode 100644 src/core/use-cases/notification/unread-count/get-unread-notification-count.usecase.ts create mode 100644 src/core/use-cases/notification/unread-count/index.ts create mode 100644 tests/e2e/notification/read-state.test.ts create mode 100644 tests/unit/core/use-cases/notification/get-unread-notification-count.usecase.test.ts create mode 100644 tests/unit/core/use-cases/notification/mark-notification-as-read.usecase.test.ts diff --git a/src/core/ports/repositories/notification.repository.ts b/src/core/ports/repositories/notification.repository.ts index 27ae6767..34ddf843 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 00000000..a9ad2568 --- /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 00000000..896ed3c6 --- /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 00000000..778745ea --- /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 00000000..3aa28507 --- /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 00000000..0c206afe --- /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 00000000..34b360f1 --- /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 63ca1cea..501a5ff8 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 92d4d13f..68d09627 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 3b659dbb..9429ffb5 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 1e78f8a5..180fbb68 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 8f8a3198..b167711c 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 552054e6..955a89c0 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 f137abf4..aa06a9f9 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 00000000..308a2b5f --- /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 39aea866..265bb70e 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 00000000..6b663b12 --- /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 00000000..53348bf0 --- /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"); + }); +});