diff --git a/backend/src/api-keys/api-keys.service.spec.ts b/backend/src/api-keys/api-keys.service.spec.ts new file mode 100644 index 0000000..66b7ac9 --- /dev/null +++ b/backend/src/api-keys/api-keys.service.spec.ts @@ -0,0 +1,179 @@ +import { createHash } from "node:crypto"; +import { Test, TestingModule } from "@nestjs/testing"; +import { DatabaseService } from "../database/database.service"; +import { apiKeys } from "../database/schemas"; +import { ApiKeysService } from "./api-keys.service"; + +describe("ApiKeysService", () => { + let service: ApiKeysService; + + const mockDb = { + insert: jest.fn(), + values: jest.fn(), + select: jest.fn(), + from: jest.fn(), + where: jest.fn(), + limit: jest.fn(), + update: jest.fn(), + set: jest.fn(), + returning: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockDb.insert.mockReturnThis(); + mockDb.values.mockResolvedValue(undefined); + mockDb.select.mockReturnThis(); + mockDb.from.mockReturnThis(); + mockDb.where.mockReturnThis(); + mockDb.limit.mockReturnThis(); + mockDb.update.mockReturnThis(); + mockDb.set.mockReturnThis(); + mockDb.returning.mockResolvedValue([]); + + // Default for findAll which is awaited on where() + mockDb.where.mockImplementation(() => { + const chain = { + returning: jest.fn().mockResolvedValue([]), + }; + return Object.assign(Promise.resolve([]), chain); + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiKeysService, + { + provide: DatabaseService, + useValue: { + db: mockDb, + }, + }, + ], + }).compile(); + + service = module.get(ApiKeysService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("create", () => { + it("should create an API key", async () => { + const userId = "user-id"; + const name = "Test Key"; + const expiresAt = new Date(); + + const result = await service.create(userId, name, expiresAt); + + expect(mockDb.insert).toHaveBeenCalledWith(apiKeys); + expect(mockDb.values).toHaveBeenCalledWith( + expect.objectContaining({ + userId, + name, + prefix: "mg_live_", + expiresAt, + }), + ); + expect(result).toHaveProperty("key"); + expect(result.name).toBe(name); + expect(result.expiresAt).toBe(expiresAt); + expect(result.key).toMatch(/^mg_live_/); + }); + }); + + describe("findAll", () => { + it("should find all API keys for a user", async () => { + const userId = "user-id"; + const expectedKeys = [{ id: "1", name: "Key 1" }]; + (mockDb.where as jest.Mock).mockResolvedValue(expectedKeys); + + const result = await service.findAll(userId); + + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalledWith(apiKeys); + expect(result).toEqual(expectedKeys); + }); + }); + + describe("revoke", () => { + it("should revoke an API key", async () => { + const userId = "user-id"; + const keyId = "key-id"; + const expectedResult = [{ id: keyId, isActive: false }]; + + mockDb.where.mockReturnValue({ + returning: jest.fn().mockResolvedValue(expectedResult), + }); + + const result = await service.revoke(userId, keyId); + + expect(mockDb.update).toHaveBeenCalledWith(apiKeys); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ isActive: false }), + ); + expect(result).toEqual(expectedResult); + }); + }); + + describe("validateKey", () => { + it("should validate a valid API key", async () => { + const key = "mg_live_testkey"; + const keyHash = createHash("sha256").update(key).digest("hex"); + const apiKey = { id: "1", keyHash, isActive: true, expiresAt: null }; + + (mockDb.limit as jest.Mock).mockResolvedValue([apiKey]); + (mockDb.where as jest.Mock).mockResolvedValue([apiKey]); // For the update later if needed, but here it's for select + + // We need to be careful with chaining mockDb.where is used in both select and update + const mockSelect = { + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue([apiKey]), + }; + const mockUpdate = { + set: jest.fn().mockReturnThis(), + where: jest.fn().mockResolvedValue(undefined), + }; + + (mockDb.select as jest.Mock).mockReturnValue(mockSelect); + (mockDb.update as jest.Mock).mockReturnValue(mockUpdate); + + const result = await service.validateKey(key); + + expect(result).toEqual(apiKey); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.update).toHaveBeenCalledWith(apiKeys); + }); + + it("should return null for invalid API key", async () => { + (mockDb.select as jest.Mock).mockReturnValue({ + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue([]), + }); + + const result = await service.validateKey("invalid-key"); + + expect(result).toBeNull(); + }); + + it("should return null for expired API key", async () => { + const key = "mg_live_testkey"; + const expiredDate = new Date(); + expiredDate.setFullYear(expiredDate.getFullYear() - 1); + const apiKey = { id: "1", isActive: true, expiresAt: expiredDate }; + + (mockDb.select as jest.Mock).mockReturnValue({ + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue([apiKey]), + }); + + const result = await service.validateKey(key); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..fd7bdb8 --- /dev/null +++ b/backend/src/auth/auth.service.spec.ts @@ -0,0 +1,253 @@ +import { Test, TestingModule } from "@nestjs/testing"; + +jest.mock("@noble/post-quantum/ml-kem.js", () => ({ + ml_kem768: { + keygen: jest.fn(), + encapsulate: jest.fn(), + decapsulate: jest.fn(), + }, +})); + +jest.mock("jose", () => ({ + SignJWT: jest.fn(), + jwtVerify: jest.fn(), +})); + +import { BadRequestException, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { authenticator } from "otplib"; +import * as qrcode from "qrcode"; +import { CryptoService } from "../crypto/crypto.service"; +import { SessionsService } from "../sessions/sessions.service"; +import { UsersService } from "../users/users.service"; +import { AuthService } from "./auth.service"; + +jest.mock("otplib"); +jest.mock("qrcode"); +jest.mock("../crypto/crypto.service"); +jest.mock("../users/users.service"); +jest.mock("../sessions/sessions.service"); + +describe("AuthService", () => { + let service: AuthService; + + const mockUsersService = { + findOne: jest.fn(), + setTwoFactorSecret: jest.fn(), + getTwoFactorSecret: jest.fn(), + toggleTwoFactor: jest.fn(), + create: jest.fn(), + findByEmailHash: jest.fn(), + findOneWithPrivateData: jest.fn(), + }; + + const mockCryptoService = { + hashPassword: jest.fn(), + hashEmail: jest.fn(), + verifyPassword: jest.fn(), + generateJwt: jest.fn(), + }; + + const mockSessionsService = { + createSession: jest.fn(), + refreshSession: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: UsersService, useValue: mockUsersService }, + { provide: CryptoService, useValue: mockCryptoService }, + { provide: SessionsService, useValue: mockSessionsService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("generateTwoFactorSecret", () => { + it("should generate a 2FA secret", async () => { + const userId = "user-id"; + const user = { username: "testuser" }; + mockUsersService.findOne.mockResolvedValue(user); + (authenticator.generateSecret as jest.Mock).mockReturnValue("secret"); + (authenticator.keyuri as jest.Mock).mockReturnValue("otpauth://..."); + (qrcode.toDataURL as jest.Mock).mockResolvedValue( + "data:image/png;base64,...", + ); + + const result = await service.generateTwoFactorSecret(userId); + + expect(result).toEqual({ + secret: "secret", + qrCodeDataUrl: "data:image/png;base64,...", + }); + expect(mockUsersService.setTwoFactorSecret).toHaveBeenCalledWith( + userId, + "secret", + ); + }); + + it("should throw UnauthorizedException if user not found", async () => { + mockUsersService.findOne.mockResolvedValue(null); + await expect(service.generateTwoFactorSecret("invalid")).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe("enableTwoFactor", () => { + it("should enable 2FA", async () => { + const userId = "user-id"; + const token = "123456"; + mockUsersService.getTwoFactorSecret.mockResolvedValue("secret"); + (authenticator.verify as jest.Mock).mockReturnValue(true); + + const result = await service.enableTwoFactor(userId, token); + + expect(result).toEqual({ message: "2FA enabled successfully" }); + expect(mockUsersService.toggleTwoFactor).toHaveBeenCalledWith(userId, true); + }); + + it("should throw BadRequestException if 2FA not initiated", async () => { + mockUsersService.getTwoFactorSecret.mockResolvedValue(null); + await expect(service.enableTwoFactor("user-id", "token")).rejects.toThrow( + BadRequestException, + ); + }); + + it("should throw BadRequestException if token is invalid", async () => { + mockUsersService.getTwoFactorSecret.mockResolvedValue("secret"); + (authenticator.verify as jest.Mock).mockReturnValue(false); + await expect(service.enableTwoFactor("user-id", "invalid")).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe("register", () => { + it("should register a user", async () => { + const dto = { + username: "test", + email: "test@example.com", + password: "password", + }; + mockCryptoService.hashPassword.mockResolvedValue("hashed-password"); + mockCryptoService.hashEmail.mockResolvedValue("hashed-email"); + mockUsersService.create.mockResolvedValue({ uuid: "new-user-id" }); + + const result = await service.register(dto); + + expect(result).toEqual({ + message: "User registered successfully", + userId: "new-user-id", + }); + }); + }); + + describe("login", () => { + it("should login a user", async () => { + const dto = { email: "test@example.com", password: "password" }; + const user = { + uuid: "user-id", + username: "test", + passwordHash: "hash", + isTwoFactorEnabled: false, + }; + mockCryptoService.hashEmail.mockResolvedValue("hashed-email"); + mockUsersService.findByEmailHash.mockResolvedValue(user); + mockCryptoService.verifyPassword.mockResolvedValue(true); + mockCryptoService.generateJwt.mockResolvedValue("access-token"); + mockSessionsService.createSession.mockResolvedValue({ + refreshToken: "refresh-token", + }); + + const result = await service.login(dto); + + expect(result).toEqual({ + message: "User logged in successfully", + access_token: "access-token", + refresh_token: "refresh-token", + }); + }); + + it("should return requires2FA if 2FA is enabled", async () => { + const dto = { email: "test@example.com", password: "password" }; + const user = { + uuid: "user-id", + username: "test", + passwordHash: "hash", + isTwoFactorEnabled: true, + }; + mockCryptoService.hashEmail.mockResolvedValue("hashed-email"); + mockUsersService.findByEmailHash.mockResolvedValue(user); + mockCryptoService.verifyPassword.mockResolvedValue(true); + + const result = await service.login(dto); + + expect(result).toEqual({ + message: "2FA required", + requires2FA: true, + userId: "user-id", + }); + }); + + it("should throw UnauthorizedException for invalid credentials", async () => { + mockUsersService.findByEmailHash.mockResolvedValue(null); + await expect(service.login({ email: "x", password: "y" })).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe("verifyTwoFactorLogin", () => { + it("should verify 2FA login", async () => { + const userId = "user-id"; + const token = "123456"; + const user = { uuid: userId, username: "test", isTwoFactorEnabled: true }; + mockUsersService.findOneWithPrivateData.mockResolvedValue(user); + mockUsersService.getTwoFactorSecret.mockResolvedValue("secret"); + (authenticator.verify as jest.Mock).mockReturnValue(true); + mockCryptoService.generateJwt.mockResolvedValue("access-token"); + mockSessionsService.createSession.mockResolvedValue({ + refreshToken: "refresh-token", + }); + + const result = await service.verifyTwoFactorLogin(userId, token); + + expect(result).toEqual({ + message: "User logged in successfully (2FA)", + access_token: "access-token", + refresh_token: "refresh-token", + }); + }); + }); + + describe("refresh", () => { + it("should refresh tokens", async () => { + const refreshToken = "old-refresh"; + const session = { userId: "user-id", refreshToken: "new-refresh" }; + const user = { uuid: "user-id", username: "test" }; + mockSessionsService.refreshSession.mockResolvedValue(session); + mockUsersService.findOne.mockResolvedValue(user); + mockCryptoService.generateJwt.mockResolvedValue("new-access"); + + const result = await service.refresh(refreshToken); + + expect(result).toEqual({ + access_token: "new-access", + refresh_token: "new-refresh", + }); + }); + }); +}); diff --git a/backend/src/auth/rbac.service.spec.ts b/backend/src/auth/rbac.service.spec.ts new file mode 100644 index 0000000..dfba4c9 --- /dev/null +++ b/backend/src/auth/rbac.service.spec.ts @@ -0,0 +1,69 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { DatabaseService } from "../database/database.service"; +import { RbacService } from "./rbac.service"; + +describe("RbacService", () => { + let service: RbacService; + + const mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RbacService, + { + provide: DatabaseService, + useValue: { + db: mockDb, + }, + }, + ], + }).compile(); + + service = module.get(RbacService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("getUserRoles", () => { + it("should return user roles", async () => { + const userId = "user-id"; + const mockRoles = [{ slug: "admin" }, { slug: "user" }]; + mockDb.where.mockResolvedValue(mockRoles); + + const result = await service.getUserRoles(userId); + + expect(result).toEqual(["admin", "user"]); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.innerJoin).toHaveBeenCalled(); + }); + }); + + describe("getUserPermissions", () => { + it("should return unique user permissions", async () => { + const userId = "user-id"; + const mockPermissions = [ + { slug: "read" }, + { slug: "write" }, + { slug: "read" }, // Duplicate + ]; + mockDb.where.mockResolvedValue(mockPermissions); + + const result = await service.getUserPermissions(userId); + + expect(result).toEqual(["read", "write"]); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.innerJoin).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/backend/src/categories/categories.service.spec.ts b/backend/src/categories/categories.service.spec.ts new file mode 100644 index 0000000..8c0508f --- /dev/null +++ b/backend/src/categories/categories.service.spec.ts @@ -0,0 +1,122 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { DatabaseService } from "../database/database.service"; +import { categories } from "../database/schemas"; +import { CategoriesService } from "./categories.service"; +import { CreateCategoryDto } from "./dto/create-category.dto"; +import { UpdateCategoryDto } from "./dto/update-category.dto"; + +describe("CategoriesService", () => { + let service: CategoriesService; + + const mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue([]), + orderBy: jest.fn().mockResolvedValue([]), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + returning: jest.fn().mockResolvedValue([]), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CategoriesService, + { + provide: DatabaseService, + useValue: { + db: mockDb, + }, + }, + ], + }).compile(); + + service = module.get(CategoriesService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("findAll", () => { + it("should return all categories ordered by name", async () => { + const mockCategories = [{ name: "A" }, { name: "B" }]; + mockDb.orderBy.mockResolvedValue(mockCategories); + + const result = await service.findAll(); + + expect(result).toEqual(mockCategories); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalledWith(categories); + }); + }); + + describe("findOne", () => { + it("should return a category by id", async () => { + const mockCategory = { id: "1", name: "Cat" }; + mockDb.limit.mockResolvedValue([mockCategory]); + + const result = await service.findOne("1"); + + expect(result).toEqual(mockCategory); + }); + + it("should return null if category not found", async () => { + mockDb.limit.mockResolvedValue([]); + const result = await service.findOne("999"); + expect(result).toBeNull(); + }); + }); + + describe("create", () => { + it("should create a category and generate slug", async () => { + const dto: CreateCategoryDto = { name: "Test Category" }; + mockDb.returning.mockResolvedValue([{ ...dto, slug: "test-category" }]); + + const result = await service.create(dto); + + expect(mockDb.insert).toHaveBeenCalledWith(categories); + expect(mockDb.values).toHaveBeenCalledWith({ + name: "Test Category", + slug: "test-category", + }); + expect(result[0].slug).toBe("test-category"); + }); + }); + + describe("update", () => { + it("should update a category and regenerate slug", async () => { + const id = "1"; + const dto: UpdateCategoryDto = { name: "New Name" }; + mockDb.returning.mockResolvedValue([{ id, ...dto, slug: "new-name" }]); + + const result = await service.update(id, dto); + + expect(mockDb.update).toHaveBeenCalledWith(categories); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + name: "New Name", + slug: "new-name", + }), + ); + expect(result[0].slug).toBe("new-name"); + }); + }); + + describe("remove", () => { + it("should remove a category", async () => { + const id = "1"; + mockDb.returning.mockResolvedValue([{ id }]); + + const result = await service.remove(id); + + expect(mockDb.delete).toHaveBeenCalledWith(categories); + expect(result).toEqual([{ id }]); + }); + }); +}); diff --git a/backend/src/common/services/purge.service.spec.ts b/backend/src/common/services/purge.service.spec.ts new file mode 100644 index 0000000..7e6d31e --- /dev/null +++ b/backend/src/common/services/purge.service.spec.ts @@ -0,0 +1,67 @@ +import { Logger } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { DatabaseService } from "../../database/database.service"; +import { PurgeService } from "./purge.service"; + +describe("PurgeService", () => { + let service: PurgeService; + + const mockDb = { + delete: jest.fn(), + where: jest.fn(), + returning: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.spyOn(Logger.prototype, "error").mockImplementation(() => {}); + jest.spyOn(Logger.prototype, "log").mockImplementation(() => {}); + + const chain = { + delete: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + returning: jest.fn().mockResolvedValue([]), + }; + + const mockImplementation = () => Object.assign(Promise.resolve([]), chain); + for (const mock of Object.values(chain)) { + mock.mockImplementation(mockImplementation); + } + Object.assign(mockDb, chain); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PurgeService, + { provide: DatabaseService, useValue: { db: mockDb } }, + ], + }).compile(); + + service = module.get(PurgeService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("purgeExpiredData", () => { + it("should purge data", async () => { + mockDb.returning + .mockResolvedValueOnce([{ id: "s1" }]) // sessions + .mockResolvedValueOnce([{ id: "r1" }]) // reports + .mockResolvedValueOnce([{ id: "u1" }]) // users + .mockResolvedValueOnce([{ id: "c1" }]); // contents + + await service.purgeExpiredData(); + + expect(mockDb.delete).toHaveBeenCalledTimes(4); + expect(mockDb.returning).toHaveBeenCalledTimes(4); + }); + + it("should handle errors", async () => { + mockDb.delete.mockImplementation(() => { + throw new Error("Db error"); + }); + await expect(service.purgeExpiredData()).resolves.not.toThrow(); + }); + }); +}); diff --git a/backend/src/contents/contents.service.spec.ts b/backend/src/contents/contents.service.spec.ts new file mode 100644 index 0000000..a49f52c --- /dev/null +++ b/backend/src/contents/contents.service.spec.ts @@ -0,0 +1,174 @@ +jest.mock("uuid", () => ({ + v4: jest.fn(() => "mocked-uuid"), +})); + +import { BadRequestException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Test, TestingModule } from "@nestjs/testing"; +import { DatabaseService } from "../database/database.service"; +import { MediaService } from "../media/media.service"; +import { S3Service } from "../s3/s3.service"; +import { ContentsService } from "./contents.service"; + +describe("ContentsService", () => { + let service: ContentsService; + let s3Service: S3Service; + let mediaService: MediaService; + + const mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + returning: jest.fn().mockResolvedValue([]), + onConflictDoNothing: jest.fn().mockReturnThis(), + transaction: jest.fn().mockImplementation((cb) => cb(mockDb)), + execute: jest.fn().mockResolvedValue([]), + }; + + const mockS3Service = { + getUploadUrl: jest.fn(), + uploadFile: jest.fn(), + }; + + const mockMediaService = { + scanFile: jest.fn(), + processImage: jest.fn(), + processVideo: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const chain = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + returning: jest.fn().mockReturnThis(), + onConflictDoNothing: jest.fn().mockReturnThis(), + }; + + const mockImplementation = () => { + return Object.assign(Promise.resolve([]), chain); + }; + + for (const mock of Object.values(chain)) { + if (mock.mockReturnValue) { + mock.mockImplementation(mockImplementation); + } + } + + Object.assign(mockDb, chain); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ContentsService, + { provide: DatabaseService, useValue: { db: mockDb } }, + { provide: S3Service, useValue: mockS3Service }, + { provide: MediaService, useValue: mockMediaService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(ContentsService); + s3Service = module.get(S3Service); + mediaService = module.get(MediaService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("getUploadUrl", () => { + it("should return an upload URL", async () => { + mockS3Service.getUploadUrl.mockResolvedValue("http://s3/url"); + const result = await service.getUploadUrl("user1", "test.png"); + expect(result).toHaveProperty("url", "http://s3/url"); + expect(result).toHaveProperty("key"); + expect(result.key).toContain("uploads/user1/"); + }); + }); + + describe("uploadAndProcess", () => { + const file = { + buffer: Buffer.from("test"), + originalname: "test.png", + mimetype: "image/png", + size: 1000, + } as Express.Multer.File; + + it("should upload and process an image", async () => { + mockConfigService.get.mockReturnValue(1024); // max size + mockMediaService.scanFile.mockResolvedValue({ isInfected: false }); + mockMediaService.processImage.mockResolvedValue({ + buffer: Buffer.from("processed"), + extension: "webp", + mimeType: "image/webp", + size: 500, + }); + mockDb.returning.mockResolvedValue([{ id: "content-id" }]); + + const result = await service.uploadAndProcess("user1", file, { + title: "Meme", + type: "meme", + }); + + expect(mediaService.scanFile).toHaveBeenCalled(); + expect(mediaService.processImage).toHaveBeenCalled(); + expect(s3Service.uploadFile).toHaveBeenCalled(); + expect(result).toEqual({ id: "content-id" }); + }); + + it("should throw if file is infected", async () => { + mockConfigService.get.mockReturnValue(1024); + mockMediaService.scanFile.mockResolvedValue({ + isInfected: true, + virusName: "Eicar", + }); + + await expect( + service.uploadAndProcess("user1", file, { title: "X", type: "meme" }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe("findAll", () => { + it("should return contents and total count", async () => { + mockDb.where.mockResolvedValueOnce([{ count: 10 }]); // for count + mockDb.offset.mockResolvedValueOnce([{ id: "1" }]); // for data + + const result = await service.findAll({ limit: 10, offset: 0 }); + + expect(result.totalCount).toBe(10); + expect(result.data).toHaveLength(1); + }); + }); + + describe("incrementViews", () => { + it("should increment views", async () => { + mockDb.returning.mockResolvedValue([{ id: "1", views: 1 }]); + const result = await service.incrementViews("1"); + expect(mockDb.update).toHaveBeenCalled(); + expect(result[0].views).toBe(1); + }); + }); +}); diff --git a/backend/src/favorites/favorites.service.spec.ts b/backend/src/favorites/favorites.service.spec.ts new file mode 100644 index 0000000..771d899 --- /dev/null +++ b/backend/src/favorites/favorites.service.spec.ts @@ -0,0 +1,100 @@ +import { ConflictException, NotFoundException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { DatabaseService } from "../database/database.service"; +import { FavoritesService } from "./favorites.service"; + +describe("FavoritesService", () => { + let service: FavoritesService; + + const mockDb = { + select: jest.fn(), + from: jest.fn(), + where: jest.fn(), + limit: jest.fn(), + offset: jest.fn(), + innerJoin: jest.fn(), + insert: jest.fn(), + values: jest.fn(), + delete: jest.fn(), + returning: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const chain = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + returning: jest.fn().mockReturnThis(), + }; + + const mockImplementation = () => Object.assign(Promise.resolve([]), chain); + for (const mock of Object.values(chain)) { + mock.mockImplementation(mockImplementation); + } + Object.assign(mockDb, chain); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FavoritesService, + { provide: DatabaseService, useValue: { db: mockDb } }, + ], + }).compile(); + + service = module.get(FavoritesService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("addFavorite", () => { + it("should add a favorite", async () => { + mockDb.limit.mockResolvedValue([{ id: "content1" }]); + mockDb.returning.mockResolvedValue([ + { userId: "u1", contentId: "content1" }, + ]); + + const result = await service.addFavorite("u1", "content1"); + + expect(result).toEqual([{ userId: "u1", contentId: "content1" }]); + }); + + it("should throw NotFoundException if content does not exist", async () => { + mockDb.limit.mockResolvedValue([]); + await expect(service.addFavorite("u1", "invalid")).rejects.toThrow( + NotFoundException, + ); + }); + + it("should throw ConflictException on duplicate favorite", async () => { + mockDb.limit.mockResolvedValue([{ id: "content1" }]); + mockDb.returning.mockRejectedValue(new Error("Duplicate")); + await expect(service.addFavorite("u1", "content1")).rejects.toThrow( + ConflictException, + ); + }); + }); + + describe("removeFavorite", () => { + it("should remove a favorite", async () => { + mockDb.returning.mockResolvedValue([{ userId: "u1", contentId: "c1" }]); + const result = await service.removeFavorite("u1", "c1"); + expect(result).toEqual({ userId: "u1", contentId: "c1" }); + }); + + it("should throw NotFoundException if favorite not found", async () => { + mockDb.returning.mockResolvedValue([]); + await expect(service.removeFavorite("u1", "c1")).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/backend/src/health.controller.spec.ts b/backend/src/health.controller.spec.ts new file mode 100644 index 0000000..07a29ea --- /dev/null +++ b/backend/src/health.controller.spec.ts @@ -0,0 +1,42 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { DatabaseService } from "./database/database.service"; +import { HealthController } from "./health.controller"; + +describe("HealthController", () => { + let controller: HealthController; + + const mockDb = { + execute: jest.fn().mockResolvedValue([]), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { + provide: DatabaseService, + useValue: { + db: mockDb, + }, + }, + ], + }).compile(); + + controller = module.get(HealthController); + }); + + it("should return ok if database is connected", async () => { + mockDb.execute.mockResolvedValue([]); + const result = await controller.check(); + expect(result.status).toBe("ok"); + expect(result.database).toBe("connected"); + }); + + it("should return error if database is disconnected", async () => { + mockDb.execute.mockRejectedValue(new Error("DB Error")); + const result = await controller.check(); + expect(result.status).toBe("error"); + expect(result.database).toBe("disconnected"); + expect(result.message).toBe("DB Error"); + }); +}); diff --git a/backend/src/media/media.service.spec.ts b/backend/src/media/media.service.spec.ts new file mode 100644 index 0000000..4b6fde9 --- /dev/null +++ b/backend/src/media/media.service.spec.ts @@ -0,0 +1,94 @@ +import * as fs from "node:fs/promises"; +import { BadRequestException, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Test, TestingModule } from "@nestjs/testing"; +import ffmpeg from "fluent-ffmpeg"; +import sharp from "sharp"; +import { MediaService } from "./media.service"; + +jest.mock("sharp"); +jest.mock("fluent-ffmpeg"); +jest.mock("node:fs/promises"); +jest.mock("uuid", () => ({ v4: () => "mock-uuid" })); + +describe("MediaService", () => { + let service: MediaService; + + const mockSharp = { + metadata: jest.fn().mockResolvedValue({ width: 100, height: 100 }), + webp: jest.fn().mockReturnThis(), + toBuffer: jest.fn().mockResolvedValue(Buffer.from("processed")), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.spyOn(Logger.prototype, "error").mockImplementation(() => {}); + jest.spyOn(Logger.prototype, "warn").mockImplementation(() => {}); + (sharp as unknown as jest.Mock).mockReturnValue(mockSharp); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MediaService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key) => { + if (key === "CLAMAV_HOST") return "localhost"; + if (key === "CLAMAV_PORT") return 3310; + }), + }, + }, + ], + }).compile(); + + service = module.get(MediaService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("processImage", () => { + it("should process an image to webp", async () => { + const result = await service.processImage(Buffer.from("input"), "webp"); + expect(result.extension).toBe("webp"); + expect(result.buffer).toEqual(Buffer.from("processed")); + expect(mockSharp.webp).toHaveBeenCalled(); + }); + + it("should throw BadRequestException on error", async () => { + mockSharp.toBuffer.mockRejectedValue(new Error("Sharp error")); + await expect(service.processImage(Buffer.from("input"))).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe("processVideo", () => { + it("should process a video", async () => { + const mockFfmpegCommand = { + toFormat: jest.fn().mockReturnThis(), + videoCodec: jest.fn().mockReturnThis(), + audioCodec: jest.fn().mockReturnThis(), + outputOptions: jest.fn().mockReturnThis(), + on: jest.fn().mockImplementation(function (event, cb) { + if (event === "end") setTimeout(cb, 0); + return this; + }), + save: jest.fn().mockReturnThis(), + }; + (ffmpeg as unknown as jest.Mock).mockReturnValue(mockFfmpegCommand); + (fs.readFile as jest.Mock).mockResolvedValue(Buffer.from("processed-video")); + (fs.writeFile as jest.Mock).mockResolvedValue(undefined); + (fs.unlink as jest.Mock).mockResolvedValue(undefined); + + const result = await service.processVideo( + Buffer.from("video-input"), + "webm", + ); + + expect(result.extension).toBe("webm"); + expect(result.buffer).toEqual(Buffer.from("processed-video")); + }); + }); +}); diff --git a/backend/src/reports/reports.service.spec.ts b/backend/src/reports/reports.service.spec.ts new file mode 100644 index 0000000..e42ca14 --- /dev/null +++ b/backend/src/reports/reports.service.spec.ts @@ -0,0 +1,88 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { DatabaseService } from "../database/database.service"; +import { CreateReportDto } from "./dto/create-report.dto"; +import { ReportsService } from "./reports.service"; + +describe("ReportsService", () => { + let service: ReportsService; + + const mockDb = { + insert: jest.fn(), + values: jest.fn(), + returning: jest.fn(), + select: jest.fn(), + from: jest.fn(), + orderBy: jest.fn(), + limit: jest.fn(), + offset: jest.fn(), + update: jest.fn(), + set: jest.fn(), + where: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const chain = { + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + }; + + const mockImplementation = () => Object.assign(Promise.resolve([]), chain); + for (const mock of Object.values(chain)) { + mock.mockImplementation(mockImplementation); + } + Object.assign(mockDb, chain); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReportsService, + { provide: DatabaseService, useValue: { db: mockDb } }, + ], + }).compile(); + + service = module.get(ReportsService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("create", () => { + it("should create a report", async () => { + const reporterId = "u1"; + const data: CreateReportDto = { contentId: "c1", reason: "spam" }; + mockDb.returning.mockResolvedValue([{ id: "r1", ...data, reporterId }]); + + const result = await service.create(reporterId, data); + + expect(result.id).toBe("r1"); + expect(mockDb.insert).toHaveBeenCalled(); + }); + }); + + describe("findAll", () => { + it("should return reports", async () => { + mockDb.offset.mockResolvedValue([{ id: "r1" }]); + const result = await service.findAll(10, 0); + expect(result).toHaveLength(1); + }); + }); + + describe("updateStatus", () => { + it("should update report status", async () => { + mockDb.returning.mockResolvedValue([{ id: "r1", status: "resolved" }]); + const result = await service.updateStatus("r1", "resolved"); + expect(result[0].status).toBe("resolved"); + }); + }); +}); diff --git a/backend/src/sessions/sessions.service.spec.ts b/backend/src/sessions/sessions.service.spec.ts new file mode 100644 index 0000000..7972ac0 --- /dev/null +++ b/backend/src/sessions/sessions.service.spec.ts @@ -0,0 +1,124 @@ +jest.mock("@noble/post-quantum/ml-kem.js", () => ({ + ml_kem768: { + keygen: jest.fn(), + encapsulate: jest.fn(), + decapsulate: jest.fn(), + }, +})); + +jest.mock("jose", () => ({ + SignJWT: jest.fn(), + jwtVerify: jest.fn(), +})); + +import { UnauthorizedException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { CryptoService } from "../crypto/crypto.service"; +import { DatabaseService } from "../database/database.service"; +import { SessionsService } from "./sessions.service"; + +describe("SessionsService", () => { + let service: SessionsService; + + const mockDb = { + insert: jest.fn(), + select: jest.fn(), + update: jest.fn(), + }; + + const mockCryptoService = { + generateJwt: jest.fn().mockResolvedValue("mock-jwt"), + hashIp: jest.fn().mockResolvedValue("mock-ip-hash"), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const chain = { + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + // biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes + then: jest.fn().mockImplementation(function (cb) { + return Promise.resolve(this).then(cb); + }), + }; + + const mockImplementation = () => Object.assign(Promise.resolve([]), chain); + for (const mock of Object.values(chain)) { + if (mock !== chain.then) { + mock.mockImplementation(mockImplementation); + } + } + Object.assign(mockDb, chain); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SessionsService, + { provide: DatabaseService, useValue: { db: mockDb } }, + { provide: CryptoService, useValue: mockCryptoService }, + ], + }).compile(); + + service = module.get(SessionsService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("createSession", () => { + it("should create a session", async () => { + mockDb.returning.mockResolvedValue([{ id: "s1", refreshToken: "mock-jwt" }]); + const result = await service.createSession("u1", "agent", "1.2.3.4"); + expect(result.id).toBe("s1"); + expect(mockCryptoService.generateJwt).toHaveBeenCalledWith( + { sub: "u1", type: "refresh" }, + "7d", + ); + }); + }); + + describe("refreshSession", () => { + it("should refresh a valid session", async () => { + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 1); + const session = { id: "s1", userId: "u1", expiresAt, isValid: true }; + + mockDb.where.mockImplementation(() => { + const chain = { + limit: jest.fn().mockReturnThis(), + returning: jest + .fn() + .mockResolvedValue([{ ...session, refreshToken: "new-jwt" }]), + // biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes + then: jest.fn().mockResolvedValue(session), + }; + return Object.assign(Promise.resolve([session]), chain); + }); + + const result = await service.refreshSession("old-jwt"); + expect(result.refreshToken).toBe("new-jwt"); + }); + + it("should throw UnauthorizedException if session not found", async () => { + mockDb.where.mockImplementation(() => { + const chain = { + limit: jest.fn().mockReturnThis(), + // biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes + then: jest.fn().mockResolvedValue(null), + }; + return Object.assign(Promise.resolve([]), chain); + }); + await expect(service.refreshSession("invalid")).rejects.toThrow( + UnauthorizedException, + ); + }); + }); +}); diff --git a/backend/src/tags/tags.service.spec.ts b/backend/src/tags/tags.service.spec.ts new file mode 100644 index 0000000..145451b --- /dev/null +++ b/backend/src/tags/tags.service.spec.ts @@ -0,0 +1,76 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { DatabaseService } from "../database/database.service"; +import { TagsService } from "./tags.service"; + +describe("TagsService", () => { + let service: TagsService; + + const mockDb = { + select: jest.fn(), + from: jest.fn(), + leftJoin: jest.fn(), + where: jest.fn(), + groupBy: jest.fn(), + orderBy: jest.fn(), + limit: jest.fn(), + offset: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const chain = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + }; + + const mockImplementation = () => Object.assign(Promise.resolve([]), chain); + for (const mock of Object.values(chain)) { + mock.mockImplementation(mockImplementation); + } + Object.assign(mockDb, chain); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TagsService, + { provide: DatabaseService, useValue: { db: mockDb } }, + ], + }).compile(); + + service = module.get(TagsService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("findAll", () => { + it("should return tags", async () => { + const mockTags = [{ id: "1", name: "tag1" }]; + mockDb.offset.mockResolvedValue(mockTags); + + const result = await service.findAll({ limit: 10, offset: 0 }); + + expect(result).toEqual(mockTags); + expect(mockDb.select).toHaveBeenCalled(); + }); + + it("should handle popular sort", async () => { + mockDb.offset.mockResolvedValue([{ id: "1", name: "tag1", count: 5 }]); + const result = await service.findAll({ + limit: 10, + offset: 0, + sortBy: "popular", + }); + expect(result[0].count).toBe(5); + expect(mockDb.leftJoin).toHaveBeenCalled(); + expect(mockDb.groupBy).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts new file mode 100644 index 0000000..87f17a3 --- /dev/null +++ b/backend/src/users/users.service.spec.ts @@ -0,0 +1,114 @@ +jest.mock("@noble/post-quantum/ml-kem.js", () => ({ + ml_kem768: { + keygen: jest.fn(), + encapsulate: jest.fn(), + decapsulate: jest.fn(), + }, +})); + +jest.mock("jose", () => ({ + SignJWT: jest.fn(), + jwtVerify: jest.fn(), +})); + +import { Test, TestingModule } from "@nestjs/testing"; +import { CryptoService } from "../crypto/crypto.service"; +import { DatabaseService } from "../database/database.service"; +import { UsersService } from "./users.service"; + +describe("UsersService", () => { + let service: UsersService; + + const mockDb = { + insert: jest.fn(), + select: jest.fn(), + update: jest.fn(), + transaction: jest.fn().mockImplementation((cb) => cb(mockDb)), + }; + + const mockCryptoService = { + getPgpEncryptionKey: jest.fn().mockReturnValue("mock-pgp-key"), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const chain = { + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + // biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes + then: jest.fn().mockImplementation(function (cb) { + return Promise.resolve(this).then(cb); + }), + }; + + const mockImplementation = () => Object.assign(Promise.resolve([]), chain); + for (const mock of Object.values(chain)) { + if (mock !== chain.then) { + mock.mockImplementation(mockImplementation); + } + } + Object.assign(mockDb, chain); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { provide: DatabaseService, useValue: { db: mockDb } }, + { provide: CryptoService, useValue: mockCryptoService }, + ], + }).compile(); + + service = module.get(UsersService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("create", () => { + it("should create a user", async () => { + const data = { + username: "u1", + email: "e1", + passwordHash: "p1", + emailHash: "eh1", + }; + mockDb.returning.mockResolvedValue([{ uuid: "uuid1", ...data }]); + + const result = await service.create(data); + + expect(result.uuid).toBe("uuid1"); + expect(mockDb.insert).toHaveBeenCalled(); + }); + }); + + describe("findOne", () => { + it("should find a user", async () => { + mockDb.limit.mockResolvedValue([{ uuid: "uuid1" }]); + const result = await service.findOne("uuid1"); + expect(result.uuid).toBe("uuid1"); + }); + + it("should return null if not found", async () => { + mockDb.limit.mockResolvedValue([]); + const result = await service.findOne("uuid1"); + expect(result).toBeNull(); + }); + }); + + describe("update", () => { + it("should update a user", async () => { + mockDb.returning.mockResolvedValue([{ uuid: "uuid1", displayName: "New" }]); + const result = await service.update("uuid1", { displayName: "New" }); + expect(result[0].displayName).toBe("New"); + }); + }); +});