From ca68ad7e43c102ed6a06594217d05cb1d214cb12 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 17:33:39 +0300 Subject: [PATCH 01/29] fix(auth): unify refreshTokenExpiresAt type to Unix timestamp TokenResult.refreshTokenExpiresAt was Date while expiresAt was number (Unix seconds), causing inconsistent token expiry formats in the API response. - auth-token.port.ts: refreshTokenExpiresAt Date -> number - auth-token.service.ts: produce Unix timestamp instead of Date object - login/refresh/oauth-exchange usecases: convert to Date only at DB write boundary - auth.mapper.ts, login.output.ts, refresh.output.ts: update types accordingly --- src/core/ports/services/auth-token.port.ts | 4 ++-- src/core/use-cases/auth/auth.mapper.ts | 8 ++++---- src/core/use-cases/auth/login/login.output.ts | 4 ++-- src/core/use-cases/auth/login/login.usecase.ts | 2 +- src/core/use-cases/auth/refresh/refresh.output.ts | 4 ++-- src/core/use-cases/auth/refresh/refresh.usecase.ts | 2 +- .../oauth/oauth-exchange/oauth-exchange.usecase.ts | 2 +- src/infrastructure/security/auth-token.service.ts | 5 ++--- 8 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/core/ports/services/auth-token.port.ts b/src/core/ports/services/auth-token.port.ts index ea38585..e3b5e18 100644 --- a/src/core/ports/services/auth-token.port.ts +++ b/src/core/ports/services/auth-token.port.ts @@ -22,8 +22,8 @@ export interface TokenResult { /** The opaque random refresh token string. */ refreshToken: string; - /** The date at which the refresh token expires. */ - refreshTokenExpiresAt: Date; + /** The Unix timestamp (in seconds) at which the refresh token expires. */ + refreshTokenExpiresAt: number; } /** diff --git a/src/core/use-cases/auth/auth.mapper.ts b/src/core/use-cases/auth/auth.mapper.ts index 8a43728..ff064ae 100644 --- a/src/core/use-cases/auth/auth.mapper.ts +++ b/src/core/use-cases/auth/auth.mapper.ts @@ -35,12 +35,12 @@ export class AuthMapper { accessToken: string; expiresAt: number; refreshToken: string; - refreshTokenExpiresAt: Date; + refreshTokenExpiresAt: number; }): { accessToken: string; expiresAt: number; refreshToken: string; - refreshTokenExpiresAt: Date; + refreshTokenExpiresAt: number; } { return { accessToken: tokens.accessToken, @@ -61,14 +61,14 @@ export class AuthMapper { accessToken: string; expiresAt: number; refreshToken: string; - refreshTokenExpiresAt: Date; + refreshTokenExpiresAt: number; }): { user: { id: string; username: string }; tokens: { accessToken: string; expiresAt: number; refreshToken: string; - refreshTokenExpiresAt: Date; + refreshTokenExpiresAt: number; }; } { return { diff --git a/src/core/use-cases/auth/login/login.output.ts b/src/core/use-cases/auth/login/login.output.ts index db6a9ca..56591c8 100644 --- a/src/core/use-cases/auth/login/login.output.ts +++ b/src/core/use-cases/auth/login/login.output.ts @@ -24,7 +24,7 @@ export interface LoginOutput { expiresAt: number; /** Long-lived token used to obtain new access tokens */ refreshToken: string; - /** Date indicating when the refresh token expires */ - refreshTokenExpiresAt: Date; + /** Unix timestamp (in seconds) indicating when the refresh token expires */ + refreshTokenExpiresAt: number; }; } diff --git a/src/core/use-cases/auth/login/login.usecase.ts b/src/core/use-cases/auth/login/login.usecase.ts index 1630167..75f5dae 100644 --- a/src/core/use-cases/auth/login/login.usecase.ts +++ b/src/core/use-cases/auth/login/login.usecase.ts @@ -90,7 +90,7 @@ export class LoginUseCase { userId: user.id, deviceIp: input.deviceIp, userAgent: input.userAgent, - expiresAt: refreshTokenExpiresAt, + expiresAt: new Date(refreshTokenExpiresAt * 1000), }); return { diff --git a/src/core/use-cases/auth/refresh/refresh.output.ts b/src/core/use-cases/auth/refresh/refresh.output.ts index 2a4d16b..e8e979d 100644 --- a/src/core/use-cases/auth/refresh/refresh.output.ts +++ b/src/core/use-cases/auth/refresh/refresh.output.ts @@ -4,7 +4,7 @@ import type { UserPayload } from "@core/ports/services/auth-token.port"; * @property {string} accessToken - The new access token for the user * @property {number} expiresAt - The timestamp when the access token expires * @property {string} refreshToken - The new refresh token for the user - * @property {Date} refreshTokenExpiresAt - The expiration date of the new refresh token + * @property {number} refreshTokenExpiresAt - The Unix timestamp (in seconds) of the new refresh token expiration * @property {UserPayload} user - The payload containing user information included in the token */ export interface RefreshOutput { @@ -23,7 +23,7 @@ export interface RefreshOutput { /** * The expiration date of the new refresh token, used by clients to know when they need to prompt the user to log in again */ - refreshTokenExpiresAt: Date; + refreshTokenExpiresAt: number; /** * The payload containing user information included in the token, which can be used by clients to display user info without needing to decode the token themselves */ diff --git a/src/core/use-cases/auth/refresh/refresh.usecase.ts b/src/core/use-cases/auth/refresh/refresh.usecase.ts index b4fa652..720fb53 100644 --- a/src/core/use-cases/auth/refresh/refresh.usecase.ts +++ b/src/core/use-cases/auth/refresh/refresh.usecase.ts @@ -93,7 +93,7 @@ export class RefreshUseCase { userId: user.id, deviceIp: input.deviceIp, userAgent: input.userAgent, - expiresAt: refreshTokenExpiresAt, + expiresAt: new Date(refreshTokenExpiresAt * 1000), }); return { diff --git a/src/core/use-cases/oauth/oauth-exchange/oauth-exchange.usecase.ts b/src/core/use-cases/oauth/oauth-exchange/oauth-exchange.usecase.ts index 5cf9421..54b0136 100644 --- a/src/core/use-cases/oauth/oauth-exchange/oauth-exchange.usecase.ts +++ b/src/core/use-cases/oauth/oauth-exchange/oauth-exchange.usecase.ts @@ -51,7 +51,7 @@ export class OAuthExchangeUseCase { userId: payload.userId, deviceIp: input.deviceIp, userAgent: input.userAgent, - expiresAt: refreshTokenExpiresAt, + expiresAt: new Date(refreshTokenExpiresAt * 1000), }); return { diff --git a/src/infrastructure/security/auth-token.service.ts b/src/infrastructure/security/auth-token.service.ts index 6719137..c5542e3 100644 --- a/src/infrastructure/security/auth-token.service.ts +++ b/src/infrastructure/security/auth-token.service.ts @@ -21,9 +21,8 @@ export class AuthTokenService implements AuthTokenPort { const accessTokenExpiresAt = nowInSeconds + this.expiresInSeconds; const refreshToken = randomBytes(40).toString("hex"); - const refreshTokenExpiresAt = new Date( - Date.now() + this.refreshTokenExpiresInSeconds * 1000, - ); + const refreshTokenExpiresAt = + Math.floor(Date.now() / 1000) + this.refreshTokenExpiresInSeconds; return { accessToken, From 414c66c5ab70f888565d8a91515c66200ee84703 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 17:34:12 +0300 Subject: [PATCH 02/29] test(auth): add CheckUserUseCase and LoginUseCase unit tests - CheckUserUseCase: 3 tests (user found, user not found, correct identifier passed) - LoginUseCase: 5 tests (user not found, OAuth user, wrong password, soft-deleted account, happy path token return) - add tests/unit/helpers/mock-factories.ts with buildUser() factory --- .../use-cases/auth/check-user.usecase.test.ts | 38 +++++++ .../core/use-cases/auth/login-usecase.test.ts | 102 ++++++++++++++++++ tests/unit/helpers/mock-factories.ts | 17 +++ 3 files changed, 157 insertions(+) create mode 100644 tests/unit/core/use-cases/auth/check-user.usecase.test.ts create mode 100644 tests/unit/core/use-cases/auth/login-usecase.test.ts create mode 100644 tests/unit/helpers/mock-factories.ts diff --git a/tests/unit/core/use-cases/auth/check-user.usecase.test.ts b/tests/unit/core/use-cases/auth/check-user.usecase.test.ts new file mode 100644 index 0000000..798a121 --- /dev/null +++ b/tests/unit/core/use-cases/auth/check-user.usecase.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { CheckUserUseCase } from "@core/use-cases/auth/check-user/check-user.usecase"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import { buildUser } from "../../../helpers/mock-factories"; + +describe("CheckUserUseCase", () => { + let useCase: CheckUserUseCase; + let userRepo: Pick; + + beforeEach(() => { + userRepo = { findByIdentifier: vi.fn() }; + useCase = new CheckUserUseCase(userRepo as IUserRepository); + }); + + it("should return true when user exists", async () => { + vi.mocked(userRepo.findByIdentifier).mockResolvedValue(buildUser()); + + const result = await useCase.execute({ identifier: "testuser" }); + + expect(result).toBe(true); + }); + + it("should return false when user does not exist", async () => { + vi.mocked(userRepo.findByIdentifier).mockResolvedValue(null); + + const result = await useCase.execute({ identifier: "nonexistent" }); + + expect(result).toBe(false); + }); + + it("should call repository with the given identifier", async () => { + vi.mocked(userRepo.findByIdentifier).mockResolvedValue(null); + + await useCase.execute({ identifier: "search_term" }); + + expect(userRepo.findByIdentifier).toHaveBeenCalledWith("search_term"); + }); +}); diff --git a/tests/unit/core/use-cases/auth/login-usecase.test.ts b/tests/unit/core/use-cases/auth/login-usecase.test.ts new file mode 100644 index 0000000..6128ac8 --- /dev/null +++ b/tests/unit/core/use-cases/auth/login-usecase.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LoginUseCase } from "@core/use-cases/auth/login/login.usecase"; +import { + AccountPendingDeletionError, + InvalidCredentialsError, +} from "@core/errors"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { PasswordPort } from "@core/ports/services/password.port"; +import type { AuthTokenPort } from "@core/ports/services/auth-token.port"; +import type { IRefreshTokenRepository } from "@core/ports/repositories/refresh-token.repository"; +import { buildUser } from "../../../helpers/mock-factories"; + +describe("LoginUseCase", () => { + let useCase: LoginUseCase; + let userRepo: Pick; + let passwordSvc: Pick; + let authTokenSvc: Pick< + AuthTokenPort, + "generate" | "hashRefreshSecret" | "generateRecoveryToken" + >; + let refreshTokenRepo: Pick; + + const input = { + identifier: "testuser", + password: "plain_password", + deviceIp: "127.0.0.1", + userAgent: "Mozilla/5.0", + }; + + beforeEach(() => { + userRepo = { findByIdentifier: vi.fn() }; + passwordSvc = { verify: vi.fn() }; + authTokenSvc = { + generate: vi.fn(), + hashRefreshSecret: vi.fn(), + generateRecoveryToken: vi.fn(), + }; + refreshTokenRepo = { create: vi.fn() }; + useCase = new LoginUseCase( + userRepo as IUserRepository, + passwordSvc as PasswordPort, + authTokenSvc as AuthTokenPort, + refreshTokenRepo as IRefreshTokenRepository, + ); + }); + + it("should throw InvalidCredentialsError when user does not exist", async () => { + vi.mocked(userRepo.findByIdentifier).mockResolvedValue(null); + await expect(useCase.execute(input)).rejects.toThrow( + InvalidCredentialsError, + ); + }); + + it("should throw InvalidCredentialsError when user has no password (OAuth user)", async () => { + const oauthUser = buildUser({ passwordHash: null }); + vi.mocked(userRepo.findByIdentifier).mockResolvedValue(oauthUser); + await expect(useCase.execute(input)).rejects.toThrow( + InvalidCredentialsError, + ); + }); + + it("should throw InvalidCredentialsError when password is wrong", async () => { + const user = buildUser(); + vi.mocked(userRepo.findByIdentifier).mockResolvedValue(user); + vi.mocked(passwordSvc.verify).mockResolvedValue(false); + await expect(useCase.execute(input)).rejects.toThrow( + InvalidCredentialsError, + ); + }); + + it("should throw AccountPendingDeletionError when account is soft-deleted", async () => { + const deletedUser = buildUser({ deletedAt: new Date() }); + vi.mocked(userRepo.findByIdentifier).mockResolvedValue(deletedUser); + vi.mocked(passwordSvc.verify).mockResolvedValue(true); + vi.mocked(authTokenSvc.generateRecoveryToken).mockReturnValue( + "account_recovery", + ); + await expect(useCase.execute(input)).rejects.toThrow( + AccountPendingDeletionError, + ); + }); + + it("should return tokens and user info on valid credentials", async () => { + const user = buildUser(); + vi.mocked(userRepo.findByIdentifier).mockResolvedValue(user); + vi.mocked(passwordSvc.verify).mockResolvedValue(true); + vi.mocked(authTokenSvc.generate).mockReturnValue({ + accessToken: "access_token", + refreshToken: "refresh_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + refreshTokenExpiresAt: + Math.floor(Date.now() / 1000) + 7 * 24 * 3600, + }); + vi.mocked(authTokenSvc.hashRefreshSecret).mockReturnValue( + "hashed_secret", + ); + const result = await useCase.execute(input); + expect(result.tokens.accessToken).toBe("access_token"); + expect(result.tokens.refreshToken).toBe("refresh_token"); + expect(refreshTokenRepo.create).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/helpers/mock-factories.ts b/tests/unit/helpers/mock-factories.ts new file mode 100644 index 0000000..49ea0e8 --- /dev/null +++ b/tests/unit/helpers/mock-factories.ts @@ -0,0 +1,17 @@ +import { User } from "@core/domain/entities/user.entity"; +import type { UserProps } from "@core/domain/interfaces/user-props.interface"; + +export function buildUser(overrides: Partial = {}): User { + return User.with({ + id: "user-1", + email: "test@example.com", + username: "testuser", + passwordHash: "hashed_password", + isEmailVerified: true, + isBot: false, + deletedAt: null, + createdAt: new Date("2024-01-01T00:00:00Z"), + updatedAt: new Date("2024-01-01T00:00:00Z"), + ...overrides, + }); +} From 396e5f06274da2e95ea09f7fb716b68b83c2e35b Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 18:02:24 +0300 Subject: [PATCH 03/29] refactor(persistence): extract P2002 handling into private helper in PrismaUserRepository --- .../repositories/prisma-user.repository.ts | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/src/infrastructure/persistence/repositories/prisma-user.repository.ts b/src/infrastructure/persistence/repositories/prisma-user.repository.ts index c1673cb..3adef9b 100644 --- a/src/infrastructure/persistence/repositories/prisma-user.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-user.repository.ts @@ -1,8 +1,11 @@ import type { User } from "@core/domain/entities/user.entity"; import type { IUserRepository } from "@core/ports/repositories/user.repository"; import { UserPrismaMapper } from "@infrastructure/persistence/mappers/user-prisma.mapper"; -import { UserAlreadyExistsError } from "@core/errors"; -import { ConflictError } from "@core/errors"; +import { + ConflictError, + UserAlreadyExistsError, + type CustomError, +} from "@core/errors"; import { Prisma } from "@generated/prisma/client"; import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; @@ -57,11 +60,7 @@ export class PrismaUserRepository implements IUserRepository { return UserPrismaMapper.toDomainUser(user); } catch (error: unknown) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === "P2002") throw new UserAlreadyExistsError(); - } - - throw error; + this.handleP2002(error, new UserAlreadyExistsError()); } } /** @@ -100,11 +99,7 @@ export class PrismaUserRepository implements IUserRepository { return UserPrismaMapper.toDomainUser(user); } catch (error: unknown) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === "P2002") throw new UserAlreadyExistsError(); - } - - throw error; + this.handleP2002(error, new UserAlreadyExistsError()); } } /** @@ -281,14 +276,12 @@ export class PrismaUserRepository implements IUserRepository { data: { username }, }); } catch (error: unknown) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === "P2002") { - throw new ConflictError( - "This username is already taken. Please choose another one.", - ); - } - } - throw error; + this.handleP2002( + error, + new ConflictError( + "This username is already taken. Please choose another one.", + ), + ); } } @@ -311,14 +304,22 @@ export class PrismaUserRepository implements IUserRepository { }, }); } catch (error: unknown) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === "P2002") { - throw new ConflictError( - "This email address is already in use by another account.", - ); - } - } - throw error; + this.handleP2002( + error, + new ConflictError( + "This email address is already in use by another account.", + ), + ); + } + } + + private handleP2002(error: unknown, conflictError: CustomError): never { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw conflictError; } + throw error as Error; } } From 0ed8306a0a67c1359e5379532e3906d44aa732fe Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 18:03:10 +0300 Subject: [PATCH 04/29] test(auth): add RegisterUseCase unit tests - 3 test cases: password hashing + delegation args, return value, error propagation --- .../use-cases/auth/register.usecase.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/unit/core/use-cases/auth/register.usecase.test.ts diff --git a/tests/unit/core/use-cases/auth/register.usecase.test.ts b/tests/unit/core/use-cases/auth/register.usecase.test.ts new file mode 100644 index 0000000..65d917d --- /dev/null +++ b/tests/unit/core/use-cases/auth/register.usecase.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RegisterUseCase } from "@core/use-cases/auth/register/register.usecase"; +import type { PasswordPort } from "@core/ports/services/password.port"; +import type { CreateUserUseCase } from "@core/use-cases/user/create-user/create-user.usecase"; +import { buildUser } from "../../../helpers/mock-factories"; + +describe("RegisterUseCase", () => { + let useCase: RegisterUseCase; + let passwordSvc: Pick; + let createUserUseCase: Pick; + + const input = { + username: "testuser", + email: "test@example.com", + password: "plain_password", + }; + + beforeEach(() => { + passwordSvc = { hash: vi.fn() }; + createUserUseCase = { execute: vi.fn() }; + useCase = new RegisterUseCase( + createUserUseCase as CreateUserUseCase, + passwordSvc as PasswordPort, + ); + }); + + it("should hash the password and call createUserUseCase with correct args", async () => { + vi.mocked(passwordSvc.hash).mockResolvedValue("hashed_password"); + const mockUser = buildUser(); + vi.mocked(createUserUseCase.execute).mockResolvedValue(mockUser); + const result = await useCase.execute(input); + expect(passwordSvc.hash).toHaveBeenCalledWith("plain_password"); + expect(createUserUseCase.execute).toHaveBeenCalledWith({ + username: input.username, + email: input.email, + passwordHash: "hashed_password", + }); + expect(result).toBe(mockUser); + }); + + it("should return the user returned by createUserUseCase", async () => { + vi.mocked(passwordSvc.hash).mockResolvedValue("hashed_password"); + const mockUser = buildUser(); + vi.mocked(createUserUseCase.execute).mockResolvedValue(mockUser); + const result = await useCase.execute(input); + expect(result).toBe(mockUser); + }); + + it("should propagate error when createUserUseCase throws", async () => { + vi.mocked(passwordSvc.hash).mockResolvedValue("hashed_password"); + vi.mocked(createUserUseCase.execute).mockRejectedValue( + new Error("db error"), + ); + await expect(useCase.execute(input)).rejects.toThrow("db error"); + }); +}); From 3118bd0ea510b8cc1dc27ae8ad2462498b7055bf Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 18:15:35 +0300 Subject: [PATCH 05/29] refactor(auth): remove unnecessary TransactionPort from LogoutUseCase Single-write operations don't require a transaction. LogoutUseCase now injects IRefreshTokenRepository directly instead of TransactionPort. Also adds buildRefreshToken() factory to test helpers. --- .../use-cases/auth/logout/logout.usecase.ts | 28 ++++++++----------- tests/unit/helpers/mock-factories.ts | 19 +++++++++++++ 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/core/use-cases/auth/logout/logout.usecase.ts b/src/core/use-cases/auth/logout/logout.usecase.ts index 50c2d63..2cfb471 100644 --- a/src/core/use-cases/auth/logout/logout.usecase.ts +++ b/src/core/use-cases/auth/logout/logout.usecase.ts @@ -1,5 +1,5 @@ import type { AuthTokenPort } from "@core/ports/services/auth-token.port"; -import type { TransactionPort } from "@core/ports/services/transaction.port"; +import type { IRefreshTokenRepository } from "@core/ports/repositories/refresh-token.repository"; import type { LogoutInput } from "./logout.input"; /** @@ -12,11 +12,11 @@ export class LogoutUseCase { /** * Creates a new instance of LogoutUseCase. * - * @param transactionService - Service for managing database transactions + * @param refreshTokenRepository - Repository for refresh token operations * @param authTokenService - Service for token operations */ constructor( - private readonly transactionService: TransactionPort, + private readonly refreshTokenRepository: IRefreshTokenRepository, private readonly authTokenService: AuthTokenPort, ) {} @@ -28,26 +28,20 @@ export class LogoutUseCase { * * @remarks * If no token is provided, the method returns silently. - * The operation is performed within a database transaction to ensure consistency. + * If the token is not found or already revoked, the method returns silently. */ async execute(input: LogoutInput): Promise { if (!input.token) { return; } - await this.transactionService.runInTransaction(async (ctx) => { - const tokenHash = this.authTokenService.hashRefreshSecret( - input.token, - ); + const tokenHash = this.authTokenService.hashRefreshSecret(input.token); + const currentToken = + await this.refreshTokenRepository.findByTokenHash(tokenHash); - const currentToken = - await ctx.refreshTokenRepository.findByTokenHash(tokenHash); - - if (currentToken && !currentToken.isRevoked) { - currentToken.revoke(); - - await ctx.refreshTokenRepository.update(currentToken); - } - }); + if (currentToken && !currentToken.isRevoked) { + currentToken.revoke(); + await this.refreshTokenRepository.update(currentToken); + } } } diff --git a/tests/unit/helpers/mock-factories.ts b/tests/unit/helpers/mock-factories.ts index 49ea0e8..f5ef1d7 100644 --- a/tests/unit/helpers/mock-factories.ts +++ b/tests/unit/helpers/mock-factories.ts @@ -1,5 +1,7 @@ import { User } from "@core/domain/entities/user.entity"; import type { UserProps } from "@core/domain/interfaces/user-props.interface"; +import { RefreshToken } from "@core/domain/entities/refresh-token.entity"; +import type { RefreshTokenProps } from "@core/domain/interfaces/refresh-token.props.interface"; export function buildUser(overrides: Partial = {}): User { return User.with({ @@ -15,3 +17,20 @@ export function buildUser(overrides: Partial = {}): User { ...overrides, }); } + +export function buildRefreshToken( + overrides: Partial = {}, +): RefreshToken { + return RefreshToken.with({ + id: "token-1", + tokenHash: "hashed_token", + userId: "user-1", + deviceIp: "127.0.0.1", + userAgent: "Mozilla/5.0", + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + isRevoked: false, + createdAt: new Date("2024-01-01T00:00:00Z"), + updatedAt: new Date("2024-01-01T00:00:00Z"), + ...overrides, + }); +} From 5dadae266df341e30c9a68eadb9399c945f76330 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 18:15:59 +0300 Subject: [PATCH 06/29] test(auth): add LogoutUseCase unit tests - 5 test cases: empty token guard, token hashing, token not found, already revoked, and successful revocation --- .../use-cases/auth/logout.usecase.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/unit/core/use-cases/auth/logout.usecase.test.ts diff --git a/tests/unit/core/use-cases/auth/logout.usecase.test.ts b/tests/unit/core/use-cases/auth/logout.usecase.test.ts new file mode 100644 index 0000000..dac9c2e --- /dev/null +++ b/tests/unit/core/use-cases/auth/logout.usecase.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LogoutUseCase } from "@core/use-cases/auth/logout/logout.usecase"; +import type { IRefreshTokenRepository } from "@core/ports/repositories/refresh-token.repository"; +import type { AuthTokenPort } from "@core/ports/services/auth-token.port"; +import { buildRefreshToken } from "../../../helpers/mock-factories"; + +describe("LogoutUseCase", () => { + let useCase: LogoutUseCase; + let refreshTokenRepo: Pick< + IRefreshTokenRepository, + "findByTokenHash" | "update" + >; + let authTokenSvc: Pick; + + const input = { token: "raw_refresh_token" }; + + beforeEach(() => { + refreshTokenRepo = { + findByTokenHash: vi.fn(), + update: vi.fn(), + }; + authTokenSvc = { hashRefreshSecret: vi.fn() }; + useCase = new LogoutUseCase( + refreshTokenRepo as IRefreshTokenRepository, + authTokenSvc as AuthTokenPort, + ); + }); + + it("should do nothing when token is empty string", async () => { + await useCase.execute({ token: "" }); + + expect(authTokenSvc.hashRefreshSecret).not.toHaveBeenCalled(); + expect(refreshTokenRepo.findByTokenHash).not.toHaveBeenCalled(); + }); + + it("should hash the token and look it up by hash", async () => { + vi.mocked(authTokenSvc.hashRefreshSecret).mockReturnValue( + "hashed_token", + ); + vi.mocked(refreshTokenRepo.findByTokenHash).mockResolvedValue(null); + + await useCase.execute(input); + + expect(authTokenSvc.hashRefreshSecret).toHaveBeenCalledWith( + "raw_refresh_token", + ); + expect(refreshTokenRepo.findByTokenHash).toHaveBeenCalledWith( + "hashed_token", + ); + }); + + it("should do nothing when token is not found", async () => { + vi.mocked(authTokenSvc.hashRefreshSecret).mockReturnValue( + "hashed_token", + ); + vi.mocked(refreshTokenRepo.findByTokenHash).mockResolvedValue(null); + + await useCase.execute(input); + + expect(refreshTokenRepo.update).not.toHaveBeenCalled(); + }); + + it("should do nothing when token is already revoked", async () => { + const revokedToken = buildRefreshToken({ isRevoked: true }); + vi.mocked(authTokenSvc.hashRefreshSecret).mockReturnValue( + "hashed_token", + ); + vi.mocked(refreshTokenRepo.findByTokenHash).mockResolvedValue( + revokedToken, + ); + + await useCase.execute(input); + + expect(refreshTokenRepo.update).not.toHaveBeenCalled(); + }); + + it("should revoke and update the token when found and active", async () => { + const activeToken = buildRefreshToken({ isRevoked: false }); + vi.mocked(authTokenSvc.hashRefreshSecret).mockReturnValue( + "hashed_token", + ); + vi.mocked(refreshTokenRepo.findByTokenHash).mockResolvedValue( + activeToken, + ); + vi.mocked(refreshTokenRepo.update).mockResolvedValue(undefined); + + await useCase.execute(input); + + expect(activeToken.isRevoked).toBe(true); + expect(refreshTokenRepo.update).toHaveBeenCalledWith(activeToken); + }); +}); From 8fc1afb0539d85f9a2370723b40a7c9a214b4987 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 18:42:48 +0300 Subject: [PATCH 07/29] fix(auth): persist refresh token in RecoverAccountUseCase Generated refresh token was never saved to DB, causing all subsequent /auth/refresh calls to fail with 'Session not found' after account recovery. --- .../recover-account/recover-account.input.ts | 8 +++++ .../recover-account.usecase.ts | 29 ++++++++++++++----- src/http/controllers/auth.controller.ts | 2 ++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/core/use-cases/auth/recover-account/recover-account.input.ts b/src/core/use-cases/auth/recover-account/recover-account.input.ts index 5540734..ae2bccd 100644 --- a/src/core/use-cases/auth/recover-account/recover-account.input.ts +++ b/src/core/use-cases/auth/recover-account/recover-account.input.ts @@ -6,4 +6,12 @@ export interface RecoverAccountInput { * The token used to recover the account */ recoveryToken: string; + /** + * The IP address of the device making the recovery request + */ + deviceIp: string; + /** + * The user agent string of the device making the recovery request + */ + userAgent: string; } diff --git a/src/core/use-cases/auth/recover-account/recover-account.usecase.ts b/src/core/use-cases/auth/recover-account/recover-account.usecase.ts index 7cf61d8..a4520f3 100644 --- a/src/core/use-cases/auth/recover-account/recover-account.usecase.ts +++ b/src/core/use-cases/auth/recover-account/recover-account.usecase.ts @@ -1,4 +1,5 @@ import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { IRefreshTokenRepository } from "@core/ports/repositories/refresh-token.repository"; import type { AuthTokenPort, RecoveryPayload, @@ -19,10 +20,12 @@ export class RecoverAccountUseCase { * Creates a new instance of RecoverAccountUseCase. * * @param userRepository - Repository for managing user data + * @param refreshTokenRepository - Repository for persisting refresh tokens * @param authTokenService - Service for token operations */ constructor( private readonly userRepository: IUserRepository, + private readonly refreshTokenRepository: IRefreshTokenRepository, private readonly authTokenService: AuthTokenPort, ) {} @@ -64,9 +67,21 @@ export class RecoverAccountUseCase { await this.userRepository.restoreById(user.id); - const tokens = this.authTokenService.generate({ - id: user.id, - username: user.username, + const { accessToken, expiresAt, refreshToken, refreshTokenExpiresAt } = + this.authTokenService.generate({ + id: user.id, + username: user.username, + }); + + const refreshTokenHash = + this.authTokenService.hashRefreshSecret(refreshToken); + + await this.refreshTokenRepository.create({ + tokenHash: refreshTokenHash, + userId: user.id, + deviceIp: input.deviceIp, + userAgent: input.userAgent, + expiresAt: new Date(refreshTokenExpiresAt * 1000), }); return { @@ -76,10 +91,10 @@ export class RecoverAccountUseCase { isEmailVerified: user.isEmailVerified, }, tokens: AuthMapper.toTokenOutput({ - accessToken: tokens.accessToken, - expiresAt: tokens.expiresAt, - refreshToken: tokens.refreshToken, - refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, + accessToken, + expiresAt, + refreshToken, + refreshTokenExpiresAt, }), }; } diff --git a/src/http/controllers/auth.controller.ts b/src/http/controllers/auth.controller.ts index c64df54..4ce74b8 100644 --- a/src/http/controllers/auth.controller.ts +++ b/src/http/controllers/auth.controller.ts @@ -192,6 +192,8 @@ export class AuthController extends BaseAuthController { ): Promise { const response = await this.recoverAccountUseCase.execute({ recoveryToken: request.body.recoveryToken, + deviceIp: request.ip, + userAgent: request.headers["user-agent"] ?? "Unknown Device", }); this.setRefreshTokenCookie( From 2cbdbfc3fcc0fecf5286645469ff4c9e49fedd73 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 18:45:47 +0300 Subject: [PATCH 08/29] test(auth): add RefreshUseCase unit tests - 6 test cases: token not found, revoked token (reuse attack + revokeAll), expired token, user not found, soft-deleted user, happy path (token rotation) --- .../use-cases/auth/refresh.usecase.test.ts | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 tests/unit/core/use-cases/auth/refresh.usecase.test.ts diff --git a/tests/unit/core/use-cases/auth/refresh.usecase.test.ts b/tests/unit/core/use-cases/auth/refresh.usecase.test.ts new file mode 100644 index 0000000..e36ede2 --- /dev/null +++ b/tests/unit/core/use-cases/auth/refresh.usecase.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RefreshUseCase } from "@core/use-cases/auth/refresh/refresh.usecase"; +import { UnauthorizedError } from "@core/errors"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import type { AuthTokenPort } from "@core/ports/services/auth-token.port"; +import type { IRefreshTokenRepository } from "@core/ports/repositories/refresh-token.repository"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import { buildUser, buildRefreshToken } from "../../../helpers/mock-factories"; + +describe("RefreshUseCase", () => { + let useCase: RefreshUseCase; + let refreshTokenRepo: Pick< + IRefreshTokenRepository, + "findByTokenHash" | "revokeAllByUserId" | "update" | "create" + >; + let userRepo: Pick; + let transactionSvc: Pick; + let authTokenSvc: Pick; + + const input = { + token: "raw_refresh_token", + deviceIp: "127.0.0.1", + userAgent: "Mozilla/5.0", + }; + + beforeEach(() => { + refreshTokenRepo = { + findByTokenHash: vi.fn(), + revokeAllByUserId: vi.fn(), + update: vi.fn(), + create: vi.fn(), + }; + userRepo = { findById: vi.fn() }; + transactionSvc = { + runInTransaction: vi.fn((fn) => + fn({ + refreshTokenRepository: refreshTokenRepo, + userRepository: userRepo, + } as unknown as TransactionContext), + ), + }; + authTokenSvc = { + hashRefreshSecret: vi.fn(), + generate: vi.fn(), + }; + useCase = new RefreshUseCase( + transactionSvc as TransactionPort, + authTokenSvc as AuthTokenPort, + ); + }); + + it("should throw UnauthorizedError when token is not found", async () => { + vi.mocked(authTokenSvc.hashRefreshSecret).mockReturnValue( + "hashed_token", + ); + vi.mocked(refreshTokenRepo.findByTokenHash).mockResolvedValue(null); + + await expect(useCase.execute(input)).rejects.toThrow( + new UnauthorizedError("Session not found"), + ); + }); + + it("should revoke all sessions and throw when token is already revoked (token reuse attack)", async () => { + const revokedToken = buildRefreshToken({ isRevoked: true }); + vi.mocked(authTokenSvc.hashRefreshSecret).mockReturnValue( + "hashed_token", + ); + vi.mocked(refreshTokenRepo.findByTokenHash).mockResolvedValue( + revokedToken, + ); + vi.mocked(refreshTokenRepo.revokeAllByUserId).mockResolvedValue( + undefined, + ); + + await expect(useCase.execute(input)).rejects.toThrow( + new UnauthorizedError( + "Security alert: Session compromised. All sessions revoked.", + ), + ); + + expect(refreshTokenRepo.revokeAllByUserId).toHaveBeenCalledWith( + revokedToken.userId, + ); + }); + + it("should throw UnauthorizedError when token is expired", async () => { + const expiredToken = buildRefreshToken({ + expiresAt: new Date(Date.now() - 1000), + }); + vi.mocked(authTokenSvc.hashRefreshSecret).mockReturnValue( + "hashed_token", + ); + vi.mocked(refreshTokenRepo.findByTokenHash).mockResolvedValue( + expiredToken, + ); + + await expect(useCase.execute(input)).rejects.toThrow( + new UnauthorizedError("Session expired"), + ); + + expect(refreshTokenRepo.update).not.toHaveBeenCalled(); + }); + + it("should throw UnauthorizedError when user is not found", async () => { + const activeToken = buildRefreshToken(); + vi.mocked(authTokenSvc.hashRefreshSecret).mockReturnValue( + "hashed_token", + ); + vi.mocked(refreshTokenRepo.findByTokenHash).mockResolvedValue( + activeToken, + ); + vi.mocked(userRepo.findById).mockResolvedValue(null); + + await expect(useCase.execute(input)).rejects.toThrow( + new UnauthorizedError("User account unavailable"), + ); + }); + + it("should throw UnauthorizedError when user account is soft-deleted", async () => { + const activeToken = buildRefreshToken(); + const deletedUser = buildUser({ deletedAt: new Date() }); + vi.mocked(authTokenSvc.hashRefreshSecret).mockReturnValue( + "hashed_token", + ); + vi.mocked(refreshTokenRepo.findByTokenHash).mockResolvedValue( + activeToken, + ); + vi.mocked(userRepo.findById).mockResolvedValue(deletedUser); + + await expect(useCase.execute(input)).rejects.toThrow( + new UnauthorizedError("User account unavailable"), + ); + }); + + it("should revoke old token, create new token, and return new credentials", async () => { + const activeToken = buildRefreshToken(); + const user = buildUser(); + const newTokens = { + accessToken: "new_access_token", + refreshToken: "new_refresh_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + refreshTokenExpiresAt: + Math.floor(Date.now() / 1000) + 7 * 24 * 3600, + }; + + vi.mocked(authTokenSvc.hashRefreshSecret) + .mockReturnValueOnce("hashed_incoming") + .mockReturnValueOnce("hashed_new"); + vi.mocked(refreshTokenRepo.findByTokenHash).mockResolvedValue( + activeToken, + ); + vi.mocked(userRepo.findById).mockResolvedValue(user); + vi.mocked(authTokenSvc.generate).mockReturnValue(newTokens); + vi.mocked(refreshTokenRepo.update).mockResolvedValue(undefined); + vi.mocked(refreshTokenRepo.create).mockResolvedValue(activeToken); + + const result = await useCase.execute(input); + + expect(activeToken.isRevoked).toBe(true); + expect(refreshTokenRepo.update).toHaveBeenCalledWith(activeToken); + expect(refreshTokenRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + tokenHash: "hashed_new", + userId: user.id, + deviceIp: input.deviceIp, + userAgent: input.userAgent, + }), + ); + + expect(result.accessToken).toBe("new_access_token"); + expect(result.refreshToken).toBe("new_refresh_token"); + expect(result.user.id).toBe(user.id); + }); +}); From 628cccd3dffffca357317c60bb6a62ac66fd6b81 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 18:51:43 +0300 Subject: [PATCH 09/29] fix(auth): replace PasswordService with PasswordPort in ResetPasswordUseCase Direct import from @infrastructure violated Clean Architecture layer rules. Use-cases must only depend on port interfaces. --- .../use-cases/auth/reset-password/reset-password.usecase.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/use-cases/auth/reset-password/reset-password.usecase.ts b/src/core/use-cases/auth/reset-password/reset-password.usecase.ts index 6c37fe1..b206238 100644 --- a/src/core/use-cases/auth/reset-password/reset-password.usecase.ts +++ b/src/core/use-cases/auth/reset-password/reset-password.usecase.ts @@ -1,7 +1,7 @@ import { BadRequestError } from "@core/errors"; import type { IUserRepository } from "@core/ports/repositories/user.repository"; import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository"; -import type { PasswordService } from "@infrastructure/security/password.service"; +import type { PasswordPort } from "@core/ports/services/password.port"; import { TokenType } from "@core/domain/enums/token-type.enum"; import type { CryptoPort } from "@core/ports/services/crypto.port"; import type { ResetPasswordInput } from "./reset-password.input"; @@ -26,7 +26,7 @@ export class ResetPasswordUseCase { constructor( private readonly userRepository: IUserRepository, private readonly verificationTokenRepository: IVerificationTokenRepository, - private readonly passwordService: PasswordService, + private readonly passwordService: PasswordPort, private readonly cryptoService: CryptoPort, ) {} From f6da0e7d3233e7d0198938b6983e94ff3b83cb44 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 18:51:58 +0300 Subject: [PATCH 10/29] test(auth): add ForgotPasswordUseCase unit tests 5 tests: user not found, soft-deleted, unverified email, OAuth-only (no password), happy path (OTP + upsert + email) --- .../auth/forgot-password.usecase.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/unit/core/use-cases/auth/forgot-password.usecase.test.ts diff --git a/tests/unit/core/use-cases/auth/forgot-password.usecase.test.ts b/tests/unit/core/use-cases/auth/forgot-password.usecase.test.ts new file mode 100644 index 0000000..657861f --- /dev/null +++ b/tests/unit/core/use-cases/auth/forgot-password.usecase.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ForgotPasswordUseCase } from "@core/use-cases/auth/forgot-password"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository"; +import type { EmailPort } from "@core/ports/services/email.port"; +import type { CryptoPort } from "@core/ports/services/crypto.port"; +import { TokenType } from "@core/domain/enums/token-type.enum"; +import type { VerificationToken } from "@core/domain/entities/verification-token.entity"; +import { buildUser } from "../../../helpers/mock-factories"; + +describe("ForgotPasswordUseCase", () => { + let userRepo: Pick; + let verificationTokenRepo: Pick; + let emailService: Pick; + let cryptoService: Pick; + let useCase: ForgotPasswordUseCase; + + beforeEach(() => { + userRepo = { findByEmail: vi.fn() }; + verificationTokenRepo = { upsert: vi.fn() }; + emailService = { sendPasswordResetEmail: vi.fn() }; + cryptoService = { + generateOtp: vi.fn().mockReturnValue("12345678"), + hashOtp: vi.fn().mockReturnValue("hashed_otp"), + }; + + useCase = new ForgotPasswordUseCase( + userRepo as IUserRepository, + verificationTokenRepo as IVerificationTokenRepository, + emailService as EmailPort, + cryptoService as CryptoPort, + ); + }); + + it("user not found → returns silently, no email sent", async () => { + vi.mocked(userRepo.findByEmail).mockResolvedValue(null); + + await useCase.execute({ email: "notexists@example.com" }); + + expect(emailService.sendPasswordResetEmail).not.toHaveBeenCalled(); + expect(verificationTokenRepo.upsert).not.toHaveBeenCalled(); + }); + + it("soft-deleted user → returns silently, no email sent", async () => { + vi.mocked(userRepo.findByEmail).mockResolvedValue( + buildUser({ deletedAt: new Date() }), + ); + + await useCase.execute({ email: "test@example.com" }); + + expect(emailService.sendPasswordResetEmail).not.toHaveBeenCalled(); + expect(verificationTokenRepo.upsert).not.toHaveBeenCalled(); + }); + + it("email not verified → returns silently, no email sent", async () => { + vi.mocked(userRepo.findByEmail).mockResolvedValue( + buildUser({ isEmailVerified: false }), + ); + + await useCase.execute({ email: "test@example.com" }); + + expect(emailService.sendPasswordResetEmail).not.toHaveBeenCalled(); + expect(verificationTokenRepo.upsert).not.toHaveBeenCalled(); + }); + + it("OAuth-only user (no passwordHash) → returns silently, no email sent", async () => { + vi.mocked(userRepo.findByEmail).mockResolvedValue( + buildUser({ passwordHash: null }), + ); + + await useCase.execute({ email: "test@example.com" }); + + expect(emailService.sendPasswordResetEmail).not.toHaveBeenCalled(); + expect(verificationTokenRepo.upsert).not.toHaveBeenCalled(); + }); + + it("happy path: generates OTP, upserts token, sends reset email", async () => { + const user = buildUser(); + vi.mocked(userRepo.findByEmail).mockResolvedValue(user); + vi.mocked(verificationTokenRepo.upsert).mockResolvedValue( + {} as VerificationToken, + ); + vi.mocked(emailService.sendPasswordResetEmail).mockResolvedValue(); + + await useCase.execute({ email: user.email }); + + expect(cryptoService.generateOtp).toHaveBeenCalledWith(8); + expect(cryptoService.hashOtp).toHaveBeenCalledWith("12345678"); + expect(verificationTokenRepo.upsert).toHaveBeenCalledWith({ + userId: user.id, + tokenHash: "hashed_otp", + type: TokenType.PASSWORD_RESET, + expiresAt: expect.any(Date), + }); + expect(emailService.sendPasswordResetEmail).toHaveBeenCalledWith({ + to: user.email, + otp: "12345678", + }); + }); +}); From e7f134e6a5e3c8953f29e13e9ebfbc6d75e6f015 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 19:12:11 +0300 Subject: [PATCH 11/29] feat(auth): make OTP expiry configurable via OTP_EXPIRY_MS env var Replaces hardcoded 10-min expiry in ForgotPassword and SendVerificationEmail use-cases with constructor-injected otpExpiryMs. DI passes config.OTP_EXPIRY_MS (default 600_000ms). Keeps Clean Architecture boundaries intact. --- .env.example | 3 ++ .../forgot-password.usecase.ts | 6 ++-- .../send-verification-email.usecase.ts | 3 +- src/http/plugins/di/use-cases.di.ts | 34 +++++++++++++++++-- src/http/types/schemas/env.schema.ts | 1 + 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index b953b25..c86a172 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ ACCESS_TOKEN_SECRET_KEY=your_access_token_secret ACCESS_TOKEN_EXPIRES_IN=900 REFRESH_TOKEN_EXPIRES_IN=1800 +# OTP Configuration +OTP_EXPIRY_SECONDS=600 + # Cookie Configuration COOKIE_SECRET=your_cookie_secret diff --git a/src/core/use-cases/auth/forgot-password/forgot-password.usecase.ts b/src/core/use-cases/auth/forgot-password/forgot-password.usecase.ts index cc67fa9..ba88139 100644 --- a/src/core/use-cases/auth/forgot-password/forgot-password.usecase.ts +++ b/src/core/use-cases/auth/forgot-password/forgot-password.usecase.ts @@ -25,6 +25,7 @@ export class ForgotPasswordUseCase { private readonly verificationTokenRepository: IVerificationTokenRepository, private readonly emailService: EmailPort, private readonly cryptoService: CryptoPort, + private readonly otpExpirySeconds: number = 10 * 60, ) {} /** @@ -54,10 +55,7 @@ export class ForgotPasswordUseCase { const otp = this.cryptoService.generateOtp(8); const tokenHash = this.cryptoService.hashOtp(otp); - /** - * It can then be done from the .env file. - */ - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); + const expiresAt = new Date(Date.now() + this.otpExpirySeconds * 1000); await this.verificationTokenRepository.upsert({ userId: user.id, diff --git a/src/core/use-cases/auth/send-verification-email/send-verification-email.usecase.ts b/src/core/use-cases/auth/send-verification-email/send-verification-email.usecase.ts index b3317ec..985e79b 100644 --- a/src/core/use-cases/auth/send-verification-email/send-verification-email.usecase.ts +++ b/src/core/use-cases/auth/send-verification-email/send-verification-email.usecase.ts @@ -26,6 +26,7 @@ export class SendVerificationEmailUseCase { private readonly verificationTokenRepository: IVerificationTokenRepository, private readonly emailService: EmailPort, private readonly cryptoService: CryptoPort, + private readonly otpExpirySeconds: number = 10 * 60, ) {} /** @@ -55,7 +56,7 @@ export class SendVerificationEmailUseCase { const hashedOtp = this.cryptoService.hashOtp(plainOtp); - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); + const expiresAt = new Date(Date.now() + this.otpExpirySeconds * 1000); await this.verificationTokenRepository.upsert({ userId: user.id, diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index c64db3c..aaa3ff5 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -119,8 +119,21 @@ export const useCasesModule = { /** * Use case for sending email verification */ - sendVerificationEmailUseCase: asClass( - SendVerificationEmailUseCase, + sendVerificationEmailUseCase: asFunction( + ({ + userRepository, + verificationTokenRepository, + emailService, + cryptoService, + config, + }) => + new SendVerificationEmailUseCase( + userRepository, + verificationTokenRepository, + emailService, + cryptoService, + config.OTP_EXPIRY_SECONDS, + ), ).singleton(), /** @@ -131,7 +144,22 @@ export const useCasesModule = { /** * Use case for password reset request */ - forgotPasswordUseCase: asClass(ForgotPasswordUseCase).singleton(), + forgotPasswordUseCase: asFunction( + ({ + userRepository, + verificationTokenRepository, + emailService, + cryptoService, + config, + }) => + new ForgotPasswordUseCase( + userRepository, + verificationTokenRepository, + emailService, + cryptoService, + config.OTP_EXPIRY_SECONDS, + ), + ).singleton(), /** * Use case for password reset confirmation diff --git a/src/http/types/schemas/env.schema.ts b/src/http/types/schemas/env.schema.ts index 70b605d..4c4bb9d 100644 --- a/src/http/types/schemas/env.schema.ts +++ b/src/http/types/schemas/env.schema.ts @@ -14,6 +14,7 @@ export const EnvSchema = Type.Object({ // --- Authentication & Tokens --- ACCESS_TOKEN_EXPIRES_IN: Type.Number({ default: 900 }), REFRESH_TOKEN_EXPIRES_IN: Type.Number({ default: 90000 }), + OTP_EXPIRY_SECONDS: Type.Number({ default: 600 }), REFRESH_TOKEN_PURGE_CRON: Type.String({ default: "0 */6 * * *" }), REFRESH_TOKEN_PURGE_GRACE_PERIOD_DAYS: Type.Number({ default: 24 }), From a4d93e218f9e227188e270851ca8463d9fc9076b Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 19:14:16 +0300 Subject: [PATCH 12/29] test(auth): add SendVerificationEmailUseCase unit tests 5 tests: user not found, soft-deleted, already verified (silent return), happy path (OTP + upsert + email), expiresAt respects OTP_EXPIRY_SECONDS --- .../send-verification-email.usecase.test.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/unit/core/use-cases/auth/send-verification-email.usecase.test.ts diff --git a/tests/unit/core/use-cases/auth/send-verification-email.usecase.test.ts b/tests/unit/core/use-cases/auth/send-verification-email.usecase.test.ts new file mode 100644 index 0000000..6d401df --- /dev/null +++ b/tests/unit/core/use-cases/auth/send-verification-email.usecase.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { SendVerificationEmailUseCase } from "@core/use-cases/auth/send-verification-email"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository"; +import type { EmailPort } from "@core/ports/services/email.port"; +import type { CryptoPort } from "@core/ports/services/crypto.port"; +import { TokenType } from "@core/domain/enums/token-type.enum"; +import type { VerificationToken } from "@core/domain/entities/verification-token.entity"; +import { UnauthorizedError } from "@core/errors"; +import { buildUser } from "../../../helpers/mock-factories"; + +describe("SendVerificationEmailUseCase", () => { + let userRepo: Pick; + let verificationTokenRepo: Pick; + let emailService: Pick; + let cryptoService: Pick; + let useCase: SendVerificationEmailUseCase; + + const OTP_EXPIRY_SECONDS = 600; + + beforeEach(() => { + userRepo = { findById: vi.fn() }; + verificationTokenRepo = { upsert: vi.fn() }; + emailService = { sendVerificationEmail: vi.fn() }; + cryptoService = { + generateOtp: vi.fn().mockReturnValue("12345678"), + hashOtp: vi.fn().mockReturnValue("hashed_otp"), + }; + + useCase = new SendVerificationEmailUseCase( + userRepo as IUserRepository, + verificationTokenRepo as IVerificationTokenRepository, + emailService as EmailPort, + cryptoService as CryptoPort, + OTP_EXPIRY_SECONDS, + ); + }); + + it("user not found → throws UnauthorizedError", async () => { + vi.mocked(userRepo.findById).mockResolvedValue(null); + + await expect(useCase.execute({ userId: "user-1" })).rejects.toThrow( + UnauthorizedError, + ); + + expect(emailService.sendVerificationEmail).not.toHaveBeenCalled(); + }); + + it("soft-deleted user → throws UnauthorizedError", async () => { + vi.mocked(userRepo.findById).mockResolvedValue( + buildUser({ deletedAt: new Date() }), + ); + + await expect(useCase.execute({ userId: "user-1" })).rejects.toThrow( + UnauthorizedError, + ); + + expect(emailService.sendVerificationEmail).not.toHaveBeenCalled(); + }); + + it("already verified email → returns silently, no email sent", async () => { + vi.mocked(userRepo.findById).mockResolvedValue( + buildUser({ isEmailVerified: true }), + ); + + await useCase.execute({ userId: "user-1" }); + + expect(emailService.sendVerificationEmail).not.toHaveBeenCalled(); + expect(verificationTokenRepo.upsert).not.toHaveBeenCalled(); + }); + + it("happy path: generates OTP, upserts token, sends verification email", async () => { + const user = buildUser({ isEmailVerified: false }); + vi.mocked(userRepo.findById).mockResolvedValue(user); + vi.mocked(verificationTokenRepo.upsert).mockResolvedValue( + {} as VerificationToken, + ); + vi.mocked(emailService.sendVerificationEmail).mockResolvedValue(); + + await useCase.execute({ userId: user.id }); + + expect(cryptoService.generateOtp).toHaveBeenCalledWith(8); + expect(cryptoService.hashOtp).toHaveBeenCalledWith("12345678"); + expect(verificationTokenRepo.upsert).toHaveBeenCalledWith({ + userId: user.id, + tokenHash: "hashed_otp", + type: TokenType.EMAIL_VERIFICATION, + expiresAt: expect.any(Date), + }); + expect(emailService.sendVerificationEmail).toHaveBeenCalledWith({ + to: user.email, + otp: "12345678", + }); + }); + + it("happy path: expiresAt is approximately OTP_EXPIRY_SECONDS from now", async () => { + const user = buildUser({ isEmailVerified: false }); + vi.mocked(userRepo.findById).mockResolvedValue(user); + vi.mocked(verificationTokenRepo.upsert).mockResolvedValue( + {} as VerificationToken, + ); + + const before = Date.now(); + await useCase.execute({ userId: user.id }); + const after = Date.now(); + + const upsertCall = vi.mocked(verificationTokenRepo.upsert).mock + .calls[0][0]; + const expiresAtMs = upsertCall.expiresAt.getTime(); + + expect(expiresAtMs).toBeGreaterThanOrEqual( + before + OTP_EXPIRY_SECONDS * 1000, + ); + expect(expiresAtMs).toBeLessThanOrEqual( + after + OTP_EXPIRY_SECONDS * 1000, + ); + }); +}); From 7b5c672df296905b3346201970cabadb76d6a661 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 19:28:53 +0300 Subject: [PATCH 13/29] fix(auth): timing-safe OTP comparison and atomic verify/reset operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add timingSafeEqual() to CryptoPort + CryptoService (Node crypto module) - Add verificationTokenRepository to TransactionContext + TransactionService - VerifyEmailUseCase: use timingSafeEqual, wrap update+delete in transaction - ResetPasswordUseCase: same — prevents orphan tokens on partial failure --- src/core/ports/services/crypto.port.ts | 9 ++++++ src/core/ports/services/transaction.port.ts | 3 ++ .../reset-password/reset-password.usecase.ts | 15 +++++++-- .../auth/verify-email/verify-email.usecase.ts | 16 +++++++--- src/http/plugins/di/use-cases.di.ts | 32 +++++++++++++++++-- .../database/transaction.service.ts | 3 ++ src/infrastructure/security/crypto.service.ts | 14 +++++++- 7 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/core/ports/services/crypto.port.ts b/src/core/ports/services/crypto.port.ts index 768ff44..1d3435b 100644 --- a/src/core/ports/services/crypto.port.ts +++ b/src/core/ports/services/crypto.port.ts @@ -25,4 +25,13 @@ export interface CryptoPort { * @returns A hex-encoded string of the specified byte length. */ generateRandomHex(bytes: number): string; + + /** + * Compares two strings in constant time to prevent timing attacks. + * + * @param a - First string to compare. + * @param b - Second string to compare. + * @returns True if the strings are equal, false otherwise. + */ + timingSafeEqual(a: string, b: string): boolean; } diff --git a/src/core/ports/services/transaction.port.ts b/src/core/ports/services/transaction.port.ts index e688bf6..d36f529 100644 --- a/src/core/ports/services/transaction.port.ts +++ b/src/core/ports/services/transaction.port.ts @@ -5,6 +5,7 @@ import type { ICommentRepository } from "@core/ports/repositories/comment.reposi import type { IPostLikeRepository } from "@core/ports/repositories/post-like.repository"; import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; import type { IBookmarkRepository } from "../repositories/bookmark.repository"; +import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository"; /** * Provides transactional access to repositories within a single atomic operation. @@ -29,6 +30,8 @@ export interface TransactionContext { readonly notificationRepository: INotificationRepository; /** */ readonly bookmarkRepository: IBookmarkRepository; + /** Repository for verification token operations within the transaction. */ + readonly verificationTokenRepository: IVerificationTokenRepository; } /** diff --git a/src/core/use-cases/auth/reset-password/reset-password.usecase.ts b/src/core/use-cases/auth/reset-password/reset-password.usecase.ts index b206238..525440f 100644 --- a/src/core/use-cases/auth/reset-password/reset-password.usecase.ts +++ b/src/core/use-cases/auth/reset-password/reset-password.usecase.ts @@ -4,6 +4,7 @@ import type { IVerificationTokenRepository } from "@core/ports/repositories/veri import type { PasswordPort } from "@core/ports/services/password.port"; import { TokenType } from "@core/domain/enums/token-type.enum"; import type { CryptoPort } from "@core/ports/services/crypto.port"; +import type { TransactionPort } from "@core/ports/services/transaction.port"; import type { ResetPasswordInput } from "./reset-password.input"; /** @@ -28,6 +29,7 @@ export class ResetPasswordUseCase { private readonly verificationTokenRepository: IVerificationTokenRepository, private readonly passwordService: PasswordPort, private readonly cryptoService: CryptoPort, + private readonly transactionService: TransactionPort, ) {} /** @@ -67,7 +69,12 @@ export class ResetPasswordUseCase { const hashedInputOtp = this.cryptoService.hashOtp(input.otp); - if (hashedInputOtp !== verificationToken.tokenHash) { + if ( + !this.cryptoService.timingSafeEqual( + hashedInputOtp, + verificationToken.tokenHash, + ) + ) { throw new BadRequestError(this.GENERIC_ERROR); } @@ -77,7 +84,9 @@ export class ResetPasswordUseCase { user.hashPassword = newPasswordHash; - await this.userRepository.update(user); - await this.verificationTokenRepository.delete(verificationToken.id); + await this.transactionService.runInTransaction(async (ctx) => { + await ctx.userRepository.update(user); + await ctx.verificationTokenRepository.delete(verificationToken.id); + }); } } diff --git a/src/core/use-cases/auth/verify-email/verify-email.usecase.ts b/src/core/use-cases/auth/verify-email/verify-email.usecase.ts index f669321..18bbe7f 100644 --- a/src/core/use-cases/auth/verify-email/verify-email.usecase.ts +++ b/src/core/use-cases/auth/verify-email/verify-email.usecase.ts @@ -3,6 +3,7 @@ import type { IUserRepository } from "@core/ports/repositories/user.repository"; import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository"; import { TokenType } from "@core/domain/enums/token-type.enum"; import { type CryptoPort } from "@core/ports/services/crypto.port"; +import type { TransactionPort } from "@core/ports/services/transaction.port"; import type { VerifyEmailInput } from "./verify-email.input"; /** * Use case for verifying a user's email address using a one-time password (OTP) @@ -20,6 +21,7 @@ export class VerifyEmailUseCase { private readonly userRepository: IUserRepository, private readonly verificationTokenRepository: IVerificationTokenRepository, private readonly cryptoService: CryptoPort, + private readonly transactionService: TransactionPort, ) {} /** * Executes the email verification process for a user based on the provided input @@ -56,14 +58,20 @@ export class VerifyEmailUseCase { } const hashedInputOtp = this.cryptoService.hashOtp(input.otp); - if (hashedInputOtp !== verificationToken.tokenHash) { + if ( + !this.cryptoService.timingSafeEqual( + hashedInputOtp, + verificationToken.tokenHash, + ) + ) { throw new BadRequestError("Invalid verification code."); } user.verifyEmail(); - await this.userRepository.update(user); - - await this.verificationTokenRepository.delete(verificationToken.id); + await this.transactionService.runInTransaction(async (ctx) => { + await ctx.userRepository.update(user); + await ctx.verificationTokenRepository.delete(verificationToken.id); + }); } } diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index aaa3ff5..1026635 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -139,7 +139,20 @@ export const useCasesModule = { /** * Use case for verifying email address */ - verifyEmailUseCase: asClass(VerifyEmailUseCase).singleton(), + verifyEmailUseCase: asFunction( + ({ + userRepository, + verificationTokenRepository, + cryptoService, + transactionService, + }) => + new VerifyEmailUseCase( + userRepository, + verificationTokenRepository, + cryptoService, + transactionService, + ), + ).singleton(), /** * Use case for password reset request @@ -164,7 +177,22 @@ export const useCasesModule = { /** * Use case for password reset confirmation */ - resetPasswordUseCase: asClass(ResetPasswordUseCase).singleton(), + resetPasswordUseCase: asFunction( + ({ + userRepository, + verificationTokenRepository, + passwordService, + cryptoService, + transactionService, + }) => + new ResetPasswordUseCase( + userRepository, + verificationTokenRepository, + passwordService, + cryptoService, + transactionService, + ), + ).singleton(), /** * Use case for account recovery diff --git a/src/infrastructure/persistence/database/transaction.service.ts b/src/infrastructure/persistence/database/transaction.service.ts index a366e0d..ebe7eac 100644 --- a/src/infrastructure/persistence/database/transaction.service.ts +++ b/src/infrastructure/persistence/database/transaction.service.ts @@ -11,6 +11,7 @@ import { PrismaPostRepository } from "../repositories/prisma-post.repository"; import { PrismaNotificationRepository } from "../repositories/prisma-notification.repository"; import { PrismaLikeRepository } from "../repositories/prisma-like.repository"; import { PrismaBookmarkRepository } from "../repositories/prisma-bookmark.repository"; +import { PrismaVerificationTokenRepository } from "../repositories/prisma-verification-token.repository"; /** * Transaction service implementation for managing database transactions @@ -50,6 +51,8 @@ export class TransactionService implements TransactionPort { postLikeRepository: new PrismaLikeRepository(tx), notificationRepository: new PrismaNotificationRepository(tx), bookmarkRepository: new PrismaBookmarkRepository(tx), + verificationTokenRepository: + new PrismaVerificationTokenRepository(tx), }; return await work(context); diff --git a/src/infrastructure/security/crypto.service.ts b/src/infrastructure/security/crypto.service.ts index 62d2af3..eacd8b5 100644 --- a/src/infrastructure/security/crypto.service.ts +++ b/src/infrastructure/security/crypto.service.ts @@ -1,4 +1,9 @@ -import { randomInt, createHash, randomBytes } from "crypto"; +import { + randomInt, + createHash, + randomBytes, + timingSafeEqual as cryptoTimingSafeEqual, +} from "crypto"; import type { CryptoPort } from "@core/ports/services/crypto.port"; export class CryptoService implements CryptoPort { @@ -15,4 +20,11 @@ export class CryptoService implements CryptoPort { hashOtp(otp: string): string { return createHash("sha256").update(otp).digest("hex"); } + + timingSafeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return cryptoTimingSafeEqual(bufA, bufB); + } } From 2974da5f4f40ed2530a09e6637bd5e7b33628c76 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 21:06:33 +0300 Subject: [PATCH 14/29] test: add buildVerificationToken factory and fix e2e global setup - Add buildVerificationToken() factory to mock-factories for use in VerifyEmailUseCase and ResetPasswordUseCase unit tests - Fix global-setup: use pnpm prisma instead of npx prisma for migrate reset --- tests/e2e/global-setup.ts | 5 +++-- tests/unit/helpers/mock-factories.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 02c9faa..0f1239b 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -8,13 +8,14 @@ import { BOT_USER } from "./test-constants"; export default async function setup(): Promise { const { parsed } = config({ path: ".env.test" }); - execSync("npx prisma migrate reset --force", { + execSync("pnpm prisma migrate reset --force", { stdio: "inherit", env: { ...process.env, ...parsed }, }); const connectionString = parsed?.DATABASE_URL ?? process.env.DATABASE_URL; - if (!connectionString) throw new Error("DATABASE_URL is not set in .env.test"); + if (!connectionString) + throw new Error("DATABASE_URL is not set in .env.test"); const adapter = new PrismaPg({ connectionString }); const prisma = new PrismaClient({ adapter }); diff --git a/tests/unit/helpers/mock-factories.ts b/tests/unit/helpers/mock-factories.ts index f5ef1d7..5a371b4 100644 --- a/tests/unit/helpers/mock-factories.ts +++ b/tests/unit/helpers/mock-factories.ts @@ -2,6 +2,9 @@ import { User } from "@core/domain/entities/user.entity"; import type { UserProps } from "@core/domain/interfaces/user-props.interface"; import { RefreshToken } from "@core/domain/entities/refresh-token.entity"; import type { RefreshTokenProps } from "@core/domain/interfaces/refresh-token.props.interface"; +import { VerificationToken } from "@core/domain/entities/verification-token.entity"; +import type { VerificationTokenProps } from "@core/domain/interfaces/verification-token.props.interface"; +import { TokenType } from "@core/domain/enums/token-type.enum"; export function buildUser(overrides: Partial = {}): User { return User.with({ @@ -18,6 +21,20 @@ export function buildUser(overrides: Partial = {}): User { }); } +export function buildVerificationToken( + overrides: Partial = {}, +): VerificationToken { + return VerificationToken.with({ + id: "vtoken-1", + tokenHash: "hashed_otp", + userId: "user-1", + type: TokenType.EMAIL_VERIFICATION, + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + createdAt: new Date("2024-01-01T00:00:00Z"), + ...overrides, + }); +} + export function buildRefreshToken( overrides: Partial = {}, ): RefreshToken { From d3895b278c3ad72ae720ab66761fb74f45870498 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 21:06:48 +0300 Subject: [PATCH 15/29] fix(di): use positional args in asFunction registrations (CLASSIC mode) InjectionMode.CLASSIC resolves dependencies by parameter position, not by name. Object destructuring in asFunction factories causes the entire cradle to be passed as a single undefined argument. Affected use cases: - sendVerificationEmailUseCase - verifyEmailUseCase - forgotPasswordUseCase - resetPasswordUseCase This was causing all e2e tests to fail with: TypeError: Cannot read properties of undefined (reading 'OTP_EXPIRY_SECONDS') --- src/http/plugins/di/use-cases.di.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index 1026635..dee451c 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -69,8 +69,6 @@ import { TranslateUseCase } from "@core/use-cases/translate"; * shared dependencies across the application. */ export const useCasesModule = { - // --- Use Cases --- - /** * Use case for soft deleting a user account */ @@ -120,13 +118,13 @@ export const useCasesModule = { * Use case for sending email verification */ sendVerificationEmailUseCase: asFunction( - ({ + ( userRepository, verificationTokenRepository, emailService, cryptoService, config, - }) => + ) => new SendVerificationEmailUseCase( userRepository, verificationTokenRepository, @@ -140,12 +138,12 @@ export const useCasesModule = { * Use case for verifying email address */ verifyEmailUseCase: asFunction( - ({ + ( userRepository, verificationTokenRepository, cryptoService, transactionService, - }) => + ) => new VerifyEmailUseCase( userRepository, verificationTokenRepository, @@ -158,13 +156,13 @@ export const useCasesModule = { * Use case for password reset request */ forgotPasswordUseCase: asFunction( - ({ + ( userRepository, verificationTokenRepository, emailService, cryptoService, config, - }) => + ) => new ForgotPasswordUseCase( userRepository, verificationTokenRepository, @@ -178,13 +176,13 @@ export const useCasesModule = { * Use case for password reset confirmation */ resetPasswordUseCase: asFunction( - ({ + ( userRepository, verificationTokenRepository, passwordService, cryptoService, transactionService, - }) => + ) => new ResetPasswordUseCase( userRepository, verificationTokenRepository, From 081e7f648133e672087a46a0a61d48f30ad6d9c5 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 21:08:02 +0300 Subject: [PATCH 16/29] test(auth): add VerifyEmailUseCase unit tests 7 cases: user not found, soft-deleted user, already verified (silent), token not found, expired token, OTP mismatch, happy path. Covers timing-safe OTP comparison (timingSafeEqual) and atomic transaction behavior (update user + delete token in single tx). --- .../auth/verify-email.usecase.test.ts | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/unit/core/use-cases/auth/verify-email.usecase.test.ts diff --git a/tests/unit/core/use-cases/auth/verify-email.usecase.test.ts b/tests/unit/core/use-cases/auth/verify-email.usecase.test.ts new file mode 100644 index 0000000..f11324b --- /dev/null +++ b/tests/unit/core/use-cases/auth/verify-email.usecase.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { VerifyEmailUseCase } from "@core/use-cases/auth/verify-email"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository"; +import type { CryptoPort } from "@core/ports/services/crypto.port"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import { BadRequestError, UnauthorizedError } from "@core/errors"; +import { + buildUser, + buildVerificationToken, +} from "../../../helpers/mock-factories"; +import { TokenType } from "@core/domain/enums/token-type.enum"; + +describe("VerifyEmailUseCase", () => { + let userRepo: Pick; + let verificationTokenRepo: Pick< + IVerificationTokenRepository, + "findByUserIdAndType" + >; + let cryptoService: Pick; + let txUserRepo: Pick; + let txVerificationTokenRepo: Pick; + let transactionService: TransactionPort; + let useCase: VerifyEmailUseCase; + + beforeEach(() => { + userRepo = { findById: vi.fn() }; + verificationTokenRepo = { findByUserIdAndType: vi.fn() }; + cryptoService = { + hashOtp: vi.fn().mockReturnValue("hashed_otp"), + timingSafeEqual: vi.fn().mockReturnValue(true), + }; + txUserRepo = { update: vi.fn() }; + txVerificationTokenRepo = { delete: vi.fn() }; + + transactionService = { + runInTransaction: vi.fn((fn) => + fn({ + userRepository: txUserRepo, + verificationTokenRepository: txVerificationTokenRepo, + } as unknown as TransactionContext), + ), + }; + + useCase = new VerifyEmailUseCase( + userRepo as IUserRepository, + verificationTokenRepo as IVerificationTokenRepository, + cryptoService as CryptoPort, + transactionService, + ); + }); + + it("user not found → throws UnauthorizedError", async () => { + vi.mocked(userRepo.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ userId: "user-1", otp: "12345678" }), + ).rejects.toThrow(UnauthorizedError); + + expect(transactionService.runInTransaction).not.toHaveBeenCalled(); + }); + + it("soft-deleted user → throws UnauthorizedError", async () => { + vi.mocked(userRepo.findById).mockResolvedValue( + buildUser({ deletedAt: new Date() }), + ); + + await expect( + useCase.execute({ userId: "user-1", otp: "12345678" }), + ).rejects.toThrow(UnauthorizedError); + + expect(transactionService.runInTransaction).not.toHaveBeenCalled(); + }); + + it("already verified email → returns silently, no transaction", async () => { + vi.mocked(userRepo.findById).mockResolvedValue( + buildUser({ isEmailVerified: true }), + ); + + await useCase.execute({ userId: "user-1", otp: "12345678" }); + + expect( + verificationTokenRepo.findByUserIdAndType, + ).not.toHaveBeenCalled(); + expect(transactionService.runInTransaction).not.toHaveBeenCalled(); + }); + + it("no verification token found → throws BadRequestError", async () => { + vi.mocked(userRepo.findById).mockResolvedValue( + buildUser({ isEmailVerified: false }), + ); + vi.mocked(verificationTokenRepo.findByUserIdAndType).mockResolvedValue( + null, + ); + + await expect( + useCase.execute({ userId: "user-1", otp: "12345678" }), + ).rejects.toThrow(BadRequestError); + + expect(transactionService.runInTransaction).not.toHaveBeenCalled(); + }); + + it("expired token → throws BadRequestError", async () => { + vi.mocked(userRepo.findById).mockResolvedValue( + buildUser({ isEmailVerified: false }), + ); + vi.mocked(verificationTokenRepo.findByUserIdAndType).mockResolvedValue( + buildVerificationToken({ expiresAt: new Date(Date.now() - 1000) }), + ); + + await expect( + useCase.execute({ userId: "user-1", otp: "12345678" }), + ).rejects.toThrow(BadRequestError); + + expect(transactionService.runInTransaction).not.toHaveBeenCalled(); + }); + + it("OTP mismatch → throws BadRequestError", async () => { + vi.mocked(userRepo.findById).mockResolvedValue( + buildUser({ isEmailVerified: false }), + ); + vi.mocked(verificationTokenRepo.findByUserIdAndType).mockResolvedValue( + buildVerificationToken(), + ); + vi.mocked(cryptoService.timingSafeEqual).mockReturnValue(false); + + await expect( + useCase.execute({ userId: "user-1", otp: "wrongotp" }), + ).rejects.toThrow(BadRequestError); + + expect(transactionService.runInTransaction).not.toHaveBeenCalled(); + }); + + it("happy path: verifies email and atomically updates user + deletes token", async () => { + const user = buildUser({ isEmailVerified: false }); + const token = buildVerificationToken({ + userId: user.id, + type: TokenType.EMAIL_VERIFICATION, + }); + + vi.mocked(userRepo.findById).mockResolvedValue(user); + vi.mocked(verificationTokenRepo.findByUserIdAndType).mockResolvedValue( + token, + ); + vi.mocked(cryptoService.timingSafeEqual).mockReturnValue(true); + vi.mocked(txUserRepo.update).mockResolvedValue(undefined as never); + vi.mocked(txVerificationTokenRepo.delete).mockResolvedValue(); + + await useCase.execute({ userId: user.id, otp: "12345678" }); + + expect(cryptoService.hashOtp).toHaveBeenCalledWith("12345678"); + expect(cryptoService.timingSafeEqual).toHaveBeenCalledWith( + "hashed_otp", + token.tokenHash, + ); + expect(transactionService.runInTransaction).toHaveBeenCalledOnce(); + expect(txUserRepo.update).toHaveBeenCalledWith(user); + expect(txVerificationTokenRepo.delete).toHaveBeenCalledWith(token.id); + expect(user.isEmailVerified).toBe(true); + }); +}); From 8ba6aeedb1ae41e24f525a1bc5eeebb46460cc6e Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 21:46:50 +0300 Subject: [PATCH 17/29] test(auth): add RecoverAccountUseCase unit tests - fix: replace BadRequestError with UnauthorizedError for not-found/active user - fix: add isDeleted() guard to prevent token replay on active accounts - test: cover invalid token, wrong purpose, user not found, already active, successful recovery and refresh token device info scenarios --- .../recover-account.usecase.ts | 6 +- .../auth/recover-account.usecase.test.ts | 148 ++++++++++++++++++ 2 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 tests/unit/core/use-cases/auth/recover-account.usecase.test.ts diff --git a/src/core/use-cases/auth/recover-account/recover-account.usecase.ts b/src/core/use-cases/auth/recover-account/recover-account.usecase.ts index a4520f3..c6371be 100644 --- a/src/core/use-cases/auth/recover-account/recover-account.usecase.ts +++ b/src/core/use-cases/auth/recover-account/recover-account.usecase.ts @@ -4,7 +4,7 @@ import type { AuthTokenPort, RecoveryPayload, } from "@core/ports/services/auth-token.port"; -import { UnauthorizedError, BadRequestError } from "@core/errors"; +import { UnauthorizedError } from "@core/errors"; import { type LoginOutput } from "../login/login.output"; import type { RecoverAccountInput } from "./recover-account.input"; import { AuthMapper } from "../auth.mapper"; @@ -61,8 +61,8 @@ export class RecoverAccountUseCase { const user = await this.userRepository.findById(userId); - if (!user) { - throw new BadRequestError("User not found."); + if (!user || !user.isDeleted()) { + throw new UnauthorizedError("Invalid or expired recovery token."); } await this.userRepository.restoreById(user.id); diff --git a/tests/unit/core/use-cases/auth/recover-account.usecase.test.ts b/tests/unit/core/use-cases/auth/recover-account.usecase.test.ts new file mode 100644 index 0000000..edd5972 --- /dev/null +++ b/tests/unit/core/use-cases/auth/recover-account.usecase.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RecoverAccountUseCase } from "@core/use-cases/auth/recover-account/recover-account.usecase"; +import { UnauthorizedError } from "@core/errors"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { + AuthTokenPort, + RecoveryPayload, +} from "@core/ports/services/auth-token.port"; +import type { IRefreshTokenRepository } from "@core/ports/repositories/refresh-token.repository"; +import { buildUser } from "../../../helpers/mock-factories"; + +describe("RecoverAccountUseCase", () => { + let useCase: RecoverAccountUseCase; + let userRepo: Pick; + let authTokenSvc: Pick< + AuthTokenPort, + "verifyRecoveryToken" | "generate" | "hashRefreshSecret" + >; + let refreshTokenRepo: Pick; + + const input = { + recoveryToken: "valid_recovery_token", + deviceIp: "127.0.0.1", + userAgent: "Mozilla/5.0", + }; + + const validPayload: RecoveryPayload = { + sub: "user-1", + purpose: "account_recovery", + }; + + beforeEach(() => { + userRepo = { + findById: vi.fn(), + restoreById: vi.fn(), + }; + authTokenSvc = { + verifyRecoveryToken: vi.fn(), + generate: vi.fn(), + hashRefreshSecret: vi.fn(), + }; + refreshTokenRepo = { create: vi.fn() }; + useCase = new RecoverAccountUseCase( + userRepo as IUserRepository, + refreshTokenRepo as IRefreshTokenRepository, + authTokenSvc as AuthTokenPort, + ); + }); + + it("should throw UnauthorizedError when recovery token is invalid", async () => { + vi.mocked(authTokenSvc.verifyRecoveryToken).mockImplementation(() => { + throw new Error("jwt malformed"); + }); + + await expect(useCase.execute(input)).rejects.toThrow(UnauthorizedError); + }); + + it("should throw UnauthorizedError when token purpose is not account_recovery", async () => { + vi.mocked(authTokenSvc.verifyRecoveryToken).mockReturnValue({ + sub: "user-1", + purpose: "other_purpose" as RecoveryPayload["purpose"], + }); + + await expect(useCase.execute(input)).rejects.toThrow(UnauthorizedError); + }); + + it("should throw UnauthorizedError when user is not found", async () => { + vi.mocked(authTokenSvc.verifyRecoveryToken).mockReturnValue( + validPayload, + ); + vi.mocked(userRepo.findById).mockResolvedValue(null); + + await expect(useCase.execute(input)).rejects.toThrow(UnauthorizedError); + }); + + it("should throw UnauthorizedError when user is not deleted (already active)", async () => { + const activeUser = buildUser({ deletedAt: null }); + vi.mocked(authTokenSvc.verifyRecoveryToken).mockReturnValue( + validPayload, + ); + vi.mocked(userRepo.findById).mockResolvedValue(activeUser); + + await expect(useCase.execute(input)).rejects.toThrow(UnauthorizedError); + }); + + it("should restore user and return tokens on valid recovery", async () => { + const deletedUser = buildUser({ deletedAt: new Date() }); + vi.mocked(authTokenSvc.verifyRecoveryToken).mockReturnValue( + validPayload, + ); + vi.mocked(userRepo.findById).mockResolvedValue(deletedUser); + vi.mocked(userRepo.restoreById).mockResolvedValue(); + vi.mocked(authTokenSvc.generate).mockReturnValue({ + accessToken: "access_token", + refreshToken: "refresh_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + refreshTokenExpiresAt: + Math.floor(Date.now() / 1000) + 7 * 24 * 3600, + }); + vi.mocked(authTokenSvc.hashRefreshSecret).mockReturnValue( + "hashed_secret", + ); + vi.mocked(refreshTokenRepo.create).mockResolvedValue( + undefined as never, + ); + + const result = await useCase.execute(input); + + expect(userRepo.restoreById).toHaveBeenCalledWith(deletedUser.id); + expect(refreshTokenRepo.create).toHaveBeenCalledOnce(); + expect(result.tokens.accessToken).toBe("access_token"); + expect(result.tokens.refreshToken).toBe("refresh_token"); + expect(result.user.id).toBe(deletedUser.id); + }); + + it("should store refresh token with correct device info", async () => { + const deletedUser = buildUser({ deletedAt: new Date() }); + vi.mocked(authTokenSvc.verifyRecoveryToken).mockReturnValue( + validPayload, + ); + vi.mocked(userRepo.findById).mockResolvedValue(deletedUser); + vi.mocked(userRepo.restoreById).mockResolvedValue(); + vi.mocked(authTokenSvc.generate).mockReturnValue({ + accessToken: "access_token", + refreshToken: "refresh_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + refreshTokenExpiresAt: + Math.floor(Date.now() / 1000) + 7 * 24 * 3600, + }); + vi.mocked(authTokenSvc.hashRefreshSecret).mockReturnValue( + "hashed_secret", + ); + vi.mocked(refreshTokenRepo.create).mockResolvedValue( + undefined as never, + ); + + await useCase.execute(input); + + expect(refreshTokenRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + deviceIp: input.deviceIp, + userAgent: input.userAgent, + userId: deletedUser.id, + tokenHash: "hashed_secret", + }), + ); + }); +}); From 013f1fef56cedd43efc755a923cd0a57171c9169 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 21:48:17 +0300 Subject: [PATCH 18/29] test(auth): add PurgeExpiredTokensUseCase unit tests --- .../auth/purge-expired-tokens.usecase.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/unit/core/use-cases/auth/purge-expired-tokens.usecase.test.ts diff --git a/tests/unit/core/use-cases/auth/purge-expired-tokens.usecase.test.ts b/tests/unit/core/use-cases/auth/purge-expired-tokens.usecase.test.ts new file mode 100644 index 0000000..79f01cc --- /dev/null +++ b/tests/unit/core/use-cases/auth/purge-expired-tokens.usecase.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PurgeExpiredTokensUseCase } from "@core/use-cases/auth/purge-expired-tokens/purge-expires-tokens.use.case"; +import type { IRefreshTokenRepository } from "@core/ports/repositories/refresh-token.repository"; + +describe("PurgeExpiredTokensUseCase", () => { + let useCase: PurgeExpiredTokensUseCase; + let refreshTokenRepo: Pick; + + beforeEach(() => { + refreshTokenRepo = { deleteExpiredTokens: vi.fn() }; + useCase = new PurgeExpiredTokensUseCase( + refreshTokenRepo as IRefreshTokenRepository, + ); + }); + + it("should return the number of deleted tokens", async () => { + vi.mocked(refreshTokenRepo.deleteExpiredTokens).mockResolvedValue(5); + + const result = await useCase.execute(); + + expect(result).toBe(5); + }); + + it("should return 0 when no expired tokens exist", async () => { + vi.mocked(refreshTokenRepo.deleteExpiredTokens).mockResolvedValue(0); + + const result = await useCase.execute(); + + expect(result).toBe(0); + }); + + it("should delegate to refreshTokenRepository.deleteExpiredTokens", async () => { + vi.mocked(refreshTokenRepo.deleteExpiredTokens).mockResolvedValue(3); + + await useCase.execute(); + + expect(refreshTokenRepo.deleteExpiredTokens).toHaveBeenCalledOnce(); + }); +}); From 0784beb908ddaf9426683fad40f6e5f30546b891 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 21:50:04 +0300 Subject: [PATCH 19/29] test(auth): add AuthMapper unit tests --- .../core/use-cases/auth/auth.mapper.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/unit/core/use-cases/auth/auth.mapper.test.ts diff --git a/tests/unit/core/use-cases/auth/auth.mapper.test.ts b/tests/unit/core/use-cases/auth/auth.mapper.test.ts new file mode 100644 index 0000000..99b8cee --- /dev/null +++ b/tests/unit/core/use-cases/auth/auth.mapper.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { AuthMapper } from "@core/use-cases/auth/auth.mapper"; +import type { UserPayload } from "@core/ports/services/auth-token.port"; + +describe("AuthMapper", () => { + const userPayload: UserPayload = { + id: "user-1", + username: "testuser", + }; + + const tokenData = { + accessToken: "access_token", + expiresAt: 1000, + refreshToken: "refresh_token", + refreshTokenExpiresAt: 2000, + }; + + describe("toUserOutput", () => { + it("should map id and username", () => { + const result = AuthMapper.toUserOutput(userPayload); + + expect(result).toEqual({ id: "user-1", username: "testuser" }); + }); + + it("should only expose id and username — no extra fields", () => { + const result = AuthMapper.toUserOutput(userPayload); + + expect(Object.keys(result)).toStrictEqual(["id", "username"]); + }); + }); + + describe("toTokenOutput", () => { + it("should map all token fields correctly", () => { + const result = AuthMapper.toTokenOutput(tokenData); + + expect(result).toEqual(tokenData); + }); + + it("should expose exactly the expected token fields", () => { + const result = AuthMapper.toTokenOutput(tokenData); + + expect(Object.keys(result)).toStrictEqual([ + "accessToken", + "expiresAt", + "refreshToken", + "refreshTokenExpiresAt", + ]); + }); + }); + + describe("toAuthOutput", () => { + it("should compose user and token output correctly", () => { + const result = AuthMapper.toAuthOutput({ + user: userPayload, + ...tokenData, + }); + + expect(result.user).toEqual({ id: "user-1", username: "testuser" }); + expect(result.tokens).toEqual(tokenData); + }); + + it("should not leak extra user fields into the response", () => { + const result = AuthMapper.toAuthOutput({ + user: userPayload, + ...tokenData, + }); + + expect(Object.keys(result.user)).toStrictEqual(["id", "username"]); + }); + }); +}); From 54651c23c4b11d3dc6e5eee3aab6a02ccdb170b7 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 21:52:07 +0300 Subject: [PATCH 20/29] test(auth): add ResetPasswordUseCase unit tests --- .../auth/reset-password.usecase.test.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/unit/core/use-cases/auth/reset-password.usecase.test.ts diff --git a/tests/unit/core/use-cases/auth/reset-password.usecase.test.ts b/tests/unit/core/use-cases/auth/reset-password.usecase.test.ts new file mode 100644 index 0000000..8ede195 --- /dev/null +++ b/tests/unit/core/use-cases/auth/reset-password.usecase.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ResetPasswordUseCase } from "@core/use-cases/auth/reset-password/reset-password.usecase"; +import { BadRequestError } from "@core/errors"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository"; +import type { PasswordPort } from "@core/ports/services/password.port"; +import type { CryptoPort } from "@core/ports/services/crypto.port"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import { + buildUser, + buildVerificationToken, +} from "../../../helpers/mock-factories"; +import { TokenType } from "@core/domain/enums/token-type.enum"; + +describe("ResetPasswordUseCase", () => { + let useCase: ResetPasswordUseCase; + let userRepo: Pick; + let verificationTokenRepo: Pick< + IVerificationTokenRepository, + "findByUserIdAndType" + >; + let passwordSvc: Pick; + let cryptoSvc: Pick; + let transactionSvc: Pick; + + const input = { + email: "test@example.com", + otp: "12345678", + newPassword: "newSecurePassword123!", + }; + + beforeEach(() => { + userRepo = { findByEmail: vi.fn() }; + verificationTokenRepo = { findByUserIdAndType: vi.fn() }; + passwordSvc = { hash: vi.fn() }; + cryptoSvc = { + hashOtp: vi.fn(), + timingSafeEqual: vi.fn(), + }; + transactionSvc = { runInTransaction: vi.fn() }; + + useCase = new ResetPasswordUseCase( + userRepo as IUserRepository, + verificationTokenRepo as IVerificationTokenRepository, + passwordSvc as PasswordPort, + cryptoSvc as CryptoPort, + transactionSvc as TransactionPort, + ); + }); + + it("should throw BadRequestError when user is not found", async () => { + vi.mocked(userRepo.findByEmail).mockResolvedValue(null); + + await expect(useCase.execute(input)).rejects.toThrow(BadRequestError); + }); + + it("should throw BadRequestError when user is soft-deleted", async () => { + vi.mocked(userRepo.findByEmail).mockResolvedValue( + buildUser({ deletedAt: new Date() }), + ); + + await expect(useCase.execute(input)).rejects.toThrow(BadRequestError); + }); + + it("should throw BadRequestError when user has no password (OAuth account)", async () => { + vi.mocked(userRepo.findByEmail).mockResolvedValue( + buildUser({ passwordHash: null }), + ); + + await expect(useCase.execute(input)).rejects.toThrow(BadRequestError); + }); + + it("should throw BadRequestError when verification token is not found", async () => { + vi.mocked(userRepo.findByEmail).mockResolvedValue(buildUser()); + vi.mocked(verificationTokenRepo.findByUserIdAndType).mockResolvedValue( + null, + ); + + await expect(useCase.execute(input)).rejects.toThrow(BadRequestError); + }); + + it("should throw BadRequestError when verification token is expired", async () => { + vi.mocked(userRepo.findByEmail).mockResolvedValue(buildUser()); + vi.mocked(verificationTokenRepo.findByUserIdAndType).mockResolvedValue( + buildVerificationToken({ + type: TokenType.PASSWORD_RESET, + expiresAt: new Date(Date.now() - 1000), + }), + ); + + await expect(useCase.execute(input)).rejects.toThrow(BadRequestError); + }); + + it("should throw BadRequestError when OTP does not match", async () => { + vi.mocked(userRepo.findByEmail).mockResolvedValue(buildUser()); + vi.mocked(verificationTokenRepo.findByUserIdAndType).mockResolvedValue( + buildVerificationToken({ type: TokenType.PASSWORD_RESET }), + ); + vi.mocked(cryptoSvc.hashOtp).mockReturnValue("hashed_input_otp"); + vi.mocked(cryptoSvc.timingSafeEqual).mockReturnValue(false); + + await expect(useCase.execute(input)).rejects.toThrow(BadRequestError); + }); + + it("should update password and delete token in transaction on valid input", async () => { + const user = buildUser(); + const token = buildVerificationToken({ + type: TokenType.PASSWORD_RESET, + }); + + vi.mocked(userRepo.findByEmail).mockResolvedValue(user); + vi.mocked(verificationTokenRepo.findByUserIdAndType).mockResolvedValue( + token, + ); + vi.mocked(cryptoSvc.hashOtp).mockReturnValue("hashed_otp"); + vi.mocked(cryptoSvc.timingSafeEqual).mockReturnValue(true); + vi.mocked(passwordSvc.hash).mockResolvedValue("new_hashed_password"); + + const txUserRepo = { update: vi.fn().mockResolvedValue(undefined) }; + const txVerifTokenRepo = { + delete: vi.fn().mockResolvedValue(undefined), + }; + + vi.mocked(transactionSvc.runInTransaction).mockImplementation( + async (work) => { + return work({ + userRepository: txUserRepo as unknown as IUserRepository, + verificationTokenRepository: + txVerifTokenRepo as unknown as IVerificationTokenRepository, + } as TransactionContext); + }, + ); + + await useCase.execute(input); + + expect(passwordSvc.hash).toHaveBeenCalledWith(input.newPassword); + expect(transactionSvc.runInTransaction).toHaveBeenCalledOnce(); + expect(txUserRepo.update).toHaveBeenCalledWith(user); + expect(txVerifTokenRepo.delete).toHaveBeenCalledWith(token.id); + }); + + it("should query verification token with correct type", async () => { + const user = buildUser(); + vi.mocked(userRepo.findByEmail).mockResolvedValue(user); + vi.mocked(verificationTokenRepo.findByUserIdAndType).mockResolvedValue( + null, + ); + + await expect(useCase.execute(input)).rejects.toThrow(BadRequestError); + + expect(verificationTokenRepo.findByUserIdAndType).toHaveBeenCalledWith( + user.id, + TokenType.PASSWORD_RESET, + ); + }); +}); From f2595e38541890ee2ac6dae62b7da34a6a0d384e Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 22:00:01 +0300 Subject: [PATCH 21/29] test(bookmark): add CreateBookmarkUseCase unit tests - fix: skip cache invalidation when post was already bookmarked - fix: wrap cache invalidation in try/catch to prevent non-critical failures from surfacing --- .../create-bookmark.usecase.ts | 16 ++- .../bookmark/create-bookmark.usecase.test.ts | 109 ++++++++++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 tests/unit/core/use-cases/bookmark/create-bookmark.usecase.test.ts diff --git a/src/core/use-cases/bookmark/create-bookmark/create-bookmark.usecase.ts b/src/core/use-cases/bookmark/create-bookmark/create-bookmark.usecase.ts index 769233b..41d77a7 100644 --- a/src/core/use-cases/bookmark/create-bookmark/create-bookmark.usecase.ts +++ b/src/core/use-cases/bookmark/create-bookmark/create-bookmark.usecase.ts @@ -23,6 +23,8 @@ export class CreateBookmarkUseCase { * @throws NotFoundError if the post does not exist */ async execute(input: CreateBookmarkInput): Promise { + let bookmarkCreated = false; + await this.transactionService.runInTransaction(async (ctx) => { const post = await ctx.postRepository.findById(input.postId); if (!post) { @@ -37,9 +39,17 @@ export class CreateBookmarkUseCase { if (alreadyBookmarked) return; await ctx.bookmarkRepository.save(input.postId, input.userId); + bookmarkCreated = true; }); - await this.cacheService.deleteByPattern( - `posts:feed:*user:${input.userId}*`, - ); + + if (!bookmarkCreated) return; + + try { + await this.cacheService.deleteByPattern( + `posts:feed:*user:${input.userId}*`, + ); + } catch { + // Cache invalidation failure is non-critical; bookmark is already persisted. + } } } diff --git a/tests/unit/core/use-cases/bookmark/create-bookmark.usecase.test.ts b/tests/unit/core/use-cases/bookmark/create-bookmark.usecase.test.ts new file mode 100644 index 0000000..0932500 --- /dev/null +++ b/tests/unit/core/use-cases/bookmark/create-bookmark.usecase.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CreateBookmarkUseCase } from "@core/use-cases/bookmark/create-bookmark/create-bookmark.usecase"; +import { NotFoundError } from "@core/errors"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import type { CachePort } from "@core/ports/services/cache.port"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { IBookmarkRepository } from "@core/ports/repositories/bookmark.repository"; +import type { Post } from "@core/domain/entities/post.entity"; + +describe("CreateBookmarkUseCase", () => { + let useCase: CreateBookmarkUseCase; + let transactionSvc: Pick; + let cacheSvc: Pick; + let txPostRepo: Pick; + let txBookmarkRepo: Pick; + + const input = { postId: "post-1", userId: "user-1" }; + + const buildTransactionContext = (): TransactionContext => + ({ + postRepository: txPostRepo as IPostRepository, + bookmarkRepository: txBookmarkRepo as IBookmarkRepository, + }) as TransactionContext; + + beforeEach(() => { + txPostRepo = { findById: vi.fn() }; + txBookmarkRepo = { + isBookmarked: vi.fn(), + save: vi.fn(), + }; + cacheSvc = { deleteByPattern: vi.fn() }; + transactionSvc = { runInTransaction: vi.fn() }; + + vi.mocked(transactionSvc.runInTransaction).mockImplementation( + async (work) => work(buildTransactionContext()), + ); + + useCase = new CreateBookmarkUseCase( + transactionSvc as TransactionPort, + cacheSvc as CachePort, + ); + }); + + it("should throw NotFoundError when post does not exist", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue(null); + + await expect(useCase.execute(input)).rejects.toThrow(NotFoundError); + expect(txBookmarkRepo.save).not.toHaveBeenCalled(); + }); + + it("should not save bookmark when post is already bookmarked", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue({} as Post); + vi.mocked(txBookmarkRepo.isBookmarked).mockResolvedValue(true); + + await useCase.execute(input); + + expect(txBookmarkRepo.save).not.toHaveBeenCalled(); + }); + + it("should save bookmark when post exists and is not bookmarked", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue({} as Post); + vi.mocked(txBookmarkRepo.isBookmarked).mockResolvedValue(false); + vi.mocked(txBookmarkRepo.save).mockResolvedValue(); + vi.mocked(cacheSvc.deleteByPattern).mockResolvedValue(); + + await useCase.execute(input); + + expect(txBookmarkRepo.save).toHaveBeenCalledWith( + input.postId, + input.userId, + ); + }); + + it("should invalidate user feed cache after bookmark is saved", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue({} as Post); + vi.mocked(txBookmarkRepo.isBookmarked).mockResolvedValue(false); + vi.mocked(txBookmarkRepo.save).mockResolvedValue(); + vi.mocked(cacheSvc.deleteByPattern).mockResolvedValue(); + + await useCase.execute(input); + + expect(cacheSvc.deleteByPattern).toHaveBeenCalledWith( + `posts:feed:*user:${input.userId}*`, + ); + }); + + it("should not invalidate cache when post is already bookmarked", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue({} as Post); + vi.mocked(txBookmarkRepo.isBookmarked).mockResolvedValue(true); + + await useCase.execute(input); + + expect(cacheSvc.deleteByPattern).not.toHaveBeenCalled(); + }); + + it("should resolve successfully even when cache invalidation fails", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue({} as Post); + vi.mocked(txBookmarkRepo.isBookmarked).mockResolvedValue(false); + vi.mocked(txBookmarkRepo.save).mockResolvedValue(); + vi.mocked(cacheSvc.deleteByPattern).mockRejectedValue( + new Error("Redis connection error"), + ); + + await expect(useCase.execute(input)).resolves.toBeUndefined(); + }); +}); From 7a7a57fd59fe21dd9c71cf27be5ef3e391e7eda9 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 22:01:59 +0300 Subject: [PATCH 22/29] test(bookmark): add GetBookmarksUseCase unit tests --- .../bookmark/get-bookmarks.usecase.test.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/unit/core/use-cases/bookmark/get-bookmarks.usecase.test.ts diff --git a/tests/unit/core/use-cases/bookmark/get-bookmarks.usecase.test.ts b/tests/unit/core/use-cases/bookmark/get-bookmarks.usecase.test.ts new file mode 100644 index 0000000..6b5c5db --- /dev/null +++ b/tests/unit/core/use-cases/bookmark/get-bookmarks.usecase.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetBookmarksUseCase } from "@core/use-cases/bookmark/get-bookmarks/get-bookmarks.usecase"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { ICommentBookmarkRepository } from "@core/ports/repositories/comment-bookmark.repository"; +import type { Post } from "@core/domain/entities/post.entity"; +import type { Comment } from "@core/domain/entities/comment.entity"; + +describe("GetBookmarksUseCase", () => { + let useCase: GetBookmarksUseCase; + let postRepo: Pick; + let commentBookmarkRepo: Pick< + ICommentBookmarkRepository, + "findBookmarkedByUserId" + >; + + const userId = "user-1"; + + beforeEach(() => { + postRepo = { findAll: vi.fn() }; + commentBookmarkRepo = { findBookmarkedByUserId: vi.fn() }; + + useCase = new GetBookmarksUseCase( + postRepo as IPostRepository, + commentBookmarkRepo as ICommentBookmarkRepository, + ); + }); + + it("should return bookmarked posts and comments", async () => { + const posts = [{} as Post]; + const comments = [{} as Comment]; + + vi.mocked(postRepo.findAll).mockResolvedValue({ posts, total: 1 }); + vi.mocked(commentBookmarkRepo.findBookmarkedByUserId).mockResolvedValue( + { + comments, + total: 1, + }, + ); + + const result = await useCase.execute({ userId }); + + expect(result.posts).toBe(posts); + expect(result.postTotal).toBe(1); + expect(result.comments).toBe(comments); + expect(result.commentTotal).toBe(1); + }); + + it("should use default page=1 and limit=10 when not provided", async () => { + vi.mocked(postRepo.findAll).mockResolvedValue({ posts: [], total: 0 }); + vi.mocked(commentBookmarkRepo.findBookmarkedByUserId).mockResolvedValue( + { + comments: [], + total: 0, + }, + ); + + await useCase.execute({ userId }); + + expect(postRepo.findAll).toHaveBeenCalledWith( + expect.objectContaining({ page: 1, limit: 10 }), + ); + expect(commentBookmarkRepo.findBookmarkedByUserId).toHaveBeenCalledWith( + userId, + 10, + 0, // offset = (1-1) * 10 + ); + }); + + it("should calculate correct offset from page and limit", async () => { + vi.mocked(postRepo.findAll).mockResolvedValue({ posts: [], total: 0 }); + vi.mocked(commentBookmarkRepo.findBookmarkedByUserId).mockResolvedValue( + { + comments: [], + total: 0, + }, + ); + + await useCase.execute({ userId, page: 3, limit: 5 }); + + expect(commentBookmarkRepo.findBookmarkedByUserId).toHaveBeenCalledWith( + userId, + 5, + 10, // offset = (3-1) * 5 + ); + }); + + it("should pass savedByUserId and currentUserId to post repository", async () => { + vi.mocked(postRepo.findAll).mockResolvedValue({ posts: [], total: 0 }); + vi.mocked(commentBookmarkRepo.findBookmarkedByUserId).mockResolvedValue( + { + comments: [], + total: 0, + }, + ); + + await useCase.execute({ userId, page: 2, limit: 20 }); + + expect(postRepo.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + savedByUserId: userId, + currentUserId: userId, + page: 2, + limit: 20, + }), + ); + }); + + it("should fetch posts and comments in parallel", async () => { + const callOrder: string[] = []; + + vi.mocked(postRepo.findAll).mockImplementation(async () => { + callOrder.push("posts"); + return { posts: [], total: 0 }; + }); + vi.mocked( + commentBookmarkRepo.findBookmarkedByUserId, + ).mockImplementation(async () => { + callOrder.push("comments"); + return { comments: [], total: 0 }; + }); + + await useCase.execute({ userId }); + + expect(callOrder).toContain("posts"); + expect(callOrder).toContain("comments"); + expect(postRepo.findAll).toHaveBeenCalledOnce(); + expect( + commentBookmarkRepo.findBookmarkedByUserId, + ).toHaveBeenCalledOnce(); + }); +}); From d979006e202ba95bcf4dbb0af77895ac66e6c8fa Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 22:03:10 +0300 Subject: [PATCH 23/29] test(bookmark): add RemoveBookmarkUseCase unit tests - fix: skip cache invalidation when post was not bookmarked - fix: wrap cache invalidation in try/catch to prevent non-critical failures from surfacing --- .../remove-bookmark.usecase.ts | 16 ++- .../bookmark/remove-bookmark.usecase.test.ts | 106 ++++++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 tests/unit/core/use-cases/bookmark/remove-bookmark.usecase.test.ts diff --git a/src/core/use-cases/bookmark/remove-bookmark/remove-bookmark.usecase.ts b/src/core/use-cases/bookmark/remove-bookmark/remove-bookmark.usecase.ts index 8d0c827..4d54f6c 100644 --- a/src/core/use-cases/bookmark/remove-bookmark/remove-bookmark.usecase.ts +++ b/src/core/use-cases/bookmark/remove-bookmark/remove-bookmark.usecase.ts @@ -23,6 +23,8 @@ export class RemoveBookmarkUseCase { * @throws NotFoundError if the post does not exist */ async execute(input: RemoveBookmarkInput): Promise { + let bookmarkRemoved = false; + await this.transactionService.runInTransaction(async (ctx) => { const post = await ctx.postRepository.findById(input.postId); if (!post) { @@ -36,10 +38,18 @@ export class RemoveBookmarkUseCase { if (hasBookmark) { await ctx.bookmarkRepository.remove(input.postId, input.userId); + bookmarkRemoved = true; } }); - await this.cacheService.deleteByPattern( - `posts:feed:*user:${input.userId}*`, - ); + + if (!bookmarkRemoved) return; + + try { + await this.cacheService.deleteByPattern( + `posts:feed:*user:${input.userId}*`, + ); + } catch { + // Cache invalidation failure is non-critical; bookmark is already removed. + } } } diff --git a/tests/unit/core/use-cases/bookmark/remove-bookmark.usecase.test.ts b/tests/unit/core/use-cases/bookmark/remove-bookmark.usecase.test.ts new file mode 100644 index 0000000..3d8706a --- /dev/null +++ b/tests/unit/core/use-cases/bookmark/remove-bookmark.usecase.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RemoveBookmarkUseCase } from "@core/use-cases/bookmark/remove-bookmark/remove-bookmark.usecase"; +import { NotFoundError } from "@core/errors"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import type { CachePort } from "@core/ports/services/cache.port"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { IBookmarkRepository } from "@core/ports/repositories/bookmark.repository"; +import type { Post } from "@core/domain/entities/post.entity"; + +describe("RemoveBookmarkUseCase", () => { + let useCase: RemoveBookmarkUseCase; + let transactionSvc: Pick; + let cacheSvc: Pick; + let txPostRepo: Pick; + let txBookmarkRepo: Pick; + + const input = { postId: "post-1", userId: "user-1" }; + + const buildTransactionContext = (): TransactionContext => + ({ + postRepository: txPostRepo as IPostRepository, + bookmarkRepository: txBookmarkRepo as IBookmarkRepository, + }) as TransactionContext; + + beforeEach(() => { + txPostRepo = { findById: vi.fn() }; + txBookmarkRepo = { isBookmarked: vi.fn(), remove: vi.fn() }; + cacheSvc = { deleteByPattern: vi.fn() }; + transactionSvc = { runInTransaction: vi.fn() }; + + vi.mocked(transactionSvc.runInTransaction).mockImplementation( + async (work) => work(buildTransactionContext()), + ); + + useCase = new RemoveBookmarkUseCase( + transactionSvc as TransactionPort, + cacheSvc as CachePort, + ); + }); + + it("should throw NotFoundError when post does not exist", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue(null); + + await expect(useCase.execute(input)).rejects.toThrow(NotFoundError); + expect(txBookmarkRepo.remove).not.toHaveBeenCalled(); + }); + + it("should not remove when post is not bookmarked", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue({} as Post); + vi.mocked(txBookmarkRepo.isBookmarked).mockResolvedValue(false); + + await useCase.execute(input); + + expect(txBookmarkRepo.remove).not.toHaveBeenCalled(); + }); + + it("should remove bookmark when post is bookmarked", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue({} as Post); + vi.mocked(txBookmarkRepo.isBookmarked).mockResolvedValue(true); + vi.mocked(txBookmarkRepo.remove).mockResolvedValue(); + vi.mocked(cacheSvc.deleteByPattern).mockResolvedValue(); + + await useCase.execute(input); + + expect(txBookmarkRepo.remove).toHaveBeenCalledWith( + input.postId, + input.userId, + ); + }); + + it("should invalidate user feed cache after bookmark is removed", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue({} as Post); + vi.mocked(txBookmarkRepo.isBookmarked).mockResolvedValue(true); + vi.mocked(txBookmarkRepo.remove).mockResolvedValue(); + vi.mocked(cacheSvc.deleteByPattern).mockResolvedValue(); + + await useCase.execute(input); + + expect(cacheSvc.deleteByPattern).toHaveBeenCalledWith( + `posts:feed:*user:${input.userId}*`, + ); + }); + + it("should not invalidate cache when post was not bookmarked", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue({} as Post); + vi.mocked(txBookmarkRepo.isBookmarked).mockResolvedValue(false); + + await useCase.execute(input); + + expect(cacheSvc.deleteByPattern).not.toHaveBeenCalled(); + }); + + it("should resolve successfully even when cache invalidation fails", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue({} as Post); + vi.mocked(txBookmarkRepo.isBookmarked).mockResolvedValue(true); + vi.mocked(txBookmarkRepo.remove).mockResolvedValue(); + vi.mocked(cacheSvc.deleteByPattern).mockRejectedValue( + new Error("Redis connection error"), + ); + + await expect(useCase.execute(input)).resolves.toBeUndefined(); + }); +}); From 01ca0ffcae462d8ca72892ca9cbf1553d6b4d02c Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 22:04:28 +0300 Subject: [PATCH 24/29] test(bookmark): add SaveCommentBookmarkUseCase and RemoveCommentBookmarkUseCase unit tests --- .../remove-comment-bookmark.usecase.test.ts | 56 +++++++++++++++++++ .../save-comment-bookmark.usecase.test.ts | 56 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 tests/unit/core/use-cases/bookmark/remove-comment-bookmark.usecase.test.ts create mode 100644 tests/unit/core/use-cases/bookmark/save-comment-bookmark.usecase.test.ts diff --git a/tests/unit/core/use-cases/bookmark/remove-comment-bookmark.usecase.test.ts b/tests/unit/core/use-cases/bookmark/remove-comment-bookmark.usecase.test.ts new file mode 100644 index 0000000..9eeb63b --- /dev/null +++ b/tests/unit/core/use-cases/bookmark/remove-comment-bookmark.usecase.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RemoveCommentBookmarkUseCase } from "@core/use-cases/bookmark/remove-comment-bookmark/remove-comment-bookmark.usecase"; +import { NotFoundError } from "@core/errors"; +import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import type { ICommentBookmarkRepository } from "@core/ports/repositories/comment-bookmark.repository"; +import type { Comment } from "@core/domain/entities/comment.entity"; + +describe("RemoveCommentBookmarkUseCase", () => { + let useCase: RemoveCommentBookmarkUseCase; + let commentRepo: Pick; + let commentBookmarkRepo: Pick< + ICommentBookmarkRepository, + "isBookmarked" | "remove" + >; + + const input = { commentId: "comment-1", userId: "user-1" }; + + beforeEach(() => { + commentRepo = { findById: vi.fn() }; + commentBookmarkRepo = { isBookmarked: vi.fn(), remove: vi.fn() }; + + useCase = new RemoveCommentBookmarkUseCase( + commentRepo as ICommentRepository, + commentBookmarkRepo as ICommentBookmarkRepository, + ); + }); + + it("should throw NotFoundError when comment does not exist", async () => { + vi.mocked(commentRepo.findById).mockResolvedValue(null); + + await expect(useCase.execute(input)).rejects.toThrow(NotFoundError); + expect(commentBookmarkRepo.remove).not.toHaveBeenCalled(); + }); + + it("should not remove when comment is not bookmarked", async () => { + vi.mocked(commentRepo.findById).mockResolvedValue({} as Comment); + vi.mocked(commentBookmarkRepo.isBookmarked).mockResolvedValue(false); + + await useCase.execute(input); + + expect(commentBookmarkRepo.remove).not.toHaveBeenCalled(); + }); + + it("should remove bookmark when comment is bookmarked", async () => { + vi.mocked(commentRepo.findById).mockResolvedValue({} as Comment); + vi.mocked(commentBookmarkRepo.isBookmarked).mockResolvedValue(true); + vi.mocked(commentBookmarkRepo.remove).mockResolvedValue(); + + await useCase.execute(input); + + expect(commentBookmarkRepo.remove).toHaveBeenCalledWith( + input.commentId, + input.userId, + ); + }); +}); diff --git a/tests/unit/core/use-cases/bookmark/save-comment-bookmark.usecase.test.ts b/tests/unit/core/use-cases/bookmark/save-comment-bookmark.usecase.test.ts new file mode 100644 index 0000000..e7211fb --- /dev/null +++ b/tests/unit/core/use-cases/bookmark/save-comment-bookmark.usecase.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SaveCommentBookmarkUseCase } from "@core/use-cases/bookmark/save-comment-bookmark/save-comment-bookmark.usecase"; +import { NotFoundError } from "@core/errors"; +import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import type { ICommentBookmarkRepository } from "@core/ports/repositories/comment-bookmark.repository"; +import type { Comment } from "@core/domain/entities/comment.entity"; + +describe("SaveCommentBookmarkUseCase", () => { + let useCase: SaveCommentBookmarkUseCase; + let commentRepo: Pick; + let commentBookmarkRepo: Pick< + ICommentBookmarkRepository, + "isBookmarked" | "save" + >; + + const input = { commentId: "comment-1", userId: "user-1" }; + + beforeEach(() => { + commentRepo = { findById: vi.fn() }; + commentBookmarkRepo = { isBookmarked: vi.fn(), save: vi.fn() }; + + useCase = new SaveCommentBookmarkUseCase( + commentRepo as ICommentRepository, + commentBookmarkRepo as ICommentBookmarkRepository, + ); + }); + + it("should throw NotFoundError when comment does not exist", async () => { + vi.mocked(commentRepo.findById).mockResolvedValue(null); + + await expect(useCase.execute(input)).rejects.toThrow(NotFoundError); + expect(commentBookmarkRepo.save).not.toHaveBeenCalled(); + }); + + it("should not save bookmark when comment is already bookmarked", async () => { + vi.mocked(commentRepo.findById).mockResolvedValue({} as Comment); + vi.mocked(commentBookmarkRepo.isBookmarked).mockResolvedValue(true); + + await useCase.execute(input); + + expect(commentBookmarkRepo.save).not.toHaveBeenCalled(); + }); + + it("should save bookmark when comment exists and is not bookmarked", async () => { + vi.mocked(commentRepo.findById).mockResolvedValue({} as Comment); + vi.mocked(commentBookmarkRepo.isBookmarked).mockResolvedValue(false); + vi.mocked(commentBookmarkRepo.save).mockResolvedValue(); + + await useCase.execute(input); + + expect(commentBookmarkRepo.save).toHaveBeenCalledWith( + input.commentId, + input.userId, + ); + }); +}); From c577173f3ec29656240ce15b49a3c08072a5bb4e Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 22:10:43 +0300 Subject: [PATCH 25/29] test(comment): add unit tests for all comment use cases - CreateCommentUseCase: 7 tests (post/parent not found, cross-post parent, top-level notification, reply notification, self-comment no notify) - DeleteCommentUseCase: 5 tests (not found, forbidden, top-level delete, reply repliesCount decrement, cache error silenced) - GetCommentUseCase: 3 tests - GetCommentRepliesUseCase: 4 tests (not found, replies returned, pagination offset, currentUserId) - GetPostCommentsUseCase: 4 tests - LikeCommentUseCase: 5 tests (not found, idempotent, add+increment, self no notify, notify+realtime) - UnlikeCommentUseCase: 3 tests fix(comment): wrap cache deleteByPattern in try/catch in DeleteCommentUseCase feat(test): add buildComment factory to mock-factories --- .../delete-comment/delete-comment.usecase.ts | 7 +- .../comment/create-comment.usecase.test.ts | 218 ++++++++++++++++++ .../comment/delete-comment.usecase.test.ts | 119 ++++++++++ .../get-comment-replies.usecase.test.ts | 85 +++++++ .../comment/get-comment.usecase.test.ts | 51 ++++ .../comment/get-post-comments.usecase.test.ts | 77 +++++++ .../comment/like-comment.usecase.test.ts | 131 +++++++++++ .../comment/unlike-comment.usecase.test.ts | 76 ++++++ tests/unit/helpers/mock-factories.ts | 15 ++ 9 files changed, 777 insertions(+), 2 deletions(-) create mode 100644 tests/unit/core/use-cases/comment/create-comment.usecase.test.ts create mode 100644 tests/unit/core/use-cases/comment/delete-comment.usecase.test.ts create mode 100644 tests/unit/core/use-cases/comment/get-comment-replies.usecase.test.ts create mode 100644 tests/unit/core/use-cases/comment/get-comment.usecase.test.ts create mode 100644 tests/unit/core/use-cases/comment/get-post-comments.usecase.test.ts create mode 100644 tests/unit/core/use-cases/comment/like-comment.usecase.test.ts create mode 100644 tests/unit/core/use-cases/comment/unlike-comment.usecase.test.ts diff --git a/src/core/use-cases/comment/delete-comment/delete-comment.usecase.ts b/src/core/use-cases/comment/delete-comment/delete-comment.usecase.ts index 5c85b53..3af68a5 100644 --- a/src/core/use-cases/comment/delete-comment/delete-comment.usecase.ts +++ b/src/core/use-cases/comment/delete-comment/delete-comment.usecase.ts @@ -47,7 +47,10 @@ export class DeleteCommentUseCase { await ctx.postRepository.decrementCommentsCount(comment.postId); }); - // 4. Cache temizliği - await this.cacheService.deleteByPattern("posts:feed:*"); + try { + await this.cacheService.deleteByPattern("posts:feed:*"); + } catch { + // Cache invalidation failure is non-critical; comment is already deleted. + } } } diff --git a/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts b/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts new file mode 100644 index 0000000..9d08c46 --- /dev/null +++ b/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CreateCommentUseCase } from "@core/use-cases/comment/create-comment/create-comment.usecase"; +import { NotFoundError, BadRequestError } from "@core/errors"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import type { RealtimePort } from "@core/ports/services/realtime.port"; +import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { Comment } from "@core/domain/entities/comment.entity"; +import type { Post } from "@core/domain/entities/post.entity"; +import { buildComment } from "../../../helpers/mock-factories"; + +const buildPost = (authorId = "author-1"): Post => + ({ + id: "post-1", + author: { id: authorId }, + }) as unknown as Post; + +describe("CreateCommentUseCase", () => { + let useCase: CreateCommentUseCase; + let transactionSvc: Pick; + let realtimeSvc: Pick; + let txPostRepo: Pick< + IPostRepository, + "findById" | "incrementCommentsCount" + >; + let txCommentRepo: Pick< + ICommentRepository, + "findById" | "create" | "incrementRepliesCount" + >; + let txNotificationRepo: Pick; + + const buildTransactionContext = (): TransactionContext => + ({ + postRepository: txPostRepo as IPostRepository, + commentRepository: txCommentRepo as ICommentRepository, + notificationRepository: + txNotificationRepo as INotificationRepository, + }) as TransactionContext; + + beforeEach(() => { + txPostRepo = { + findById: vi.fn(), + incrementCommentsCount: vi.fn(), + }; + txCommentRepo = { + findById: vi.fn(), + create: vi.fn(), + incrementRepliesCount: vi.fn(), + }; + txNotificationRepo = { create: vi.fn() }; + realtimeSvc = { emitToUser: vi.fn() }; + transactionSvc = { runInTransaction: vi.fn() }; + + vi.mocked(transactionSvc.runInTransaction).mockImplementation( + async (work) => work(buildTransactionContext()), + ); + + useCase = new CreateCommentUseCase( + transactionSvc as TransactionPort, + realtimeSvc as RealtimePort, + ); + }); + + it("should throw NotFoundError when post does not exist", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ + content: "Hello", + postId: "post-1", + authorId: "user-1", + }), + ).rejects.toThrow(NotFoundError); + }); + + it("should throw NotFoundError when parent comment does not exist", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue(buildPost()); + vi.mocked(txCommentRepo.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ + content: "Reply", + postId: "post-1", + authorId: "user-1", + parentId: "parent-1", + }), + ).rejects.toThrow(NotFoundError); + }); + + it("should throw BadRequestError when parent comment belongs to a different post", async () => { + vi.mocked(txPostRepo.findById).mockResolvedValue(buildPost()); + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ id: "parent-1", postId: "other-post" }), + ); + + await expect( + useCase.execute({ + content: "Reply", + postId: "post-1", + authorId: "user-1", + parentId: "parent-1", + }), + ).rejects.toThrow(BadRequestError); + }); + + it("should create top-level comment and notify post author when commenter is not the author", async () => { + const savedComment = buildComment({ id: "new-comment-1" }); + vi.mocked(txPostRepo.findById).mockResolvedValue(buildPost("author-1")); + vi.mocked(txCommentRepo.create).mockResolvedValue(savedComment); + vi.mocked(txPostRepo.incrementCommentsCount).mockResolvedValue( + undefined, + ); + + const result = await useCase.execute({ + content: "Hello", + postId: "post-1", + authorId: "commenter-user", + }); + + expect(result).toBe(savedComment); + expect(txPostRepo.incrementCommentsCount).toHaveBeenCalledWith( + "post-1", + ); + expect(txNotificationRepo.create).toHaveBeenCalledOnce(); + expect(realtimeSvc.emitToUser).toHaveBeenCalledWith( + "author-1", + "new-notification", + expect.objectContaining({ type: "COMMENT" }), + ); + }); + + it("should not send notification when commenter is the post author", async () => { + const savedComment = buildComment({ id: "new-comment-1" }); + vi.mocked(txPostRepo.findById).mockResolvedValue(buildPost("user-1")); + vi.mocked(txCommentRepo.create).mockResolvedValue(savedComment); + vi.mocked(txPostRepo.incrementCommentsCount).mockResolvedValue( + undefined, + ); + + await useCase.execute({ + content: "My own post comment", + postId: "post-1", + authorId: "user-1", + }); + + expect(txNotificationRepo.create).not.toHaveBeenCalled(); + expect(realtimeSvc.emitToUser).not.toHaveBeenCalled(); + }); + + it("should create reply, increment repliesCount and notify parent comment author", async () => { + const parentComment = buildComment({ + id: "parent-1", + postId: "post-1", + authorId: "parent-author", + }); + const savedComment = buildComment({ id: "reply-1" }); + + vi.mocked(txPostRepo.findById).mockResolvedValue(buildPost("author-1")); + vi.mocked(txCommentRepo.findById).mockResolvedValue(parentComment); + vi.mocked(txCommentRepo.create).mockResolvedValue(savedComment); + vi.mocked(txPostRepo.incrementCommentsCount).mockResolvedValue( + undefined, + ); + vi.mocked(txCommentRepo.incrementRepliesCount).mockResolvedValue( + undefined, + ); + + await useCase.execute({ + content: "Reply", + postId: "post-1", + authorId: "user-1", + parentId: "parent-1", + }); + + expect(txCommentRepo.incrementRepliesCount).toHaveBeenCalledWith( + "parent-1", + ); + expect(txNotificationRepo.create).toHaveBeenCalledOnce(); + expect(realtimeSvc.emitToUser).toHaveBeenCalledWith( + "parent-author", + "new-notification", + expect.objectContaining({ type: "COMMENT" }), + ); + }); + + it("should not send notification when replying to own comment", async () => { + const parentComment = buildComment({ + id: "parent-1", + postId: "post-1", + authorId: "user-1", + }); + const savedComment = buildComment({ id: "reply-1" }); + + vi.mocked(txPostRepo.findById).mockResolvedValue(buildPost("author-1")); + vi.mocked(txCommentRepo.findById).mockResolvedValue(parentComment); + vi.mocked(txCommentRepo.create).mockResolvedValue(savedComment); + vi.mocked(txPostRepo.incrementCommentsCount).mockResolvedValue( + undefined, + ); + vi.mocked(txCommentRepo.incrementRepliesCount).mockResolvedValue( + undefined, + ); + + await useCase.execute({ + content: "Self reply", + postId: "post-1", + authorId: "user-1", + parentId: "parent-1", + }); + + expect(txNotificationRepo.create).not.toHaveBeenCalled(); + expect(realtimeSvc.emitToUser).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/core/use-cases/comment/delete-comment.usecase.test.ts b/tests/unit/core/use-cases/comment/delete-comment.usecase.test.ts new file mode 100644 index 0000000..c3c423b --- /dev/null +++ b/tests/unit/core/use-cases/comment/delete-comment.usecase.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DeleteCommentUseCase } from "@core/use-cases/comment/delete-comment/delete-comment.usecase"; +import { NotFoundError, ForbiddenError } from "@core/errors"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import type { CachePort } from "@core/ports/services/cache.port"; +import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import { buildComment } from "../../../helpers/mock-factories"; + +describe("DeleteCommentUseCase", () => { + let useCase: DeleteCommentUseCase; + let transactionSvc: Pick; + let cacheSvc: Pick; + let txCommentRepo: Pick< + ICommentRepository, + "findById" | "delete" | "decrementRepliesCount" + >; + let txPostRepo: Pick; + + const input = { commentId: "comment-1", userId: "user-1" }; + + const buildTransactionContext = (): TransactionContext => + ({ + commentRepository: txCommentRepo as ICommentRepository, + postRepository: txPostRepo as IPostRepository, + }) as TransactionContext; + + beforeEach(() => { + txCommentRepo = { + findById: vi.fn(), + delete: vi.fn(), + decrementRepliesCount: vi.fn(), + }; + txPostRepo = { decrementCommentsCount: vi.fn() }; + cacheSvc = { deleteByPattern: vi.fn() }; + transactionSvc = { runInTransaction: vi.fn() }; + + vi.mocked(transactionSvc.runInTransaction).mockImplementation( + async (work) => work(buildTransactionContext()), + ); + + useCase = new DeleteCommentUseCase( + transactionSvc as TransactionPort, + cacheSvc as CachePort, + ); + }); + + it("should throw NotFoundError when comment does not exist", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue(null); + + await expect(useCase.execute(input)).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenError when user is not the comment author", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ authorId: "other-user" }), + ); + + await expect(useCase.execute(input)).rejects.toThrow(ForbiddenError); + }); + + it("should delete top-level comment and decrement post comment count", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ authorId: "user-1", parentId: null }), + ); + vi.mocked(txCommentRepo.delete).mockResolvedValue(undefined); + vi.mocked(txPostRepo.decrementCommentsCount).mockResolvedValue( + undefined, + ); + vi.mocked(cacheSvc.deleteByPattern).mockResolvedValue(undefined); + + await useCase.execute(input); + + expect(txCommentRepo.delete).toHaveBeenCalledWith("comment-1"); + expect(txPostRepo.decrementCommentsCount).toHaveBeenCalledWith( + "post-1", + ); + expect(txCommentRepo.decrementRepliesCount).not.toHaveBeenCalled(); + expect(cacheSvc.deleteByPattern).toHaveBeenCalledWith("posts:feed:*"); + }); + + it("should also decrement parent repliesCount when deleting a reply", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ authorId: "user-1", parentId: "parent-1" }), + ); + vi.mocked(txCommentRepo.delete).mockResolvedValue(undefined); + vi.mocked(txPostRepo.decrementCommentsCount).mockResolvedValue( + undefined, + ); + vi.mocked(txCommentRepo.decrementRepliesCount).mockResolvedValue( + undefined, + ); + vi.mocked(cacheSvc.deleteByPattern).mockResolvedValue(undefined); + + await useCase.execute(input); + + expect(txCommentRepo.decrementRepliesCount).toHaveBeenCalledWith( + "parent-1", + ); + }); + + it("should not throw when cache invalidation fails", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ authorId: "user-1" }), + ); + vi.mocked(txCommentRepo.delete).mockResolvedValue(undefined); + vi.mocked(txPostRepo.decrementCommentsCount).mockResolvedValue( + undefined, + ); + vi.mocked(cacheSvc.deleteByPattern).mockRejectedValue( + new Error("Cache unavailable"), + ); + + await expect(useCase.execute(input)).resolves.toBeUndefined(); + }); +}); diff --git a/tests/unit/core/use-cases/comment/get-comment-replies.usecase.test.ts b/tests/unit/core/use-cases/comment/get-comment-replies.usecase.test.ts new file mode 100644 index 0000000..377650b --- /dev/null +++ b/tests/unit/core/use-cases/comment/get-comment-replies.usecase.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetCommentRepliesUseCase } from "@core/use-cases/comment/get-comment-replies/get-comment-replies.usecase"; +import { NotFoundError } from "@core/errors"; +import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import { buildComment } from "../../../helpers/mock-factories"; + +describe("GetCommentRepliesUseCase", () => { + let useCase: GetCommentRepliesUseCase; + let commentRepo: Pick< + ICommentRepository, + "findById" | "findRepliesByParentId" + >; + + beforeEach(() => { + commentRepo = { + findById: vi.fn(), + findRepliesByParentId: vi.fn(), + }; + useCase = new GetCommentRepliesUseCase( + commentRepo as ICommentRepository, + ); + }); + + it("should throw NotFoundError when parent comment does not exist", async () => { + vi.mocked(commentRepo.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ commentId: "comment-1" }), + ).rejects.toThrow(NotFoundError); + }); + + it("should return replies for the given parent comment", async () => { + const parent = buildComment({ id: "comment-1" }); + const replies = [ + buildComment({ id: "reply-1", parentId: "comment-1" }), + buildComment({ id: "reply-2", parentId: "comment-1" }), + ]; + + vi.mocked(commentRepo.findById).mockResolvedValue(parent); + vi.mocked(commentRepo.findRepliesByParentId).mockResolvedValue(replies); + + const result = await useCase.execute({ commentId: "comment-1" }); + + expect(result).toBe(replies); + expect(commentRepo.findRepliesByParentId).toHaveBeenCalledWith( + "comment-1", + 10, + 0, + undefined, + ); + }); + + it("should calculate offset correctly for pagination", async () => { + const parent = buildComment({ id: "comment-1" }); + vi.mocked(commentRepo.findById).mockResolvedValue(parent); + vi.mocked(commentRepo.findRepliesByParentId).mockResolvedValue([]); + + await useCase.execute({ commentId: "comment-1", page: 3, limit: 5 }); + + expect(commentRepo.findRepliesByParentId).toHaveBeenCalledWith( + "comment-1", + 5, + 10, + undefined, + ); + }); + + it("should pass currentUserId to the repository", async () => { + const parent = buildComment({ id: "comment-1" }); + vi.mocked(commentRepo.findById).mockResolvedValue(parent); + vi.mocked(commentRepo.findRepliesByParentId).mockResolvedValue([]); + + await useCase.execute({ + commentId: "comment-1", + currentUserId: "user-99", + }); + + expect(commentRepo.findRepliesByParentId).toHaveBeenCalledWith( + "comment-1", + 10, + 0, + "user-99", + ); + }); +}); diff --git a/tests/unit/core/use-cases/comment/get-comment.usecase.test.ts b/tests/unit/core/use-cases/comment/get-comment.usecase.test.ts new file mode 100644 index 0000000..0a213a7 --- /dev/null +++ b/tests/unit/core/use-cases/comment/get-comment.usecase.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetCommentUseCase } from "@core/use-cases/comment/get-comment/get-comment.usecase"; +import { NotFoundError } from "@core/errors"; +import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import { buildComment } from "../../../helpers/mock-factories"; + +describe("GetCommentUseCase", () => { + let useCase: GetCommentUseCase; + let commentRepo: Pick; + + beforeEach(() => { + commentRepo = { findById: vi.fn() }; + useCase = new GetCommentUseCase(commentRepo as ICommentRepository); + }); + + it("should return the comment when it exists", async () => { + const comment = buildComment(); + vi.mocked(commentRepo.findById).mockResolvedValue(comment); + + const result = await useCase.execute({ commentId: "comment-1" }); + + expect(result).toBe(comment); + expect(commentRepo.findById).toHaveBeenCalledWith( + "comment-1", + undefined, + ); + }); + + it("should pass currentUserId to the repository", async () => { + const comment = buildComment(); + vi.mocked(commentRepo.findById).mockResolvedValue(comment); + + await useCase.execute({ + commentId: "comment-1", + currentUserId: "user-99", + }); + + expect(commentRepo.findById).toHaveBeenCalledWith( + "comment-1", + "user-99", + ); + }); + + it("should throw NotFoundError when comment does not exist", async () => { + vi.mocked(commentRepo.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ commentId: "comment-1" }), + ).rejects.toThrow(NotFoundError); + }); +}); diff --git a/tests/unit/core/use-cases/comment/get-post-comments.usecase.test.ts b/tests/unit/core/use-cases/comment/get-post-comments.usecase.test.ts new file mode 100644 index 0000000..13e2c0a --- /dev/null +++ b/tests/unit/core/use-cases/comment/get-post-comments.usecase.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetPostCommentsUseCase } from "@core/use-cases/comment/get-post-comments/get-post-comments.usecase"; +import { NotFoundError } from "@core/errors"; +import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { Post } from "@core/domain/entities/post.entity"; +import { buildComment } from "../../../helpers/mock-factories"; + +const buildPost = (): Post => ({ id: "post-1" }) as unknown as Post; + +describe("GetPostCommentsUseCase", () => { + let useCase: GetPostCommentsUseCase; + let postRepo: Pick; + let commentRepo: Pick; + + beforeEach(() => { + postRepo = { findById: vi.fn() }; + commentRepo = { findTopLevelByPostId: vi.fn() }; + + useCase = new GetPostCommentsUseCase( + commentRepo as ICommentRepository, + postRepo as IPostRepository, + ); + }); + + it("should throw NotFoundError when post does not exist", async () => { + vi.mocked(postRepo.findById).mockResolvedValue(null); + + await expect(useCase.execute({ postId: "post-1" })).rejects.toThrow( + NotFoundError, + ); + }); + + it("should return top-level comments for the post", async () => { + const comments = [buildComment(), buildComment({ id: "comment-2" })]; + vi.mocked(postRepo.findById).mockResolvedValue(buildPost()); + vi.mocked(commentRepo.findTopLevelByPostId).mockResolvedValue(comments); + + const result = await useCase.execute({ postId: "post-1" }); + + expect(result).toBe(comments); + expect(commentRepo.findTopLevelByPostId).toHaveBeenCalledWith( + "post-1", + 10, + 0, + undefined, + ); + }); + + it("should calculate offset correctly for pagination", async () => { + vi.mocked(postRepo.findById).mockResolvedValue(buildPost()); + vi.mocked(commentRepo.findTopLevelByPostId).mockResolvedValue([]); + + await useCase.execute({ postId: "post-1", page: 2, limit: 20 }); + + expect(commentRepo.findTopLevelByPostId).toHaveBeenCalledWith( + "post-1", + 20, + 20, + undefined, + ); + }); + + it("should pass currentUserId to the repository", async () => { + vi.mocked(postRepo.findById).mockResolvedValue(buildPost()); + vi.mocked(commentRepo.findTopLevelByPostId).mockResolvedValue([]); + + await useCase.execute({ postId: "post-1", currentUserId: "user-5" }); + + expect(commentRepo.findTopLevelByPostId).toHaveBeenCalledWith( + "post-1", + 10, + 0, + "user-5", + ); + }); +}); diff --git a/tests/unit/core/use-cases/comment/like-comment.usecase.test.ts b/tests/unit/core/use-cases/comment/like-comment.usecase.test.ts new file mode 100644 index 0000000..9f6ee21 --- /dev/null +++ b/tests/unit/core/use-cases/comment/like-comment.usecase.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LikeCommentUseCase } from "@core/use-cases/comment/like-comment/like-comment.usecase"; +import { NotFoundError } from "@core/errors"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import type { RealtimePort } from "@core/ports/services/realtime.port"; +import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import { buildComment } from "../../../helpers/mock-factories"; + +describe("LikeCommentUseCase", () => { + let useCase: LikeCommentUseCase; + let transactionSvc: Pick; + let realtimeSvc: Pick; + let txCommentRepo: Pick< + ICommentRepository, + "findById" | "hasUserLiked" | "addLike" | "incrementLikeCount" + >; + 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(() => { + txCommentRepo = { + findById: vi.fn(), + hasUserLiked: vi.fn(), + addLike: vi.fn(), + incrementLikeCount: vi.fn(), + }; + txNotificationRepo = { create: vi.fn() }; + realtimeSvc = { emitToUser: vi.fn() }; + transactionSvc = { runInTransaction: vi.fn() }; + + vi.mocked(transactionSvc.runInTransaction).mockImplementation( + async (work) => work(buildTransactionContext()), + ); + + useCase = new LikeCommentUseCase( + transactionSvc as TransactionPort, + realtimeSvc as RealtimePort, + ); + }); + + it("should throw NotFoundError when comment does not exist", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue(null); + + await expect(useCase.execute(input)).rejects.toThrow(NotFoundError); + }); + + it("should do nothing when comment is already liked (idempotent)", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue(buildComment()); + vi.mocked(txCommentRepo.hasUserLiked).mockResolvedValue(true); + + await useCase.execute(input); + + expect(txCommentRepo.addLike).not.toHaveBeenCalled(); + expect(txCommentRepo.incrementLikeCount).not.toHaveBeenCalled(); + expect(txNotificationRepo.create).not.toHaveBeenCalled(); + }); + + it("should add like and increment count", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ authorId: "user-1" }), + ); + vi.mocked(txCommentRepo.hasUserLiked).mockResolvedValue(false); + vi.mocked(txCommentRepo.addLike).mockResolvedValue(undefined); + vi.mocked(txCommentRepo.incrementLikeCount).mockResolvedValue( + undefined, + ); + + await useCase.execute(input); + + expect(txCommentRepo.addLike).toHaveBeenCalledWith( + "comment-1", + "user-1", + ); + expect(txCommentRepo.incrementLikeCount).toHaveBeenCalledWith( + "comment-1", + ); + }); + + it("should not notify when user likes their own comment", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ authorId: "user-1" }), + ); + vi.mocked(txCommentRepo.hasUserLiked).mockResolvedValue(false); + vi.mocked(txCommentRepo.addLike).mockResolvedValue(undefined); + vi.mocked(txCommentRepo.incrementLikeCount).mockResolvedValue( + undefined, + ); + + await useCase.execute(input); + + expect(txNotificationRepo.create).not.toHaveBeenCalled(); + expect(realtimeSvc.emitToUser).not.toHaveBeenCalled(); + }); + + it("should create notification and emit realtime event when liking another user's comment", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ authorId: "comment-author", postId: "post-1" }), + ); + vi.mocked(txCommentRepo.hasUserLiked).mockResolvedValue(false); + vi.mocked(txCommentRepo.addLike).mockResolvedValue(undefined); + vi.mocked(txCommentRepo.incrementLikeCount).mockResolvedValue( + undefined, + ); + + await useCase.execute({ commentId: "comment-1", userId: "user-1" }); + + expect(txNotificationRepo.create).toHaveBeenCalledOnce(); + expect(realtimeSvc.emitToUser).toHaveBeenCalledWith( + "comment-author", + "new-notification", + expect.objectContaining({ + type: "COMMENT_LIKE", + issuerId: "user-1", + commentId: "comment-1", + postId: "post-1", + }), + ); + }); +}); 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 new file mode 100644 index 0000000..3afb7ce --- /dev/null +++ b/tests/unit/core/use-cases/comment/unlike-comment.usecase.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UnlikeCommentUseCase } from "@core/use-cases/comment/unlike-comment/unlike-comment.usecase"; +import { NotFoundError } from "@core/errors"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import { buildComment } from "../../../helpers/mock-factories"; + +describe("UnlikeCommentUseCase", () => { + let useCase: UnlikeCommentUseCase; + let transactionSvc: Pick; + let txCommentRepo: Pick< + ICommentRepository, + "findById" | "hasUserLiked" | "removeLike" | "decrementLikeCount" + >; + + const input = { commentId: "comment-1", userId: "user-1" }; + + const buildTransactionContext = (): TransactionContext => + ({ + commentRepository: txCommentRepo as ICommentRepository, + }) as TransactionContext; + + beforeEach(() => { + txCommentRepo = { + findById: vi.fn(), + hasUserLiked: vi.fn(), + removeLike: vi.fn(), + decrementLikeCount: vi.fn(), + }; + transactionSvc = { runInTransaction: vi.fn() }; + + vi.mocked(transactionSvc.runInTransaction).mockImplementation( + async (work) => work(buildTransactionContext()), + ); + + useCase = new UnlikeCommentUseCase(transactionSvc as TransactionPort); + }); + + it("should throw NotFoundError when comment does not exist", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue(null); + + await expect(useCase.execute(input)).rejects.toThrow(NotFoundError); + }); + + it("should do nothing when comment is not liked (idempotent)", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue(buildComment()); + vi.mocked(txCommentRepo.hasUserLiked).mockResolvedValue(false); + + await useCase.execute(input); + + expect(txCommentRepo.removeLike).not.toHaveBeenCalled(); + expect(txCommentRepo.decrementLikeCount).not.toHaveBeenCalled(); + }); + + it("should remove like and decrement count when comment is liked", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue(buildComment()); + vi.mocked(txCommentRepo.hasUserLiked).mockResolvedValue(true); + vi.mocked(txCommentRepo.removeLike).mockResolvedValue(undefined); + vi.mocked(txCommentRepo.decrementLikeCount).mockResolvedValue( + undefined, + ); + + await useCase.execute(input); + + expect(txCommentRepo.removeLike).toHaveBeenCalledWith( + "comment-1", + "user-1", + ); + expect(txCommentRepo.decrementLikeCount).toHaveBeenCalledWith( + "comment-1", + ); + }); +}); diff --git a/tests/unit/helpers/mock-factories.ts b/tests/unit/helpers/mock-factories.ts index 5a371b4..eeb16ac 100644 --- a/tests/unit/helpers/mock-factories.ts +++ b/tests/unit/helpers/mock-factories.ts @@ -4,6 +4,8 @@ import { RefreshToken } from "@core/domain/entities/refresh-token.entity"; import type { RefreshTokenProps } from "@core/domain/interfaces/refresh-token.props.interface"; import { VerificationToken } from "@core/domain/entities/verification-token.entity"; import type { VerificationTokenProps } from "@core/domain/interfaces/verification-token.props.interface"; +import { Comment } from "@core/domain/entities/comment.entity"; +import type { CommentProps } from "@core/domain/interfaces/comment-props.interface"; import { TokenType } from "@core/domain/enums/token-type.enum"; export function buildUser(overrides: Partial = {}): User { @@ -51,3 +53,16 @@ export function buildRefreshToken( ...overrides, }); } + +export function buildComment(overrides: Partial = {}): Comment { + return Comment.with({ + id: "comment-1", + content: "Test comment", + postId: "post-1", + authorId: "user-1", + parentId: null, + createdAt: new Date("2024-01-01T00:00:00Z"), + updatedAt: new Date("2024-01-01T00:00:00Z"), + ...overrides, + }); +} From fa558410c76eec00203cc2f3fbfc83ce5abcd664 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 4 May 2026 22:23:55 +0300 Subject: [PATCH 26/29] fix(follow-user): add target user existence check; add unit tests - FollowUserUseCase: inject IProfileRepository, throw NotFoundError when target not found - Add unit tests for follow-user, unfollow-user, get-followers, get-following use cases (18 tests) - Add buildProfile factory to mock-factories - fix(comment): add missing page/limit fields to get-comment-replies test --- .../follow-user/follow-user.usecase.ts | 8 +- .../get-comment-replies.usecase.test.ts | 10 +- .../follow-user/follow-user.usecase.test.ts | 104 ++++++++++++++++++ .../follow-user/get-followers.usecase.test.ts | 99 +++++++++++++++++ .../follow-user/get-following.usecase.test.ts | 93 ++++++++++++++++ .../follow-user/unfollow-user.usecase.test.ts | 93 ++++++++++++++++ tests/unit/helpers/mock-factories.ts | 21 ++++ 7 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 tests/unit/core/use-cases/follow-user/follow-user.usecase.test.ts create mode 100644 tests/unit/core/use-cases/follow-user/get-followers.usecase.test.ts create mode 100644 tests/unit/core/use-cases/follow-user/get-following.usecase.test.ts create mode 100644 tests/unit/core/use-cases/follow-user/unfollow-user.usecase.test.ts diff --git a/src/core/use-cases/follow-user/follow-user/follow-user.usecase.ts b/src/core/use-cases/follow-user/follow-user/follow-user.usecase.ts index b371954..c067507 100644 --- a/src/core/use-cases/follow-user/follow-user/follow-user.usecase.ts +++ b/src/core/use-cases/follow-user/follow-user/follow-user.usecase.ts @@ -1,6 +1,7 @@ -import { BadRequestError } from "@core/errors"; +import { BadRequestError, NotFoundError } from "@core/errors"; import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; import { Notification } from "@core/domain/entities/notification.entity"; import { NotificationType } from "@core/domain/enums/notification-type.enum"; import type { RealtimePort } from "@core/ports/services/realtime.port"; @@ -25,6 +26,7 @@ export class FollowUserUseCase { private readonly followUserRepository: IFollowRepository, private readonly notificationRepository: INotificationRepository, private readonly realtimeService: RealtimePort, + private readonly profileRepository: IProfileRepository, ) {} /** @@ -42,6 +44,10 @@ export class FollowUserUseCase { if (currentUserId === targetId) throw new BadRequestError("You cannot follow yourself."); + const targetProfile = + await this.profileRepository.findByUserId(targetId); + if (!targetProfile) throw new NotFoundError("User not found."); + const isFollowing = await this.followUserRepository.checkIsFollowing( currentUserId, targetId, diff --git a/tests/unit/core/use-cases/comment/get-comment-replies.usecase.test.ts b/tests/unit/core/use-cases/comment/get-comment-replies.usecase.test.ts index 377650b..a26243e 100644 --- a/tests/unit/core/use-cases/comment/get-comment-replies.usecase.test.ts +++ b/tests/unit/core/use-cases/comment/get-comment-replies.usecase.test.ts @@ -25,7 +25,7 @@ describe("GetCommentRepliesUseCase", () => { vi.mocked(commentRepo.findById).mockResolvedValue(null); await expect( - useCase.execute({ commentId: "comment-1" }), + useCase.execute({ commentId: "comment-1", page: 1, limit: 10 }), ).rejects.toThrow(NotFoundError); }); @@ -39,7 +39,11 @@ describe("GetCommentRepliesUseCase", () => { vi.mocked(commentRepo.findById).mockResolvedValue(parent); vi.mocked(commentRepo.findRepliesByParentId).mockResolvedValue(replies); - const result = await useCase.execute({ commentId: "comment-1" }); + const result = await useCase.execute({ + commentId: "comment-1", + page: 1, + limit: 10, + }); expect(result).toBe(replies); expect(commentRepo.findRepliesByParentId).toHaveBeenCalledWith( @@ -72,6 +76,8 @@ describe("GetCommentRepliesUseCase", () => { await useCase.execute({ commentId: "comment-1", + page: 1, + limit: 10, currentUserId: "user-99", }); diff --git a/tests/unit/core/use-cases/follow-user/follow-user.usecase.test.ts b/tests/unit/core/use-cases/follow-user/follow-user.usecase.test.ts new file mode 100644 index 0000000..49abad4 --- /dev/null +++ b/tests/unit/core/use-cases/follow-user/follow-user.usecase.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FollowUserUseCase } from "@core/use-cases/follow-user/follow-user/follow-user.usecase"; +import { BadRequestError, NotFoundError } from "@core/errors"; +import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; +import type { RealtimePort } from "@core/ports/services/realtime.port"; +import { buildProfile } from "../../../helpers/mock-factories"; + +describe("FollowUserUseCase", () => { + let useCase: FollowUserUseCase; + let followRepo: Pick< + IFollowRepository, + "checkIsFollowing" | "followUser" | "getFollowersCount" + >; + let notificationRepo: Pick; + let realtimeSvc: Pick; + let profileRepo: Pick; + + beforeEach(() => { + followRepo = { + checkIsFollowing: vi.fn(), + followUser: vi.fn(), + getFollowersCount: vi.fn().mockResolvedValue(42), + }; + notificationRepo = { create: vi.fn() }; + realtimeSvc = { emitToUser: vi.fn() }; + profileRepo = { + findByUserId: vi + .fn() + .mockResolvedValue(buildProfile({ userId: "user-2" })), + }; + + useCase = new FollowUserUseCase( + followRepo as IFollowRepository, + notificationRepo as INotificationRepository, + realtimeSvc as RealtimePort, + profileRepo as IProfileRepository, + ); + }); + + it("should throw BadRequestError when user tries to follow themselves", async () => { + await expect( + useCase.execute({ currentUserId: "user-1", targetId: "user-1" }), + ).rejects.toThrow(BadRequestError); + }); + + it("should throw NotFoundError when target user does not exist", async () => { + vi.mocked(profileRepo.findByUserId).mockResolvedValue(null); + + await expect( + useCase.execute({ currentUserId: "user-1", targetId: "user-2" }), + ).rejects.toThrow(NotFoundError); + + expect(followRepo.checkIsFollowing).not.toHaveBeenCalled(); + }); + + it("should follow user, create notification and emit realtime when not already following", async () => { + vi.mocked(followRepo.checkIsFollowing).mockResolvedValue(false); + vi.mocked(followRepo.followUser).mockResolvedValue(undefined); + vi.mocked(notificationRepo.create).mockResolvedValue(undefined); + + const result = await useCase.execute({ + currentUserId: "user-1", + targetId: "user-2", + }); + + expect(followRepo.followUser).toHaveBeenCalledWith("user-1", "user-2"); + expect(notificationRepo.create).toHaveBeenCalledOnce(); + expect(realtimeSvc.emitToUser).toHaveBeenCalledWith( + "user-2", + "new-notification", + expect.objectContaining({ type: "FOLLOW", issuerId: "user-1" }), + ); + expect(result.followersCount).toBe(42); + }); + + it("should skip follow and notification when already following (idempotent)", async () => { + vi.mocked(followRepo.checkIsFollowing).mockResolvedValue(true); + + const result = await useCase.execute({ + currentUserId: "user-1", + targetId: "user-2", + }); + + expect(followRepo.followUser).not.toHaveBeenCalled(); + expect(notificationRepo.create).not.toHaveBeenCalled(); + expect(realtimeSvc.emitToUser).not.toHaveBeenCalled(); + expect(result.followersCount).toBe(42); + }); + + it("should always return followersCount regardless of follow state", async () => { + vi.mocked(followRepo.checkIsFollowing).mockResolvedValue(true); + vi.mocked(followRepo.getFollowersCount).mockResolvedValue(100); + + const result = await useCase.execute({ + currentUserId: "user-1", + targetId: "user-2", + }); + + expect(followRepo.getFollowersCount).toHaveBeenCalledWith("user-2"); + expect(result.followersCount).toBe(100); + }); +}); diff --git a/tests/unit/core/use-cases/follow-user/get-followers.usecase.test.ts b/tests/unit/core/use-cases/follow-user/get-followers.usecase.test.ts new file mode 100644 index 0000000..beb1846 --- /dev/null +++ b/tests/unit/core/use-cases/follow-user/get-followers.usecase.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetFollowersUseCase } from "@core/use-cases/follow-user/get-followers/get-followers.usecase"; +import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; + +const buildFollowerEntry = (userId: string) => ({ + userId, + username: `user_${userId}`, + fullName: `User ${userId}`, + avatarUrl: "https://example.com/avatar.png", + bio: null, +}); + +describe("GetFollowersUseCase", () => { + let useCase: GetFollowersUseCase; + let followRepo: Pick< + IFollowRepository, + "getFollowers" | "checkIsFollowingBulk" + >; + + const baseInput = { + targetId: "user-1", + limit: 10, + offset: 0, + currentUserId: undefined, + }; + + beforeEach(() => { + followRepo = { + getFollowers: vi.fn(), + checkIsFollowingBulk: vi.fn(), + }; + useCase = new GetFollowersUseCase(followRepo as IFollowRepository); + }); + + it("should return empty array when user has no followers", async () => { + vi.mocked(followRepo.getFollowers).mockResolvedValue([]); + + const result = await useCase.execute(baseInput); + + expect(result).toEqual([]); + expect(followRepo.checkIsFollowingBulk).not.toHaveBeenCalled(); + }); + + it("should return followers with isFollowing=false and isMe=false when no currentUserId", async () => { + vi.mocked(followRepo.getFollowers).mockResolvedValue([ + buildFollowerEntry("user-2"), + buildFollowerEntry("user-3"), + ]); + + const result = await useCase.execute(baseInput); + + expect(result).toHaveLength(2); + expect(result[0].isFollowing).toBe(false); + expect(result[0].isMe).toBe(false); + expect(followRepo.checkIsFollowingBulk).not.toHaveBeenCalled(); + }); + + it("should mark isFollowing=true for users the currentUser follows", async () => { + vi.mocked(followRepo.getFollowers).mockResolvedValue([ + buildFollowerEntry("user-2"), + buildFollowerEntry("user-3"), + ]); + vi.mocked(followRepo.checkIsFollowingBulk).mockResolvedValue([ + "user-2", + ]); + + const result = await useCase.execute({ + ...baseInput, + currentUserId: "user-1", + }); + + expect(followRepo.checkIsFollowingBulk).toHaveBeenCalledWith("user-1", [ + "user-2", + "user-3", + ]); + const user2 = result.find((r) => r.userId === "user-2"); + const user3 = result.find((r) => r.userId === "user-3"); + expect(user2?.isFollowing).toBe(true); + expect(user3?.isFollowing).toBe(false); + }); + + it("should mark isMe=true when a follower is the currentUser", async () => { + vi.mocked(followRepo.getFollowers).mockResolvedValue([ + buildFollowerEntry("user-1"), + buildFollowerEntry("user-2"), + ]); + vi.mocked(followRepo.checkIsFollowingBulk).mockResolvedValue([]); + + const result = await useCase.execute({ + ...baseInput, + currentUserId: "user-1", + }); + + const me = result.find((r) => r.userId === "user-1"); + const other = result.find((r) => r.userId === "user-2"); + expect(me?.isMe).toBe(true); + expect(other?.isMe).toBe(false); + }); +}); diff --git a/tests/unit/core/use-cases/follow-user/get-following.usecase.test.ts b/tests/unit/core/use-cases/follow-user/get-following.usecase.test.ts new file mode 100644 index 0000000..75a2e4a --- /dev/null +++ b/tests/unit/core/use-cases/follow-user/get-following.usecase.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetFollowingUseCase } from "@core/use-cases/follow-user/get-following/get-following.usecase"; +import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; + +const buildFollowingEntry = (userId: string) => ({ + userId, + username: `user_${userId}`, + fullName: `User ${userId}`, + avatarUrl: "https://example.com/avatar.png", + bio: null, +}); + +describe("GetFollowingUseCase", () => { + let useCase: GetFollowingUseCase; + let followRepo: Pick< + IFollowRepository, + "getFollowing" | "checkIsFollowingBulk" + >; + + const baseInput = { targetId: "user-1", limit: 10, offset: 0 }; + + beforeEach(() => { + followRepo = { + getFollowing: vi.fn(), + checkIsFollowingBulk: vi.fn(), + }; + useCase = new GetFollowingUseCase(followRepo as IFollowRepository); + }); + + it("should return empty array when user follows nobody", async () => { + vi.mocked(followRepo.getFollowing).mockResolvedValue([]); + + const result = await useCase.execute(baseInput); + + expect(result).toEqual([]); + expect(followRepo.checkIsFollowingBulk).not.toHaveBeenCalled(); + }); + + it("should return following list with isFollowing=false when no currentUserId", async () => { + vi.mocked(followRepo.getFollowing).mockResolvedValue([ + buildFollowingEntry("user-2"), + ]); + + const result = await useCase.execute(baseInput); + + expect(result).toHaveLength(1); + expect(result[0].isFollowing).toBe(false); + expect(result[0].isMe).toBe(false); + expect(followRepo.checkIsFollowingBulk).not.toHaveBeenCalled(); + }); + + it("should mark isFollowing=true for users currentUser also follows", async () => { + vi.mocked(followRepo.getFollowing).mockResolvedValue([ + buildFollowingEntry("user-2"), + buildFollowingEntry("user-3"), + ]); + vi.mocked(followRepo.checkIsFollowingBulk).mockResolvedValue([ + "user-3", + ]); + + const result = await useCase.execute({ + ...baseInput, + currentUserId: "user-1", + }); + + expect(followRepo.checkIsFollowingBulk).toHaveBeenCalledWith("user-1", [ + "user-2", + "user-3", + ]); + const user2 = result.find((r) => r.userId === "user-2"); + const user3 = result.find((r) => r.userId === "user-3"); + expect(user2?.isFollowing).toBe(false); + expect(user3?.isFollowing).toBe(true); + }); + + it("should mark isMe=true when a followed user is the currentUser", async () => { + vi.mocked(followRepo.getFollowing).mockResolvedValue([ + buildFollowingEntry("user-1"), + buildFollowingEntry("user-2"), + ]); + vi.mocked(followRepo.checkIsFollowingBulk).mockResolvedValue([]); + + const result = await useCase.execute({ + ...baseInput, + currentUserId: "user-1", + }); + + const me = result.find((r) => r.userId === "user-1"); + const other = result.find((r) => r.userId === "user-2"); + expect(me?.isMe).toBe(true); + expect(other?.isMe).toBe(false); + }); +}); 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 new file mode 100644 index 0000000..1e5a550 --- /dev/null +++ b/tests/unit/core/use-cases/follow-user/unfollow-user.usecase.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UnfollowUserUseCase } from "@core/use-cases/follow-user/unfollow-user/unfollow-user.usecase"; +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 { buildProfile } from "../../../helpers/mock-factories"; + +describe("UnfollowUserUseCase", () => { + let useCase: UnfollowUserUseCase; + let followRepo: Pick< + IFollowRepository, + "checkIsFollowing" | "unfollowUser" | "getFollowersCount" + >; + let profileRepo: Pick; + + beforeEach(() => { + followRepo = { + checkIsFollowing: vi.fn(), + unfollowUser: vi.fn(), + getFollowersCount: vi.fn().mockResolvedValue(10), + }; + profileRepo = { findByUserId: vi.fn() }; + + useCase = new UnfollowUserUseCase( + followRepo as IFollowRepository, + profileRepo as IProfileRepository, + ); + }); + + it("should throw NotFoundError when target profile does not exist", async () => { + vi.mocked(profileRepo.findByUserId).mockResolvedValue(null); + + await expect( + useCase.execute({ currentUserId: "user-1", targetId: "user-2" }), + ).rejects.toThrow(NotFoundError); + }); + + it("should throw BadRequestError when user tries to unfollow themselves", async () => { + vi.mocked(profileRepo.findByUserId).mockResolvedValue( + buildProfile({ userId: "user-1" }), + ); + + await expect( + useCase.execute({ currentUserId: "user-1", targetId: "user-1" }), + ).rejects.toThrow(BadRequestError); + }); + + it("should unfollow user when currently following", async () => { + vi.mocked(profileRepo.findByUserId).mockResolvedValue( + buildProfile({ userId: "user-2" }), + ); + vi.mocked(followRepo.checkIsFollowing).mockResolvedValue(true); + vi.mocked(followRepo.unfollowUser).mockResolvedValue(undefined); + + const result = await useCase.execute({ + currentUserId: "user-1", + targetId: "user-2", + }); + + expect(followRepo.unfollowUser).toHaveBeenCalledWith( + "user-1", + "user-2", + ); + expect(result.followersCount).toBe(10); + }); + + it("should skip unfollow when not currently following (idempotent)", 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(followRepo.unfollowUser).not.toHaveBeenCalled(); + }); + + it("should always return followersCount regardless of follow state", async () => { + vi.mocked(profileRepo.findByUserId).mockResolvedValue( + buildProfile({ userId: "user-2" }), + ); + vi.mocked(followRepo.checkIsFollowing).mockResolvedValue(false); + vi.mocked(followRepo.getFollowersCount).mockResolvedValue(5); + + const result = await useCase.execute({ + currentUserId: "user-1", + targetId: "user-2", + }); + + expect(followRepo.getFollowersCount).toHaveBeenCalledWith("user-2"); + expect(result.followersCount).toBe(5); + }); +}); diff --git a/tests/unit/helpers/mock-factories.ts b/tests/unit/helpers/mock-factories.ts index eeb16ac..6ef114f 100644 --- a/tests/unit/helpers/mock-factories.ts +++ b/tests/unit/helpers/mock-factories.ts @@ -6,6 +6,8 @@ import { VerificationToken } from "@core/domain/entities/verification-token.enti import type { VerificationTokenProps } from "@core/domain/interfaces/verification-token.props.interface"; import { Comment } from "@core/domain/entities/comment.entity"; import type { CommentProps } from "@core/domain/interfaces/comment-props.interface"; +import { Profile } from "@core/domain/entities/profile.entity"; +import type { ProfileProps } from "@core/domain/interfaces/profile-props.interface"; import { TokenType } from "@core/domain/enums/token-type.enum"; export function buildUser(overrides: Partial = {}): User { @@ -66,3 +68,22 @@ export function buildComment(overrides: Partial = {}): Comment { ...overrides, }); } + +export function buildProfile(overrides: Partial = {}): Profile { + return Profile.with({ + id: "profile-1", + userId: "user-1", + username: "testuser", + fullName: "Test User", + bio: null, + location: null, + avatarUrl: "https://example.com/avatar.png", + bannerUrl: "https://example.com/banner.png", + socials: null, + followersCount: 0, + followingCount: 0, + createdAt: new Date("2024-01-01T00:00:00Z"), + updatedAt: new Date("2024-01-01T00:00:00Z"), + ...overrides, + }); +} From c58787c3a67aff1e7f5bd434502e0203a4988161 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 May 2026 19:27:26 +0300 Subject: [PATCH 27/29] =?UTF-8?q?refactor(notification):=20fix=20typo=20in?= =?UTF-8?q?=20GetUserNotificatonUseCase=20=E2=86=92=20GetUserNotificationU?= =?UTF-8?q?seCase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/get-user/get-user-notification.usecase.ts | 2 +- src/http/controllers/notification.controller.ts | 4 ++-- src/http/plugins/di/use-cases.di.ts | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/core/use-cases/notification/get-user/get-user-notification.usecase.ts b/src/core/use-cases/notification/get-user/get-user-notification.usecase.ts index e25571a..c13d98f 100644 --- a/src/core/use-cases/notification/get-user/get-user-notification.usecase.ts +++ b/src/core/use-cases/notification/get-user/get-user-notification.usecase.ts @@ -7,7 +7,7 @@ import type { GetNotificationsOutput } from "./get-notifications-usecase.output" * * This use case handles fetching notifications for a specific user. */ -export class GetUserNotificatonUseCase { +export class GetUserNotificationUseCase { /** * Creates a new instance of GetUserNotificatonUseCase. * diff --git a/src/http/controllers/notification.controller.ts b/src/http/controllers/notification.controller.ts index ca605f2..832d00e 100644 --- a/src/http/controllers/notification.controller.ts +++ b/src/http/controllers/notification.controller.ts @@ -1,12 +1,12 @@ import type { FastifyReply, FastifyRequest } from "fastify"; -import type { GetUserNotificatonUseCase } from "@core/use-cases/notification/get-user"; +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 { NotificationPrismaMapper } from "@infrastructure/persistence/mappers/notification-prisma.mapper"; export class NotificationController { constructor( - private readonly getUserNotificationsUseCase: GetUserNotificatonUseCase, + private readonly getUserNotificationsUseCase: GetUserNotificationUseCase, private readonly markAllReadUseCase: MarkAllNotificationsAsReadUseCase, ) {} diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index dee451c..3354c06 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -28,7 +28,7 @@ import { FollowUserUseCase } from "@core/use-cases/follow-user/follow-user"; import { UnfollowUserUseCase } from "@core/use-cases/follow-user/unfollow-user"; import { GetFollowersUseCase } from "@core/use-cases/follow-user/get-followers"; import { GetFollowingUseCase } from "@core/use-cases/follow-user/get-following"; -import { GetUserNotificatonUseCase } from "@core/use-cases/notification/get-user"; +import { GetUserNotificationUseCase } from "@core/use-cases/notification/get-user"; import { MarkAllNotificationsAsReadUseCase } from "@core/use-cases/notification/mark-all"; import { PurgeExpiredNotificationsUseCase } from "@core/use-cases/notification/purge-expired"; import { CreatePostUseCase } from "@core/use-cases/post/create-post"; @@ -275,7 +275,9 @@ export const useCasesModule = { /** * Use case for getting user notifications */ - getUserNotificationsUseCase: asClass(GetUserNotificatonUseCase).singleton(), + getUserNotificationsUseCase: asClass( + GetUserNotificationUseCase, + ).singleton(), /** * Use case for marking all notifications as read From 8c54d9f46de20692aaa7a3aa7185d329a2ff6554 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 May 2026 19:48:17 +0300 Subject: [PATCH 28/29] test(notification): add unit tests for GetUserNotificationUseCase - Add buildNotification factory to mock-factories - Add 5 unit tests: empty result, data return, skip calculation (page 1 & paginated), parallel calls --- .../get-user-notification.usecase.test.ts | 105 ++++++++++++++++++ tests/unit/helpers/mock-factories.ts | 19 ++++ 2 files changed, 124 insertions(+) create mode 100644 tests/unit/core/use-cases/notification/get-user-notification.usecase.test.ts diff --git a/tests/unit/core/use-cases/notification/get-user-notification.usecase.test.ts b/tests/unit/core/use-cases/notification/get-user-notification.usecase.test.ts new file mode 100644 index 0000000..00c0482 --- /dev/null +++ b/tests/unit/core/use-cases/notification/get-user-notification.usecase.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetUserNotificationUseCase } from "@core/use-cases/notification/get-user"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import { buildNotification } from "../../../helpers/mock-factories"; + +describe("GetUserNotificationUseCase", () => { + let useCase: GetUserNotificationUseCase; + let notificationRepo: Pick< + INotificationRepository, + "findAllByUserId" | "countByUserId" + >; + + beforeEach(() => { + notificationRepo = { + findAllByUserId: vi.fn(), + countByUserId: vi.fn(), + }; + useCase = new GetUserNotificationUseCase( + notificationRepo as INotificationRepository, + ); + }); + + it("should return empty notifications when user has none", async () => { + vi.mocked(notificationRepo.findAllByUserId).mockResolvedValue([]); + vi.mocked(notificationRepo.countByUserId).mockResolvedValue(0); + + const result = await useCase.execute({ + userId: "user-1", + page: 1, + limit: 10, + }); + + expect(result.notifications).toEqual([]); + expect(result.total).toBe(0); + }); + + it("should return notifications and total for the given user", async () => { + const notifications = [ + buildNotification({ recipientId: "user-1" }), + buildNotification({ recipientId: "user-1" }), + ]; + vi.mocked(notificationRepo.findAllByUserId).mockResolvedValue( + notifications, + ); + vi.mocked(notificationRepo.countByUserId).mockResolvedValue(2); + + const result = await useCase.execute({ + userId: "user-1", + page: 1, + limit: 10, + }); + + expect(result.notifications).toBe(notifications); + expect(result.total).toBe(2); + }); + + it("should calculate skip correctly for page 1", async () => { + vi.mocked(notificationRepo.findAllByUserId).mockResolvedValue([]); + vi.mocked(notificationRepo.countByUserId).mockResolvedValue(0); + + await useCase.execute({ userId: "user-1", page: 1, limit: 10 }); + + expect(notificationRepo.findAllByUserId).toHaveBeenCalledWith({ + userId: "user-1", + take: 10, + skip: 0, + }); + }); + + it("should calculate skip correctly for subsequent pages", async () => { + vi.mocked(notificationRepo.findAllByUserId).mockResolvedValue([]); + vi.mocked(notificationRepo.countByUserId).mockResolvedValue(0); + + await useCase.execute({ userId: "user-1", page: 3, limit: 5 }); + + expect(notificationRepo.findAllByUserId).toHaveBeenCalledWith({ + userId: "user-1", + take: 5, + skip: 10, + }); + }); + + it("should call countByUserId and findAllByUserId in parallel", async () => { + const order: string[] = []; + + vi.mocked(notificationRepo.findAllByUserId).mockImplementation( + async () => { + order.push("findAll"); + return []; + }, + ); + vi.mocked(notificationRepo.countByUserId).mockImplementation( + async () => { + order.push("count"); + return 0; + }, + ); + + await useCase.execute({ userId: "user-1", page: 1, limit: 10 }); + + expect(order).toContain("findAll"); + expect(order).toContain("count"); + expect(notificationRepo.countByUserId).toHaveBeenCalledWith("user-1"); + }); +}); diff --git a/tests/unit/helpers/mock-factories.ts b/tests/unit/helpers/mock-factories.ts index 6ef114f..9f95c68 100644 --- a/tests/unit/helpers/mock-factories.ts +++ b/tests/unit/helpers/mock-factories.ts @@ -8,7 +8,10 @@ import { Comment } from "@core/domain/entities/comment.entity"; import type { CommentProps } from "@core/domain/interfaces/comment-props.interface"; import { Profile } from "@core/domain/entities/profile.entity"; import type { ProfileProps } from "@core/domain/interfaces/profile-props.interface"; +import { Notification } from "@core/domain/entities/notification.entity"; +import type { NotificationProps } from "@core/domain/interfaces/notification-props.interface"; import { TokenType } from "@core/domain/enums/token-type.enum"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; export function buildUser(overrides: Partial = {}): User { return User.with({ @@ -87,3 +90,19 @@ export function buildProfile(overrides: Partial = {}): Profile { ...overrides, }); } + +export function buildNotification( + overrides: Partial = {}, +): Notification { + return Notification.with({ + recipientId: "user-1", + issuerId: "user-2", + type: NotificationType.FOLLOW, + referenceId: undefined, + username: "issuer", + avatarUrl: undefined, + createdAt: new Date("2024-01-01T00:00:00Z"), + isRead: false, + ...overrides, + }); +} From 9e8c0e61ae47c43f9d50d6b73ddff79d55a3b317 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 7 May 2026 17:58:01 +0300 Subject: [PATCH 29/29] test: add unit tests for notification, oauth, tag, translate and post use-cases --- ...-all-notifications-as-read.usecase.test.ts | 43 +++++ ...urge-expired-notifications.usecase.test.ts | 80 ++++++++++ .../oauth/github-login.usecase.test.ts | 145 +++++++++++++++++ .../oauth/google-login.usecase.test.ts | 147 ++++++++++++++++++ .../oauth/oauth-exchange.usecase.test.ts | 138 ++++++++++++++++ .../post/create-post.usecase.test.ts | 101 ++++++++++++ .../post/delete-post.usecase.test.ts | 118 ++++++++++++++ .../post/get-post-detail.usecase.test.ts | 41 +++++ .../use-cases/post/get-posts.usecase.test.ts | 129 +++++++++++++++ .../use-cases/post/get-trends.usecase.test.ts | 82 ++++++++++ .../post/get-user-posts.usecase.test.ts | 78 ++++++++++ .../use-cases/post/like-post.usecase.test.ts | 136 ++++++++++++++++ .../post/unlike-post.usecase.test.ts | 107 +++++++++++++ .../post/upload-post-media.usecase.test.ts | 77 +++++++++ .../use-cases/tag/search-tag.usecase.test.ts | 73 +++++++++ .../translate/translate.usecase.test.ts | 94 +++++++++++ tests/unit/helpers/mock-factories.ts | 25 +++ 17 files changed, 1614 insertions(+) create mode 100644 tests/unit/core/use-cases/notification/mark-all-notifications-as-read.usecase.test.ts create mode 100644 tests/unit/core/use-cases/notification/purge-expired-notifications.usecase.test.ts create mode 100644 tests/unit/core/use-cases/oauth/github-login.usecase.test.ts create mode 100644 tests/unit/core/use-cases/oauth/google-login.usecase.test.ts create mode 100644 tests/unit/core/use-cases/oauth/oauth-exchange.usecase.test.ts create mode 100644 tests/unit/core/use-cases/post/create-post.usecase.test.ts create mode 100644 tests/unit/core/use-cases/post/delete-post.usecase.test.ts create mode 100644 tests/unit/core/use-cases/post/get-post-detail.usecase.test.ts create mode 100644 tests/unit/core/use-cases/post/get-posts.usecase.test.ts create mode 100644 tests/unit/core/use-cases/post/get-trends.usecase.test.ts create mode 100644 tests/unit/core/use-cases/post/get-user-posts.usecase.test.ts create mode 100644 tests/unit/core/use-cases/post/like-post.usecase.test.ts create mode 100644 tests/unit/core/use-cases/post/unlike-post.usecase.test.ts create mode 100644 tests/unit/core/use-cases/post/upload-post-media.usecase.test.ts create mode 100644 tests/unit/core/use-cases/tag/search-tag.usecase.test.ts create mode 100644 tests/unit/core/use-cases/translate/translate.usecase.test.ts diff --git a/tests/unit/core/use-cases/notification/mark-all-notifications-as-read.usecase.test.ts b/tests/unit/core/use-cases/notification/mark-all-notifications-as-read.usecase.test.ts new file mode 100644 index 0000000..5e834a3 --- /dev/null +++ b/tests/unit/core/use-cases/notification/mark-all-notifications-as-read.usecase.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MarkAllNotificationsAsReadUseCase } from "@core/use-cases/notification/mark-all"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; + +describe("MarkAllNotificationsAsReadUseCase", () => { + let useCase: MarkAllNotificationsAsReadUseCase; + let notificationRepo: Pick; + + beforeEach(() => { + notificationRepo = { + markAllAsRead: vi.fn(), + }; + useCase = new MarkAllNotificationsAsReadUseCase( + notificationRepo as INotificationRepository, + ); + }); + + it("should call markAllAsRead with the correct userId", async () => { + vi.mocked(notificationRepo.markAllAsRead).mockResolvedValue(undefined); + + await useCase.execute({ userId: "user-1" }); + + expect(notificationRepo.markAllAsRead).toHaveBeenCalledOnce(); + expect(notificationRepo.markAllAsRead).toHaveBeenCalledWith("user-1"); + }); + + it("should resolve without returning a value", async () => { + vi.mocked(notificationRepo.markAllAsRead).mockResolvedValue(undefined); + + const result = await useCase.execute({ userId: "user-1" }); + + expect(result).toBeUndefined(); + }); + + it("should propagate repository errors", async () => { + const repoError = new Error("Database connection lost"); + vi.mocked(notificationRepo.markAllAsRead).mockRejectedValue(repoError); + + await expect(useCase.execute({ userId: "user-1" })).rejects.toThrow( + "Database connection lost", + ); + }); +}); diff --git a/tests/unit/core/use-cases/notification/purge-expired-notifications.usecase.test.ts b/tests/unit/core/use-cases/notification/purge-expired-notifications.usecase.test.ts new file mode 100644 index 0000000..5253a40 --- /dev/null +++ b/tests/unit/core/use-cases/notification/purge-expired-notifications.usecase.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PurgeExpiredNotificationsUseCase } from "@core/use-cases/notification/purge-expired"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; + +describe("PurgeExpiredNotificationsUseCase", () => { + let useCase: PurgeExpiredNotificationsUseCase; + let notificationRepo: Pick< + INotificationRepository, + "deleteExpiredNotifications" + >; + + beforeEach(() => { + vi.useFakeTimers(); + notificationRepo = { + deleteExpiredNotifications: vi.fn(), + }; + useCase = new PurgeExpiredNotificationsUseCase( + notificationRepo as INotificationRepository, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should call deleteExpiredNotifications with the correct cutoff date", async () => { + const now = new Date("2026-05-07T12:00:00.000Z"); + vi.setSystemTime(now); + + const expectedCutoff = new Date("2026-05-07T12:00:00.000Z"); + expectedCutoff.setDate(expectedCutoff.getDate() - 30); + + vi.mocked( + notificationRepo.deleteExpiredNotifications, + ).mockResolvedValue(5); + + await useCase.execute(30); + + expect( + notificationRepo.deleteExpiredNotifications, + ).toHaveBeenCalledOnce(); + expect( + notificationRepo.deleteExpiredNotifications, + ).toHaveBeenCalledWith(expectedCutoff); + }); + + it("should return the count of deleted notifications", async () => { + vi.setSystemTime(new Date("2026-05-07T12:00:00.000Z")); + vi.mocked( + notificationRepo.deleteExpiredNotifications, + ).mockResolvedValue(42); + + const result = await useCase.execute(7); + + expect(result).toBe(42); + }); + + it("should return 0 when no notifications are deleted", async () => { + vi.setSystemTime(new Date("2026-05-07T12:00:00.000Z")); + vi.mocked( + notificationRepo.deleteExpiredNotifications, + ).mockResolvedValue(0); + + const result = await useCase.execute(7); + + expect(result).toBe(0); + }); + + it("should propagate repository errors", async () => { + vi.setSystemTime(new Date("2026-05-07T12:00:00.000Z")); + const repoError = new Error("Database connection lost"); + vi.mocked( + notificationRepo.deleteExpiredNotifications, + ).mockRejectedValue(repoError); + + await expect(useCase.execute(30)).rejects.toThrow( + "Database connection lost", + ); + }); +}); diff --git a/tests/unit/core/use-cases/oauth/github-login.usecase.test.ts b/tests/unit/core/use-cases/oauth/github-login.usecase.test.ts new file mode 100644 index 0000000..5bd9c73 --- /dev/null +++ b/tests/unit/core/use-cases/oauth/github-login.usecase.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GithubLoginUseCase } from "@core/use-cases/oauth/oauth-github"; +import type { GithubAuthPort } from "@core/ports/services/github-auth.port"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { AuthTokenPort } from "@core/ports/services/auth-token.port"; +import type { CryptoPort } from "@core/ports/services/crypto.port"; +import type { CachePort } from "@core/ports/services/cache.port"; +import { AccountPendingDeletionError } from "@core/errors"; +import { buildUser } from "../../../helpers/mock-factories"; + +const mockProfile = { + providerAccountId: "gh-123", + username: "ghuser", + email: "ghuser@example.com", + isEmailVerified: true, +}; + +describe("GithubLoginUseCase", () => { + let useCase: GithubLoginUseCase; + let githubAuthService: Pick; + let userRepository: Pick< + IUserRepository, + "findByEmail" | "findByUsername" | "createWithOAuth" + >; + let authTokenService: Pick; + let cryptoService: Pick; + let cacheService: Pick; + + beforeEach(() => { + githubAuthService = { + getUserProfileByCode: vi.fn().mockResolvedValue(mockProfile), + }; + userRepository = { + findByEmail: vi.fn().mockResolvedValue(null), + findByUsername: vi.fn().mockResolvedValue(null), + createWithOAuth: vi + .fn() + .mockResolvedValue( + buildUser({ username: "ghuser", isEmailVerified: true }), + ), + }; + authTokenService = { + generateRecoveryToken: vi.fn().mockReturnValue("recovery-token"), + }; + cryptoService = { + generateRandomHex: vi.fn().mockReturnValue("ab12"), + }; + cacheService = { + set: vi.fn().mockResolvedValue(undefined), + }; + useCase = new GithubLoginUseCase( + githubAuthService as GithubAuthPort, + userRepository as IUserRepository, + authTokenService as AuthTokenPort, + cryptoService as CryptoPort, + cacheService as CachePort, + ); + }); + + it("should throw AccountPendingDeletionError when user is soft-deleted", async () => { + const deletedUser = buildUser({ deletedAt: new Date() }); + vi.mocked(userRepository.findByEmail).mockResolvedValue(deletedUser); + + await expect(useCase.execute({ code: "auth-code" })).rejects.toThrow( + AccountPendingDeletionError, + ); + + expect(authTokenService.generateRecoveryToken).toHaveBeenCalledWith( + deletedUser.id, + ); + }); + + it("should return exchangeCode for existing active user", async () => { + const existingUser = buildUser({ username: "ghuser" }); + vi.mocked(userRepository.findByEmail).mockResolvedValue(existingUser); + vi.mocked(cryptoService.generateRandomHex).mockReturnValue( + "exchange-hex-code", + ); + + const result = await useCase.execute({ code: "auth-code" }); + + expect(result).toHaveProperty("exchangeCode"); + expect(userRepository.createWithOAuth).not.toHaveBeenCalled(); + }); + + it("should create new user when no account found for email", async () => { + vi.mocked(userRepository.findByEmail).mockResolvedValue(null); + vi.mocked(userRepository.findByUsername).mockResolvedValue(null); + vi.mocked(cryptoService.generateRandomHex).mockReturnValue( + "exchange-hex-code", + ); + + await useCase.execute({ code: "auth-code" }); + + expect(userRepository.createWithOAuth).toHaveBeenCalledOnce(); + expect(userRepository.createWithOAuth).toHaveBeenCalledWith( + expect.objectContaining({ + email: mockProfile.email, + username: mockProfile.username, + provider: "github", + providerAccountId: mockProfile.providerAccountId, + isEmailVerified: mockProfile.isEmailVerified, + }), + ); + }); + + it("should append random suffix when username is already taken", async () => { + vi.mocked(userRepository.findByEmail).mockResolvedValue(null); + vi.mocked(userRepository.findByUsername).mockResolvedValue( + buildUser({ username: "ghuser" }), + ); + vi.mocked(cryptoService.generateRandomHex) + .mockReturnValueOnce("ab12") + .mockReturnValueOnce("exchange-hex-code"); + + await useCase.execute({ code: "auth-code" }); + + expect(userRepository.createWithOAuth).toHaveBeenCalledWith( + expect.objectContaining({ username: "ghuser_ab12" }), + ); + }); + + it("should store payload in cache with correct key and TTL", async () => { + const existingUser = buildUser({ + id: "user-1", + username: "ghuser", + isEmailVerified: true, + }); + vi.mocked(userRepository.findByEmail).mockResolvedValue(existingUser); + vi.mocked(cryptoService.generateRandomHex).mockReturnValue("deadbeef"); + + await useCase.execute({ code: "auth-code" }); + + expect(cacheService.set).toHaveBeenCalledOnce(); + expect(cacheService.set).toHaveBeenCalledWith( + "oauth:exchange:deadbeef", + JSON.stringify({ + userId: existingUser.id, + username: existingUser.username, + isEmailVerified: existingUser.isEmailVerified, + }), + 60, + ); + }); +}); diff --git a/tests/unit/core/use-cases/oauth/google-login.usecase.test.ts b/tests/unit/core/use-cases/oauth/google-login.usecase.test.ts new file mode 100644 index 0000000..87727ba --- /dev/null +++ b/tests/unit/core/use-cases/oauth/google-login.usecase.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GoogleLoginUseCase } from "@core/use-cases/oauth/oauth-google"; +import type { GoogleAuthPort } from "@core/ports/services/google-auth.port"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { AuthTokenPort } from "@core/ports/services/auth-token.port"; +import type { CryptoPort } from "@core/ports/services/crypto.port"; +import type { CachePort } from "@core/ports/services/cache.port"; +import { AccountPendingDeletionError } from "@core/errors"; +import { buildUser } from "../../../helpers/mock-factories"; + +const mockProfile = { + providerAccountId: "google-456", + username: "googleuser", + email: "googleuser@example.com", +}; + +describe("GoogleLoginUseCase", () => { + let useCase: GoogleLoginUseCase; + let googleAuthService: Pick; + let userRepository: Pick< + IUserRepository, + "findByEmail" | "findByUsername" | "createWithOAuth" + >; + let authTokenService: Pick; + let cryptoService: Pick; + let cacheService: Pick; + + beforeEach(() => { + googleAuthService = { + getUserProfileByCode: vi.fn().mockResolvedValue(mockProfile), + }; + userRepository = { + findByEmail: vi.fn().mockResolvedValue(null), + findByUsername: vi.fn().mockResolvedValue(null), + createWithOAuth: vi + .fn() + .mockResolvedValue( + buildUser({ + username: "googleuser", + isEmailVerified: true, + }), + ), + }; + authTokenService = { + generateRecoveryToken: vi.fn().mockReturnValue("recovery-token"), + }; + cryptoService = { + generateRandomHex: vi.fn().mockReturnValue("cd34"), + }; + cacheService = { + set: vi.fn().mockResolvedValue(undefined), + }; + useCase = new GoogleLoginUseCase( + googleAuthService as GoogleAuthPort, + userRepository as IUserRepository, + authTokenService as AuthTokenPort, + cryptoService as CryptoPort, + cacheService as CachePort, + ); + }); + + it("should throw AccountPendingDeletionError when user is soft-deleted", async () => { + const deletedUser = buildUser({ deletedAt: new Date() }); + vi.mocked(userRepository.findByEmail).mockResolvedValue(deletedUser); + + await expect(useCase.execute({ code: "auth-code" })).rejects.toThrow( + AccountPendingDeletionError, + ); + + expect(authTokenService.generateRecoveryToken).toHaveBeenCalledWith( + deletedUser.id, + ); + }); + + it("should return exchangeCode for existing active user", async () => { + const existingUser = buildUser({ username: "googleuser" }); + vi.mocked(userRepository.findByEmail).mockResolvedValue(existingUser); + vi.mocked(cryptoService.generateRandomHex).mockReturnValue( + "exchange-hex-code", + ); + + const result = await useCase.execute({ code: "auth-code" }); + + expect(result).toHaveProperty("exchangeCode"); + expect(userRepository.createWithOAuth).not.toHaveBeenCalled(); + }); + + it("should create new user with isEmailVerified always true", async () => { + vi.mocked(userRepository.findByEmail).mockResolvedValue(null); + vi.mocked(userRepository.findByUsername).mockResolvedValue(null); + vi.mocked(cryptoService.generateRandomHex).mockReturnValue( + "exchange-hex-code", + ); + + await useCase.execute({ code: "auth-code" }); + + expect(userRepository.createWithOAuth).toHaveBeenCalledOnce(); + expect(userRepository.createWithOAuth).toHaveBeenCalledWith( + expect.objectContaining({ + email: mockProfile.email, + username: mockProfile.username, + provider: "google", + providerAccountId: mockProfile.providerAccountId, + isEmailVerified: true, + }), + ); + }); + + it("should append random suffix when username is already taken", async () => { + vi.mocked(userRepository.findByEmail).mockResolvedValue(null); + vi.mocked(userRepository.findByUsername).mockResolvedValue( + buildUser({ username: "googleuser" }), + ); + vi.mocked(cryptoService.generateRandomHex) + .mockReturnValueOnce("cd34") + .mockReturnValueOnce("exchange-hex-code"); + + await useCase.execute({ code: "auth-code" }); + + expect(userRepository.createWithOAuth).toHaveBeenCalledWith( + expect.objectContaining({ username: "googleuser_cd34" }), + ); + }); + + it("should store payload in cache with correct key and TTL", async () => { + const existingUser = buildUser({ + id: "user-2", + username: "googleuser", + isEmailVerified: true, + }); + vi.mocked(userRepository.findByEmail).mockResolvedValue(existingUser); + vi.mocked(cryptoService.generateRandomHex).mockReturnValue("cafebabe"); + + await useCase.execute({ code: "auth-code" }); + + expect(cacheService.set).toHaveBeenCalledOnce(); + expect(cacheService.set).toHaveBeenCalledWith( + "oauth:exchange:cafebabe", + JSON.stringify({ + userId: existingUser.id, + username: existingUser.username, + isEmailVerified: existingUser.isEmailVerified, + }), + 60, + ); + }); +}); diff --git a/tests/unit/core/use-cases/oauth/oauth-exchange.usecase.test.ts b/tests/unit/core/use-cases/oauth/oauth-exchange.usecase.test.ts new file mode 100644 index 0000000..80b1a01 --- /dev/null +++ b/tests/unit/core/use-cases/oauth/oauth-exchange.usecase.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { OAuthExchangeUseCase } from "@core/use-cases/oauth/oauth-exchange"; +import type { CachePort } from "@core/ports/services/cache.port"; +import type { AuthTokenPort } from "@core/ports/services/auth-token.port"; +import type { IRefreshTokenRepository } from "@core/ports/repositories/refresh-token.repository"; +import { UnauthorizedError } from "@core/errors"; +import { buildRefreshToken } from "../../../helpers/mock-factories"; + +const mockPayload = { + userId: "user-1", + username: "testuser", + isEmailVerified: true, +}; + +const mockTokenResult = { + accessToken: "access-token", + expiresAt: 9999999999, + refreshToken: "refresh-token", + refreshTokenExpiresAt: 9999999999, +}; + +describe("OAuthExchangeUseCase", () => { + let useCase: OAuthExchangeUseCase; + let cacheService: Pick; + let authTokenService: Pick; + let refreshTokenRepository: Pick; + + beforeEach(() => { + cacheService = { + get: vi.fn(), + delete: vi.fn().mockResolvedValue(undefined), + }; + authTokenService = { + generate: vi.fn().mockReturnValue(mockTokenResult), + hashRefreshSecret: vi.fn().mockReturnValue("hashed-refresh-token"), + }; + refreshTokenRepository = { + create: vi.fn().mockResolvedValue(buildRefreshToken()), + }; + useCase = new OAuthExchangeUseCase( + cacheService as CachePort, + authTokenService as AuthTokenPort, + refreshTokenRepository as IRefreshTokenRepository, + ); + }); + + it("should throw UnauthorizedError when code is not found in cache", async () => { + vi.mocked(cacheService.get).mockResolvedValue(null); + + await expect( + useCase.execute({ + code: "invalid-code", + deviceIp: "127.0.0.1", + userAgent: "Mozilla/5.0", + }), + ).rejects.toThrow(UnauthorizedError); + }); + + it("should delete cache key immediately after reading (single-use)", async () => { + vi.mocked(cacheService.get).mockResolvedValue( + JSON.stringify(mockPayload), + ); + + await useCase.execute({ + code: "valid-code", + deviceIp: "127.0.0.1", + userAgent: "Mozilla/5.0", + }); + + expect(cacheService.delete).toHaveBeenCalledOnce(); + expect(cacheService.delete).toHaveBeenCalledWith( + "oauth:exchange:valid-code", + ); + }); + + it("should create refresh token with correct data from input and payload", async () => { + vi.mocked(cacheService.get).mockResolvedValue( + JSON.stringify(mockPayload), + ); + + await useCase.execute({ + code: "valid-code", + deviceIp: "10.0.0.1", + userAgent: "TestAgent/1.0", + }); + + expect(refreshTokenRepository.create).toHaveBeenCalledOnce(); + expect(refreshTokenRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + tokenHash: "hashed-refresh-token", + userId: "user-1", + deviceIp: "10.0.0.1", + userAgent: "TestAgent/1.0", + }), + ); + }); + + it("should return correct LoginOutput with user and token data", async () => { + vi.mocked(cacheService.get).mockResolvedValue( + JSON.stringify(mockPayload), + ); + + const result = await useCase.execute({ + code: "valid-code", + deviceIp: "127.0.0.1", + userAgent: "Mozilla/5.0", + }); + + expect(result.user).toEqual({ + id: "user-1", + username: "testuser", + isEmailVerified: true, + }); + expect(result.tokens).toEqual({ + accessToken: "access-token", + expiresAt: 9999999999, + refreshToken: "refresh-token", + refreshTokenExpiresAt: 9999999999, + }); + }); + + it("should propagate repository errors when creating refresh token", async () => { + vi.mocked(cacheService.get).mockResolvedValue( + JSON.stringify(mockPayload), + ); + vi.mocked(refreshTokenRepository.create).mockRejectedValue( + new Error("Database error"), + ); + + await expect( + useCase.execute({ + code: "valid-code", + deviceIp: "127.0.0.1", + userAgent: "Mozilla/5.0", + }), + ).rejects.toThrow("Database error"); + }); +}); diff --git a/tests/unit/core/use-cases/post/create-post.usecase.test.ts b/tests/unit/core/use-cases/post/create-post.usecase.test.ts new file mode 100644 index 0000000..cba6416 --- /dev/null +++ b/tests/unit/core/use-cases/post/create-post.usecase.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CreatePostUseCase } from "@core/use-cases/post/create-post"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { CachePort } from "@core/ports/services/cache.port"; +import { NotFoundError } from "@core/errors/common/not-found.error"; +import { ForbiddenError } from "@core/errors/common/forbidden.error"; +import { PostType } from "@core/domain/enums/post-type.enum"; +import { buildUser, buildPost } from "../../../helpers/mock-factories"; + +describe("CreatePostUseCase", () => { + let useCase: CreatePostUseCase; + let postRepository: Pick; + let userRepository: Pick; + let cacheService: Pick; + + beforeEach(() => { + postRepository = { + create: vi.fn().mockResolvedValue(buildPost()), + }; + userRepository = { + findById: vi.fn(), + }; + cacheService = { + deleteByPattern: vi.fn().mockResolvedValue(undefined), + }; + useCase = new CreatePostUseCase( + postRepository as IPostRepository, + cacheService as CachePort, + userRepository as IUserRepository, + ); + }); + + it("should create and return post for COMMUNITY type without user lookup", async () => { + const created = buildPost({ type: PostType.COMMUNITY }); + vi.mocked(postRepository.create).mockResolvedValue(created); + + const result = await useCase.execute({ + content: "Hello world", + type: PostType.COMMUNITY, + authorId: "user-1", + }); + + expect(result).toBe(created); + expect(userRepository.findById).not.toHaveBeenCalled(); + }); + + it("should throw NotFoundError when author not found for SYSTEM_UPDATE type", async () => { + vi.mocked(userRepository.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ + content: "System update", + type: PostType.SYSTEM_UPDATE, + authorId: "ghost-user", + }), + ).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenError when non-bot creates TECH_NEWS", async () => { + const nonBotUser = buildUser({ isBot: false }); + vi.mocked(userRepository.findById).mockResolvedValue(nonBotUser); + + await expect( + useCase.execute({ + content: "Tech news", + type: PostType.TECH_NEWS, + authorId: "user-1", + }), + ).rejects.toThrow(ForbiddenError); + }); + + it("should allow bot user to create SYSTEM_UPDATE", async () => { + const botUser = buildUser({ isBot: true }); + vi.mocked(userRepository.findById).mockResolvedValue(botUser); + const created = buildPost({ type: PostType.SYSTEM_UPDATE }); + vi.mocked(postRepository.create).mockResolvedValue(created); + + const result = await useCase.execute({ + content: "System update", + type: PostType.SYSTEM_UPDATE, + authorId: "bot-1", + }); + + expect(result).toBe(created); + }); + + it("should invalidate posts:feed:* cache after creation", async () => { + vi.mocked(postRepository.create).mockResolvedValue(buildPost()); + + await useCase.execute({ + content: "Hello", + type: PostType.COMMUNITY, + authorId: "user-1", + }); + + expect(cacheService.deleteByPattern).toHaveBeenCalledWith( + "posts:feed:*", + ); + }); +}); diff --git a/tests/unit/core/use-cases/post/delete-post.usecase.test.ts b/tests/unit/core/use-cases/post/delete-post.usecase.test.ts new file mode 100644 index 0000000..1c5cda6 --- /dev/null +++ b/tests/unit/core/use-cases/post/delete-post.usecase.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DeletePostUseCase } from "@core/use-cases/post/delete-post"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { StoragePort } from "@core/ports/services/storage.port"; +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { CachePort } from "@core/ports/services/cache.port"; +import { NotFoundError } from "@core/errors"; +import { UnauthorizedActionError } from "@core/errors"; +import { buildPost } from "../../../helpers/mock-factories"; + +describe("DeletePostUseCase", () => { + let useCase: DeletePostUseCase; + let postRepository: Pick; + let storageService: Pick; + let logger: LoggerPort; + let cacheService: Pick; + + beforeEach(() => { + postRepository = { + findById: vi.fn(), + delete: vi.fn().mockResolvedValue(undefined), + }; + storageService = { + delete: vi.fn().mockResolvedValue(undefined), + }; + logger = { + error: vi.fn(), + }; + cacheService = { + deleteByPattern: vi.fn().mockResolvedValue(undefined), + }; + useCase = new DeletePostUseCase( + postRepository as IPostRepository, + storageService as StoragePort, + logger, + cacheService as CachePort, + ); + }); + + it("should throw NotFoundError when post not found", async () => { + vi.mocked(postRepository.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ + postId: "ghost-post", + userId: "user-1", + cdnBaseUrl: "https://cdn.example.com", + }), + ).rejects.toThrow(NotFoundError); + }); + + it("should throw UnauthorizedActionError when user is not the author", async () => { + const post = buildPost({ author: { id: "author-1" } }); + vi.mocked(postRepository.findById).mockResolvedValue(post); + + await expect( + useCase.execute({ + postId: "post-1", + userId: "other-user", + cdnBaseUrl: "https://cdn.example.com", + }), + ).rejects.toThrow(UnauthorizedActionError); + }); + + it("should delete post and invalidate cache", async () => { + const post = buildPost({ author: { id: "user-1" } }); + vi.mocked(postRepository.findById).mockResolvedValue(post); + + await useCase.execute({ + postId: "post-1", + userId: "user-1", + cdnBaseUrl: "https://cdn.example.com", + }); + + expect(cacheService.deleteByPattern).toHaveBeenCalledWith( + "posts:feed:*", + ); + expect(postRepository.delete).toHaveBeenCalledWith("post-1"); + }); + + it("should delete media files when post has media", async () => { + const post = buildPost({ + author: { id: "user-1" }, + mediaUrls: ["https://cdn.example.com/posts/user-1/img.jpg"], + }); + vi.mocked(postRepository.findById).mockResolvedValue(post); + + await useCase.execute({ + postId: "post-1", + userId: "user-1", + cdnBaseUrl: "https://cdn.example.com", + }); + + expect(storageService.delete).toHaveBeenCalledWith( + "posts/user-1/img.jpg", + ); + }); + + it("should continue deletion even if storage delete fails", async () => { + const post = buildPost({ + author: { id: "user-1" }, + mediaUrls: ["https://cdn.example.com/posts/user-1/img.jpg"], + }); + vi.mocked(postRepository.findById).mockResolvedValue(post); + vi.mocked(storageService.delete).mockRejectedValue( + new Error("S3 unavailable"), + ); + + await useCase.execute({ + postId: "post-1", + userId: "user-1", + cdnBaseUrl: "https://cdn.example.com", + }); + + expect(logger.error).toHaveBeenCalledOnce(); + expect(postRepository.delete).toHaveBeenCalledWith("post-1"); + }); +}); diff --git a/tests/unit/core/use-cases/post/get-post-detail.usecase.test.ts b/tests/unit/core/use-cases/post/get-post-detail.usecase.test.ts new file mode 100644 index 0000000..4087cf6 --- /dev/null +++ b/tests/unit/core/use-cases/post/get-post-detail.usecase.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetPostDetailUseCase } from "@core/use-cases/post/get-post-detail"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import { NotFoundError } from "@core/errors"; +import { buildPost } from "../../../helpers/mock-factories"; + +describe("GetPostDetailUseCase", () => { + let useCase: GetPostDetailUseCase; + let postRepository: Pick; + + beforeEach(() => { + postRepository = { + findById: vi.fn(), + }; + useCase = new GetPostDetailUseCase(postRepository as IPostRepository); + }); + + it("should return post when found", async () => { + const post = buildPost({ id: "post-42" }); + vi.mocked(postRepository.findById).mockResolvedValue(post); + + const result = await useCase.execute({ + postId: "post-42", + userId: "user-1", + }); + + expect(result).toBe(post); + expect(postRepository.findById).toHaveBeenCalledWith( + "post-42", + "user-1", + ); + }); + + it("should throw NotFoundError when post not found", async () => { + vi.mocked(postRepository.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ postId: "ghost-post", userId: undefined }), + ).rejects.toThrow(NotFoundError); + }); +}); diff --git a/tests/unit/core/use-cases/post/get-posts.usecase.test.ts b/tests/unit/core/use-cases/post/get-posts.usecase.test.ts new file mode 100644 index 0000000..e81fb26 --- /dev/null +++ b/tests/unit/core/use-cases/post/get-posts.usecase.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetPostsUseCase } from "@core/use-cases/post/get-posts"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { CachePort } from "@core/ports/services/cache.port"; +import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; +import { UnauthorizedError } from "@core/errors"; +import { buildPost } from "../../../helpers/mock-factories"; + +describe("GetPostsUseCase", () => { + let useCase: GetPostsUseCase; + let postRepository: Pick; + let cacheService: Pick; + let followRepository: Pick; + + beforeEach(() => { + postRepository = { + findAll: vi.fn().mockResolvedValue({ posts: [], total: 0 }), + }; + cacheService = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }; + followRepository = { + getFollowingIds: vi.fn().mockResolvedValue([]), + }; + useCase = new GetPostsUseCase( + postRepository as IPostRepository, + cacheService as CachePort, + followRepository as IFollowRepository, + ); + }); + + it("should return posts from repository on cache miss", async () => { + const posts = [buildPost()]; + vi.mocked(postRepository.findAll).mockResolvedValue({ + posts, + total: 1, + }); + + const result = await useCase.execute({ page: 1, limit: 10 }); + + expect(result.posts).toBe(posts); + expect(result.total).toBe(1); + }); + + it("should throw UnauthorizedError when followedOnly=true but no userId", async () => { + await expect(useCase.execute({ followedOnly: true })).rejects.toThrow( + UnauthorizedError, + ); + }); + + it("should fetch followingIds when followedOnly=true and userId provided", async () => { + vi.mocked(followRepository.getFollowingIds).mockResolvedValue([ + "user-2", + "user-3", + ]); + vi.mocked(postRepository.findAll).mockResolvedValue({ + posts: [], + total: 0, + }); + + await useCase.execute({ + followedOnly: true, + currentUserId: "user-1", + }); + + expect(followRepository.getFollowingIds).toHaveBeenCalledWith("user-1"); + expect(postRepository.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + followingIds: ["user-2", "user-3"], + }), + ); + }); + + it("should build correct cache key from all params", async () => { + await useCase.execute({ + page: 2, + limit: 5, + currentUserId: "user-1", + }); + + expect(cacheService.get).toHaveBeenCalledWith( + "posts:feed:page:2:limit:5:type:ALL:tag:ALL:categories:ALL:followedOnly:false:user:user-1", + ); + }); + + it("should store result in cache with 60-second TTL", async () => { + const posts = [buildPost()]; + vi.mocked(postRepository.findAll).mockResolvedValue({ + posts, + total: 1, + }); + + await useCase.execute({ page: 1, limit: 10 }); + + expect(cacheService.set).toHaveBeenCalledWith( + expect.stringContaining("posts:feed:"), + expect.any(String), + 60, + ); + }); + + it("should return cached posts on cache hit", async () => { + const post = buildPost(); + const cached = JSON.stringify({ + posts: [ + { + id: post.id, + content: post.content, + type: post.type, + mediaUrls: post.mediaUrls, + author: post.author, + tags: post.tags, + categories: post.categories, + createdAt: post.createdAt.toISOString(), + updatedAt: post.updatedAt.toISOString(), + }, + ], + total: 1, + }); + vi.mocked(cacheService.get).mockResolvedValue(cached); + + const result = await useCase.execute({ page: 1, limit: 10 }); + + expect(result.total).toBe(1); + expect(result.posts).toHaveLength(1); + expect(postRepository.findAll).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/core/use-cases/post/get-trends.usecase.test.ts b/tests/unit/core/use-cases/post/get-trends.usecase.test.ts new file mode 100644 index 0000000..6fe111e --- /dev/null +++ b/tests/unit/core/use-cases/post/get-trends.usecase.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetTrendsUseCase } from "@core/use-cases/post/get-trends"; +import type { + ITagRepository, + TrendItem, +} from "@core/ports/repositories/tag.repository"; +import type { CachePort } from "@core/ports/services/cache.port"; + +const mockTrends: TrendItem[] = [ + { tag: "typescript", postCount: 100, category: "programming" }, + { tag: "nodejs", postCount: 80, category: null }, +]; + +describe("GetTrendsUseCase", () => { + let useCase: GetTrendsUseCase; + let tagRepository: Pick; + let cacheService: Pick; + + beforeEach(() => { + tagRepository = { + findTrending: vi.fn().mockResolvedValue(mockTrends), + }; + cacheService = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }; + useCase = new GetTrendsUseCase( + tagRepository as ITagRepository, + cacheService as CachePort, + ); + }); + + it("should return cached trends without calling repository", async () => { + vi.mocked(cacheService.get).mockResolvedValue( + JSON.stringify(mockTrends), + ); + + const result = await useCase.execute({ limit: 10 }); + + expect(result.trends).toEqual(mockTrends); + expect(tagRepository.findTrending).not.toHaveBeenCalled(); + }); + + it("should call repository and cache result on cache miss", async () => { + vi.mocked(cacheService.get).mockResolvedValue(null); + + const result = await useCase.execute({ limit: 10 }); + + expect(tagRepository.findTrending).toHaveBeenCalledOnce(); + expect(cacheService.set).toHaveBeenCalledOnce(); + expect(result.trends).toEqual(mockTrends); + }); + + it("should use correct cache key with limit and 7-day window", async () => { + await useCase.execute({ limit: 5 }); + + expect(cacheService.get).toHaveBeenCalledWith( + "trends:top:limit:5:window:7", + ); + }); + + it("should use default limit of 10 when not provided", async () => { + await useCase.execute({}); + + expect(cacheService.get).toHaveBeenCalledWith( + "trends:top:limit:10:window:7", + ); + expect(tagRepository.findTrending).toHaveBeenCalledWith( + expect.objectContaining({ limit: 10, windowDays: 7 }), + ); + }); + + it("should cache result with 300-second TTL", async () => { + await useCase.execute({ limit: 10 }); + + expect(cacheService.set).toHaveBeenCalledWith( + "trends:top:limit:10:window:7", + JSON.stringify(mockTrends), + 300, + ); + }); +}); diff --git a/tests/unit/core/use-cases/post/get-user-posts.usecase.test.ts b/tests/unit/core/use-cases/post/get-user-posts.usecase.test.ts new file mode 100644 index 0000000..e86c768 --- /dev/null +++ b/tests/unit/core/use-cases/post/get-user-posts.usecase.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetUserPostsUseCase } from "@core/use-cases/post/get-user-posts/get-user.posts.usecase"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import { buildPost } from "../../../helpers/mock-factories"; +import { PostType } from "@core/domain/enums/post-type.enum"; + +describe("GetUserPostsUseCase", () => { + let useCase: GetUserPostsUseCase; + let postRepository: Pick; + + beforeEach(() => { + postRepository = { + findByAuthorUsername: vi.fn(), + }; + useCase = new GetUserPostsUseCase(postRepository as IPostRepository); + }); + + it("should return posts and total for given username", async () => { + const posts = [buildPost(), buildPost({ id: "post-2" })]; + vi.mocked(postRepository.findByAuthorUsername).mockResolvedValue({ + posts, + total: 2, + }); + + const result = await useCase.execute({ + username: "testuser", + page: 1, + limit: 10, + }); + + expect(result.posts).toBe(posts); + expect(result.total).toBe(2); + }); + + it("should pass optional type filter to repository", async () => { + vi.mocked(postRepository.findByAuthorUsername).mockResolvedValue({ + posts: [], + total: 0, + }); + + await useCase.execute({ + username: "testuser", + page: 1, + limit: 10, + type: PostType.TECH_NEWS, + }); + + expect(postRepository.findByAuthorUsername).toHaveBeenCalledWith( + "testuser", + 1, + 10, + PostType.TECH_NEWS, + undefined, + ); + }); + + it("should pass currentUserId to repository", async () => { + vi.mocked(postRepository.findByAuthorUsername).mockResolvedValue({ + posts: [], + total: 0, + }); + + await useCase.execute({ + username: "testuser", + page: 2, + limit: 5, + currentUserId: "viewer-99", + }); + + expect(postRepository.findByAuthorUsername).toHaveBeenCalledWith( + "testuser", + 2, + 5, + undefined, + "viewer-99", + ); + }); +}); diff --git a/tests/unit/core/use-cases/post/like-post.usecase.test.ts b/tests/unit/core/use-cases/post/like-post.usecase.test.ts new file mode 100644 index 0000000..5100de4 --- /dev/null +++ b/tests/unit/core/use-cases/post/like-post.usecase.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LikePostUseCase } from "@core/use-cases/post/like-post"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import type { RealtimePort } from "@core/ports/services/realtime.port"; +import type { CachePort } from "@core/ports/services/cache.port"; +import { NotFoundError } from "@core/errors"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; +import { buildPost } from "../../../helpers/mock-factories"; + +describe("LikePostUseCase", () => { + let useCase: LikePostUseCase; + let transactionService: Pick; + let realtimeService: Pick; + let cacheService: Pick; + let mockCtx: Pick< + TransactionContext, + "postRepository" | "postLikeRepository" | "notificationRepository" + >; + + beforeEach(() => { + mockCtx = { + postRepository: { + findById: vi.fn(), + } as unknown as TransactionContext["postRepository"], + postLikeRepository: { + isLiked: vi.fn().mockResolvedValue(false), + like: vi.fn().mockResolvedValue(undefined), + incrementLikeCount: vi.fn().mockResolvedValue(undefined), + } as unknown as TransactionContext["postLikeRepository"], + notificationRepository: { + create: vi.fn().mockResolvedValue(undefined), + } as unknown as TransactionContext["notificationRepository"], + }; + transactionService = { + runInTransaction: vi + .fn() + .mockImplementation(async (work) => + work(mockCtx as TransactionContext), + ), + }; + realtimeService = { + emitToUser: vi.fn(), + }; + cacheService = { + deleteByPattern: vi.fn().mockResolvedValue(undefined), + }; + useCase = new LikePostUseCase( + transactionService as TransactionPort, + realtimeService as RealtimePort, + cacheService as CachePort, + ); + }); + + it("should throw NotFoundError when post not found inside transaction", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ postId: "ghost-post", userId: "user-1" }), + ).rejects.toThrow(NotFoundError); + }); + + it("should return early when post is already liked (idempotent)", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue( + buildPost({ author: { id: "author-1" } }), + ); + vi.mocked(mockCtx.postLikeRepository.isLiked).mockResolvedValue(true); + + await useCase.execute({ postId: "post-1", userId: "user-1" }); + + expect(mockCtx.postLikeRepository.like).not.toHaveBeenCalled(); + expect( + mockCtx.postLikeRepository.incrementLikeCount, + ).not.toHaveBeenCalled(); + }); + + it("should like post and increment like count", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue( + buildPost({ author: { id: "author-1" } }), + ); + + await useCase.execute({ postId: "post-1", userId: "user-1" }); + + expect(mockCtx.postLikeRepository.like).toHaveBeenCalledWith( + "post-1", + "user-1", + ); + expect( + mockCtx.postLikeRepository.incrementLikeCount, + ).toHaveBeenCalledWith("post-1"); + }); + + it("should create notification and emit realtime event when liker is not author", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue( + buildPost({ id: "post-1", author: { id: "author-1" } }), + ); + + await useCase.execute({ postId: "post-1", userId: "liker-99" }); + + expect(mockCtx.notificationRepository.create).toHaveBeenCalledOnce(); + expect(realtimeService.emitToUser).toHaveBeenCalledWith( + "author-1", + "new-notification", + expect.objectContaining({ + type: NotificationType.LIKE, + issuerId: "liker-99", + postId: "post-1", + }), + ); + }); + + it("should not create notification when user likes their own post", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue( + buildPost({ author: { id: "user-1" } }), + ); + + await useCase.execute({ postId: "post-1", userId: "user-1" }); + + expect(mockCtx.notificationRepository.create).not.toHaveBeenCalled(); + expect(realtimeService.emitToUser).not.toHaveBeenCalled(); + }); + + it("should invalidate user feed cache after liking", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue( + buildPost({ author: { id: "author-1" } }), + ); + + await useCase.execute({ postId: "post-1", userId: "user-42" }); + + expect(cacheService.deleteByPattern).toHaveBeenCalledWith( + "posts:feed:*user:user-42*", + ); + }); +}); 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 new file mode 100644 index 0000000..dda8675 --- /dev/null +++ b/tests/unit/core/use-cases/post/unlike-post.usecase.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UnlikePostUseCase } from "@core/use-cases/post/unlike-post"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import type { CachePort } from "@core/ports/services/cache.port"; +import { NotFoundError } from "@core/errors"; +import { buildPost } from "../../../helpers/mock-factories"; + +describe("UnlikePostUseCase", () => { + let useCase: UnlikePostUseCase; + let transactionService: Pick; + let cacheService: Pick; + let mockCtx: Pick< + TransactionContext, + "postRepository" | "postLikeRepository" + >; + + beforeEach(() => { + mockCtx = { + postRepository: { + findById: vi.fn(), + } as unknown as TransactionContext["postRepository"], + postLikeRepository: { + isLiked: vi.fn().mockResolvedValue(true), + unlike: vi.fn().mockResolvedValue(undefined), + decrementLikeCount: vi.fn().mockResolvedValue(undefined), + } as unknown as TransactionContext["postLikeRepository"], + }; + transactionService = { + runInTransaction: vi + .fn() + .mockImplementation(async (work) => + work(mockCtx as TransactionContext), + ), + }; + cacheService = { + deleteByPattern: vi.fn().mockResolvedValue(undefined), + }; + useCase = new UnlikePostUseCase( + transactionService as TransactionPort, + cacheService as CachePort, + ); + }); + + it("should throw NotFoundError when post not found", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ postId: "ghost-post", userId: "user-1" }), + ).rejects.toThrow(NotFoundError); + }); + + it("should unlike post and decrement like count when liked", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue( + buildPost(), + ); + vi.mocked(mockCtx.postLikeRepository.isLiked).mockResolvedValue(true); + + await useCase.execute({ postId: "post-1", userId: "user-1" }); + + expect(mockCtx.postLikeRepository.unlike).toHaveBeenCalledWith( + "post-1", + "user-1", + ); + expect( + mockCtx.postLikeRepository.decrementLikeCount, + ).toHaveBeenCalledWith("post-1"); + }); + + it("should do nothing when post is not liked (idempotent)", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue( + buildPost(), + ); + vi.mocked(mockCtx.postLikeRepository.isLiked).mockResolvedValue(false); + + await useCase.execute({ postId: "post-1", userId: "user-1" }); + + expect(mockCtx.postLikeRepository.unlike).not.toHaveBeenCalled(); + expect( + mockCtx.postLikeRepository.decrementLikeCount, + ).not.toHaveBeenCalled(); + }); + + it("should invalidate user feed cache after unliking", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue( + buildPost(), + ); + + await useCase.execute({ postId: "post-1", userId: "user-55" }); + + expect(cacheService.deleteByPattern).toHaveBeenCalledWith( + "posts:feed:*user:user-55*", + ); + }); + + it("should propagate transaction errors", async () => { + vi.mocked(transactionService.runInTransaction).mockRejectedValue( + new Error("Transaction failed"), + ); + + await expect( + useCase.execute({ postId: "post-1", userId: "user-1" }), + ).rejects.toThrow("Transaction failed"); + }); +}); diff --git a/tests/unit/core/use-cases/post/upload-post-media.usecase.test.ts b/tests/unit/core/use-cases/post/upload-post-media.usecase.test.ts new file mode 100644 index 0000000..bc01689 --- /dev/null +++ b/tests/unit/core/use-cases/post/upload-post-media.usecase.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UploadPostMediaUseCase } from "@core/use-cases/post/upload-post-media"; +import type { StoragePort } from "@core/ports/services/storage.port"; +import { InvalidMediaTypeError } from "@core/errors"; + +describe("UploadPostMediaUseCase", () => { + let useCase: UploadPostMediaUseCase; + let storageService: Pick; + + beforeEach(() => { + storageService = { + upload: vi + .fn() + .mockResolvedValue( + "https://cdn.example.com/posts/user-1/file.jpg", + ), + }; + useCase = new UploadPostMediaUseCase(storageService as StoragePort); + }); + + it("should throw InvalidMediaTypeError for non-image/video MIME type", async () => { + await expect( + useCase.execute({ + userId: "user-1", + fileBuffer: Buffer.from("data"), + mimeType: "application/pdf", + originalFileName: "document.pdf", + }), + ).rejects.toThrow(InvalidMediaTypeError); + + expect(storageService.upload).not.toHaveBeenCalled(); + }); + + it("should upload image and return URL", async () => { + vi.mocked(storageService.upload).mockResolvedValue( + "https://cdn.example.com/posts/user-1/photo.png", + ); + + const result = await useCase.execute({ + userId: "user-1", + fileBuffer: Buffer.from("img"), + mimeType: "image/png", + originalFileName: "photo.png", + }); + + expect(result).toBe("https://cdn.example.com/posts/user-1/photo.png"); + expect(storageService.upload).toHaveBeenCalledOnce(); + }); + + it("should upload video and return URL", async () => { + vi.mocked(storageService.upload).mockResolvedValue( + "https://cdn.example.com/posts/user-1/clip.mp4", + ); + + const result = await useCase.execute({ + userId: "user-1", + fileBuffer: Buffer.from("vid"), + mimeType: "video/mp4", + originalFileName: "clip.mp4", + }); + + expect(result).toBe("https://cdn.example.com/posts/user-1/clip.mp4"); + expect(storageService.upload).toHaveBeenCalledOnce(); + }); + + it("should generate filename with posts/{userId}/ prefix", async () => { + await useCase.execute({ + userId: "user-42", + fileBuffer: Buffer.from("img"), + mimeType: "image/jpeg", + originalFileName: "avatar.jpg", + }); + + const [passedFileName] = vi.mocked(storageService.upload).mock.calls[0]; + expect(passedFileName).toMatch(/^posts\/user-42\/.+\.jpg$/); + }); +}); diff --git a/tests/unit/core/use-cases/tag/search-tag.usecase.test.ts b/tests/unit/core/use-cases/tag/search-tag.usecase.test.ts new file mode 100644 index 0000000..de1e91d --- /dev/null +++ b/tests/unit/core/use-cases/tag/search-tag.usecase.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SearchTagsUseCase } from "@core/use-cases/tag/search-tag"; +import type { ITagRepository } from "@core/ports/repositories/tag.repository"; + +const mockTags = [ + { name: "typescript", postCount: 42, category: "programming" }, + { name: "typeorm", postCount: 10, category: null }, +]; + +describe("SearchTagsUseCase", () => { + let useCase: SearchTagsUseCase; + let tagRepository: Pick; + + beforeEach(() => { + tagRepository = { + search: vi.fn().mockResolvedValue(mockTags), + }; + useCase = new SearchTagsUseCase(tagRepository as ITagRepository); + }); + + it("should return empty array when query is empty string", async () => { + const result = await useCase.execute({ query: "" }); + + expect(result).toEqual([]); + expect(tagRepository.search).not.toHaveBeenCalled(); + }); + + it("should return empty array when query is whitespace only", async () => { + const result = await useCase.execute({ query: " " }); + + expect(result).toEqual([]); + expect(tagRepository.search).not.toHaveBeenCalled(); + }); + + it("should trim the query before passing to repository", async () => { + await useCase.execute({ query: " typescript ", limit: 5 }); + + expect(tagRepository.search).toHaveBeenCalledWith("typescript", 5); + }); + + it("should return mapped tag results for a valid query", async () => { + const result = await useCase.execute({ query: "type", limit: 10 }); + + expect(result).toEqual([ + { name: "typescript", postCount: 42, category: "programming" }, + { name: "typeorm", postCount: 10, category: null }, + ]); + }); + + it("should pass limit to repository", async () => { + await useCase.execute({ query: "ts", limit: 20 }); + + expect(tagRepository.search).toHaveBeenCalledWith("ts", 20); + }); + + it("should return empty array when repository returns no results", async () => { + vi.mocked(tagRepository.search).mockResolvedValue([]); + + const result = await useCase.execute({ query: "notfound" }); + + expect(result).toEqual([]); + }); + + it("should propagate repository errors", async () => { + vi.mocked(tagRepository.search).mockRejectedValue( + new Error("Database error"), + ); + + await expect(useCase.execute({ query: "typescript" })).rejects.toThrow( + "Database error", + ); + }); +}); diff --git a/tests/unit/core/use-cases/translate/translate.usecase.test.ts b/tests/unit/core/use-cases/translate/translate.usecase.test.ts new file mode 100644 index 0000000..ee3cba9 --- /dev/null +++ b/tests/unit/core/use-cases/translate/translate.usecase.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TranslateUseCase } from "@core/use-cases/translate"; +import type { TranslationPort } from "@core/ports/services/translation.port"; +import type { CachePort } from "@core/ports/services/cache.port"; + +describe("TranslateUseCase", () => { + let useCase: TranslateUseCase; + let translationService: Pick; + let cacheService: Pick; + + beforeEach(() => { + translationService = { + translate: vi.fn().mockResolvedValue("Merhaba"), + }; + cacheService = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }; + useCase = new TranslateUseCase( + translationService as TranslationPort, + cacheService as CachePort, + ); + }); + + it("should return cached translation without calling translation service", async () => { + vi.mocked(cacheService.get).mockResolvedValue("Merhaba"); + + const result = await useCase.execute({ + text: "Hello", + targetLang: "tr", + }); + + expect(result).toEqual({ translatedText: "Merhaba" }); + expect(translationService.translate).not.toHaveBeenCalled(); + }); + + it("should call translation service when cache miss", async () => { + vi.mocked(cacheService.get).mockResolvedValue(null); + + await useCase.execute({ text: "Hello", targetLang: "tr" }); + + expect(translationService.translate).toHaveBeenCalledOnce(); + expect(translationService.translate).toHaveBeenCalledWith( + "Hello", + "TR", + ); + }); + + it("should normalize targetLang to uppercase before using", async () => { + await useCase.execute({ text: "Hello", targetLang: "de" }); + + expect(translationService.translate).toHaveBeenCalledWith( + "Hello", + "DE", + ); + expect(cacheService.get).toHaveBeenCalledWith("translation:DE:Hello"); + }); + + it("should store translation result in cache with correct key and TTL", async () => { + vi.mocked(translationService.translate).mockResolvedValue("Merhaba"); + + await useCase.execute({ text: "Hello", targetLang: "tr" }); + + expect(cacheService.set).toHaveBeenCalledOnce(); + expect(cacheService.set).toHaveBeenCalledWith( + "translation:TR:Hello", + "Merhaba", + 86400, + ); + }); + + it("should return translated text from service on cache miss", async () => { + vi.mocked(translationService.translate).mockResolvedValue("Bonjour"); + + const result = await useCase.execute({ + text: "Hello", + targetLang: "fr", + }); + + expect(result).toEqual({ translatedText: "Bonjour" }); + }); + + it("should not store in cache when translation service fails", async () => { + vi.mocked(translationService.translate).mockRejectedValue( + new Error("Translation API error"), + ); + + await expect( + useCase.execute({ text: "Hello", targetLang: "tr" }), + ).rejects.toThrow("Translation API error"); + + expect(cacheService.set).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/helpers/mock-factories.ts b/tests/unit/helpers/mock-factories.ts index 9f95c68..b9b793c 100644 --- a/tests/unit/helpers/mock-factories.ts +++ b/tests/unit/helpers/mock-factories.ts @@ -10,8 +10,11 @@ import { Profile } from "@core/domain/entities/profile.entity"; import type { ProfileProps } from "@core/domain/interfaces/profile-props.interface"; import { Notification } from "@core/domain/entities/notification.entity"; import type { NotificationProps } from "@core/domain/interfaces/notification-props.interface"; +import { Post } from "@core/domain/entities/post.entity"; +import type { PostProps } from "@core/domain/interfaces/post-props.interface"; import { TokenType } from "@core/domain/enums/token-type.enum"; import { NotificationType } from "@core/domain/enums/notification-type.enum"; +import { PostType } from "@core/domain/enums/post-type.enum"; export function buildUser(overrides: Partial = {}): User { return User.with({ @@ -106,3 +109,25 @@ export function buildNotification( ...overrides, }); } + +export function buildPost(overrides: Partial = {}): Post { + return Post.with({ + id: "post-1", + content: "Test post content", + type: PostType.COMMUNITY, + mediaUrls: [], + author: { + id: "user-1", + username: "testuser", + }, + tags: [], + categories: [], + likeCount: 0, + commentCount: 0, + isLiked: false, + isBookmarked: false, + createdAt: new Date("2024-01-01T00:00:00Z"), + updatedAt: new Date("2024-01-01T00:00:00Z"), + ...overrides, + }); +}