From bc0185156851ea661ef4b34497f81b2ce9c4af12 Mon Sep 17 00:00:00 2001 From: Dasha Date: Mon, 9 Mar 2026 14:36:02 +0100 Subject: [PATCH 1/2] qa: videocall --- .../videoCall/videoCall.controller.test.ts | 212 ++++++++++++++++++ .../videoCall/videoCall.service.test.ts | 159 +++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 server/__tests__/videoCall/videoCall.controller.test.ts create mode 100644 server/__tests__/videoCall/videoCall.service.test.ts diff --git a/server/__tests__/videoCall/videoCall.controller.test.ts b/server/__tests__/videoCall/videoCall.controller.test.ts new file mode 100644 index 0000000..93d2bd6 --- /dev/null +++ b/server/__tests__/videoCall/videoCall.controller.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import { VideoCallController } from "../../src/controllers/videoCall.controller.js"; +import { NextFunction, Response } from "express"; + +type MockResponse = { + status: jest.Mock; + json: jest.Mock; + sendStatus: jest.Mock; +}; + +type MockRequest = { + body?: Record; + params?: Record; + auth?: { + userId?: string; + role?: string; + }; +}; + +const mockVideoCallService = { + startCall: jest.fn<() => Promise>(), + incomingCall: jest.fn<() => Promise>(), + acceptCall: jest.fn<() => Promise>(), + declineCall: jest.fn<() => Promise>(), + endCall: jest.fn<() => Promise>(), +}; + +describe("VideoCallController", () => { + let controller: VideoCallController; + let mockResponse: MockResponse; + let mockNext: NextFunction; + + beforeEach(() => { + controller = new VideoCallController( + mockVideoCallService as unknown as VideoCallController["videoCallService"], + ); + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + sendStatus: jest.fn().mockReturnThis(), + }; + mockNext = jest.fn(); + jest.clearAllMocks(); + }); + + describe("startVideoCallController", () => { + it("should start video call successfully", async () => { + const req: MockRequest = { + body: { + teacherId: "t1", + studentId: "s1", + streamCallId: "stream1", + expiresAt: new Date(), + }, + auth: { userId: "t1", role: "teacher" }, + }; + + const mockCall = { id: "call1", status: "ringing" }; + mockVideoCallService.startCall.mockResolvedValue(mockCall); + + await controller.startVideoCallController( + req as never, + mockResponse as unknown as Response, + mockNext, + ); + + expect(mockResponse.status).toHaveBeenCalledWith(201); + expect(mockResponse.json).toHaveBeenCalledWith(mockCall); + }); + + it("should return 401 if no auth", async () => { + const req: MockRequest = { body: {}, auth: undefined }; + await controller.startVideoCallController( + req as never, + mockResponse as unknown as Response, + mockNext, + ); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(401); + }); + + it("should return 403 for invalid role", async () => { + const req: MockRequest = { + body: {}, + auth: { userId: "u1", role: "admin" }, + }; + await controller.startVideoCallController( + req as never, + mockResponse as unknown as Response, + mockNext, + ); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(403); + }); + }); + + describe("incomingCallController", () => { + it("should get incoming call for student", async () => { + const req: MockRequest = { auth: { userId: "s1", role: "student" } }; + const mockCall = { id: "call1", status: "ringing" }; + mockVideoCallService.incomingCall.mockResolvedValue(mockCall); + + await controller.incomingCallController( + req as never, + mockResponse as unknown as Response, + mockNext, + ); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(mockCall); + }); + + it("should return 403 for invalid role", async () => { + const req: MockRequest = { auth: { userId: "u1", role: "admin" } }; + await controller.incomingCallController( + req as never, + mockResponse as unknown as Response, + mockNext, + ); + expect(mockResponse.status).toHaveBeenCalledWith(403); + }); + }); + + describe("acceptCallController", () => { + it("should accept call successfully", async () => { + const req: MockRequest = { + params: { callId: "call1" }, + auth: { userId: "s1", role: "student" }, + }; + const mockCall = { id: "call1", status: "accepted" }; + mockVideoCallService.acceptCall.mockResolvedValue(mockCall); + + await controller.acceptCallController( + req as never, + mockResponse as unknown as Response, + mockNext, + ); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(mockCall); + }); + + it("should return 404 if call not found", async () => { + const req: MockRequest = { + params: { callId: "call1" }, + auth: { userId: "s1", role: "student" }, + }; + mockVideoCallService.acceptCall.mockResolvedValue(null); + + await controller.acceptCallController( + req as never, + mockResponse as unknown as Response, + mockNext, + ); + expect(mockResponse.status).toHaveBeenCalledWith(404); + }); + }); + + describe("declineCallController", () => { + it("should decline call successfully", async () => { + const req: MockRequest = { + params: { callId: "call1" }, + auth: { userId: "s1", role: "student" }, + }; + const mockCall = { id: "call1", status: "declined" }; + mockVideoCallService.declineCall.mockResolvedValue(mockCall); + + await controller.declineCallController( + req as never, + mockResponse as unknown as Response, + mockNext, + ); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(mockCall); + }); + }); + + describe("endCallController", () => { + it("should end call successfully", async () => { + const req: MockRequest = { + params: { callId: "call1" }, + auth: { userId: "t1", role: "teacher" }, + }; + const mockCall = { id: "call1", status: "ended" }; + mockVideoCallService.endCall.mockResolvedValue(mockCall); + + await controller.endCallController( + req as never, + mockResponse as unknown as Response, + mockNext, + ); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(mockCall); + }); + + it("should handle service errors", async () => { + const req: MockRequest = { + params: { callId: "call1" }, + auth: { userId: "t1" }, + }; + const error = new Error("Service error"); + mockVideoCallService.endCall.mockRejectedValue(error); + + await controller.endCallController( + req as never, + mockResponse as unknown as Response, + mockNext, + ); + expect(mockNext).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/server/__tests__/videoCall/videoCall.service.test.ts b/server/__tests__/videoCall/videoCall.service.test.ts new file mode 100644 index 0000000..4633252 --- /dev/null +++ b/server/__tests__/videoCall/videoCall.service.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import { VideoCallService } from "../../src/services/video/videoCall.service.js"; +import { VideoCallCommand } from "../../src/repositories/commandRepositories/videoCall.command.js"; +import { VideoCallQuery } from "../../src/repositories/queryRepositories/videoCall.query.js"; +import { HttpError } from "../../src/utils/error.util.js"; +import { StartVideoCallInput } from "../../src/types/video/video.types.js"; + +const mockVideoCallCommand = { + startVideoCall: jest.fn<() => Promise>(), + acceptCallById: jest.fn<() => Promise>(), + declineCallById: jest.fn<() => Promise>(), + endCallById: jest.fn<() => Promise>(), + markExpiredCallAsMissed: jest.fn<() => Promise>(), +}; + +const mockVideoCallQuery = { + getVideoByStreamCallId: jest.fn<() => Promise>(), + getIncomingCallForStudent: jest.fn<() => Promise>(), + getVideoById: jest.fn<() => Promise>(), +}; + +jest.mock("../../src/db/schemes/studentSchema.js", () => ({ + StudentModel: {}, +})); +jest.mock("../../src/db/schemes/teacherSchema.js", () => ({ + TeacherModel: {}, +})); +jest.mock("../../src/db/schemes/videoCallSchema.js", () => ({ + VideoCallModel: {}, +})); +jest.mock("../../src/socket/io.holder.js", () => ({ getIO: () => null })); + +describe("VideoCallService", () => { + let service: VideoCallService; + + beforeEach(() => { + service = new VideoCallService( + mockVideoCallCommand as unknown as VideoCallCommand, + mockVideoCallQuery as unknown as VideoCallQuery, + ); + jest.clearAllMocks(); + }); + + describe("startCall", () => { + it("should throw error if not teacher", async () => { + const input: StartVideoCallInput = { + authRole: "student", + authUserId: "s1", + teacherId: "t1", + studentId: "s1", + streamCallId: "stream1", + expiresAt: new Date(), + }; + await expect(service.startCall(input)).rejects.toThrow( + new HttpError(403, "Only teachers can start calls"), + ); + }); + + it("should throw error if call already exists", async () => { + const input: StartVideoCallInput = { + authRole: "teacher", + authUserId: "t1", + teacherId: "t1", + studentId: "s1", + streamCallId: "stream1", + expiresAt: new Date(), + }; + mockVideoCallQuery.getVideoByStreamCallId.mockResolvedValue({ + id: "existing", + }); + await expect(service.startCall(input)).rejects.toThrow( + new HttpError(409, "This call already exists"), + ); + }); + }); + + describe("incomingCall", () => { + it("should return incoming call for student", async () => { + const mockCall = { + id: "call1", + expiresAt: new Date(Date.now() + 60000), + }; + mockVideoCallQuery.getIncomingCallForStudent.mockResolvedValue(mockCall); + + const result = await service.incomingCall({ + authUserId: "s1", + authRole: "student", + }); + expect(result).toEqual(mockCall); + }); + + it("should throw error if not student", async () => { + await expect( + service.incomingCall({ authUserId: "t1", authRole: "teacher" }), + ).rejects.toThrow( + new HttpError(403, "This endpoint is for students only"), + ); + }); + }); + + describe("acceptCall", () => { + it("should throw error if not student", async () => { + await expect( + service.acceptCall({ + callId: "call1", + authUserId: "t1", + authRole: "teacher", + }), + ).rejects.toThrow( + new HttpError(403, "Students only can accept the call"), + ); + }); + + it("should throw error if call not found", async () => { + mockVideoCallQuery.getVideoById.mockResolvedValue(null); + await expect( + service.acceptCall({ + callId: "call1", + authUserId: "s1", + authRole: "student", + }), + ).rejects.toThrow(new HttpError(404, "Call not found")); + }); + }); + + describe("declineCall", () => { + it("should throw error if not student", async () => { + await expect( + service.declineCall({ + callId: "call1", + authUserId: "t1", + authRole: "teacher", + }), + ).rejects.toThrow( + new HttpError(403, "Students only can decline the call"), + ); + }); + }); + + describe("endCall", () => { + it("should throw error if call not found", async () => { + mockVideoCallQuery.getVideoById.mockResolvedValue(null); + await expect( + service.endCall({ callId: "call1", authUserId: "t1" }), + ).rejects.toThrow(new HttpError(404, "Call not found")); + }); + + it("should throw error if user not participant", async () => { + const mockCall = { id: "call1", teacherId: "t1", studentId: "s1" }; + mockVideoCallQuery.getVideoById.mockResolvedValue(mockCall); + + await expect( + service.endCall({ callId: "call1", authUserId: "other" }), + ).rejects.toThrow( + new HttpError(403, "Only participants can end the call"), + ); + }); + }); +}); From 98b71e39f9d69f8eae4b21646510272a7892d155 Mon Sep 17 00:00:00 2001 From: Dasha Date: Mon, 9 Mar 2026 14:50:30 +0100 Subject: [PATCH 2/2] qa review --- .../review/review.integration.test.ts | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 server/__tests__/review/review.integration.test.ts diff --git a/server/__tests__/review/review.integration.test.ts b/server/__tests__/review/review.integration.test.ts new file mode 100644 index 0000000..0d66d5d --- /dev/null +++ b/server/__tests__/review/review.integration.test.ts @@ -0,0 +1,325 @@ +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, +} from "@jest/globals"; +import { + setupTestDatabase, + teardownTestDatabase, + clearTestDatabase, +} from "../setup/database.setup.js"; +import { + createTestStudent, + createTestTeacher, +} from "../helpers/test.helpers.js"; +import { StudentTypeDB } from "../../src/db/schemes/types/student.types.js"; +import { TeacherTypeDB } from "../../src/db/schemes/types/teacher.types.js"; +import { ReviewModel } from "../../src/db/schemes/review.schema.js"; +import { AppointmentModel } from "../../src/db/schemes/appointmentSchema.js"; +import { randomUUID } from "node:crypto"; + +describe("Review Integration Tests", () => { + let testStudent: StudentTypeDB; + let testTeacher: TeacherTypeDB; + + beforeAll(async () => { + await setupTestDatabase(); + }); + + afterAll(async () => { + await teardownTestDatabase(); + }); + + beforeEach(async () => { + await clearTestDatabase(); + testStudent = await createTestStudent(); + testTeacher = await createTestTeacher(); + }); + + describe("ReviewModel CRUD Operations", () => { + it("should create review in database", async () => { + const appointment = await AppointmentModel.create({ + id: randomUUID(), + studentId: testStudent.id, + teacherId: testTeacher.id, + lesson: "Math", + level: "Beginner", + teacher: testTeacher.id, + student: testStudent.id, + price: "25", + date: "2024-12-15", + time: "10:00", + description: "Test", + status: "approved" as const, + videoCall: "https://meet.google.com/test", + isRegularStudent: false, + weeklySchedule: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + const review = await ReviewModel.create({ + teacherId: testTeacher.id, + studentId: testStudent.id, + bookingId: appointment.id, + rating: 5, + review: "Excellent teacher!", + subject: "Mathematics", + studentName: `${testStudent.firstName} ${testStudent.lastName}`, + ...(testStudent.profileImageUrl && { + studentAvatar: testStudent.profileImageUrl, + }), + createdAt: new Date(), + updatedAt: new Date(), + }); + + expect(review.teacherId).toBe(testTeacher.id); + expect(review.studentId).toBe(testStudent.id); + expect(review.rating).toBe(5); + expect(review.subject).toBe("Mathematics"); + }); + + it("should find review by id", async () => { + const appointment = await AppointmentModel.create({ + id: randomUUID(), + studentId: testStudent.id, + teacherId: testTeacher.id, + lesson: "Math", + level: "Beginner", + teacher: testTeacher.id, + student: testStudent.id, + price: "25", + date: "2024-12-15", + time: "10:00", + description: "Test", + status: "approved" as const, + videoCall: "https://meet.google.com/test", + isRegularStudent: false, + weeklySchedule: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + const created = await ReviewModel.create({ + teacherId: testTeacher.id, + studentId: testStudent.id, + bookingId: appointment.id, + rating: 4, + review: "Good", + subject: "Math", + studentName: `${testStudent.firstName} ${testStudent.lastName}`, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const found = await ReviewModel.findById(created._id).exec(); + + expect(found?.teacherId).toBe(testTeacher.id); + expect(found?.rating).toBe(4); + }); + + it("should find reviews by teacher id", async () => { + const appointment = await AppointmentModel.create({ + id: randomUUID(), + studentId: testStudent.id, + teacherId: testTeacher.id, + lesson: "Math", + level: "Beginner", + teacher: testTeacher.id, + student: testStudent.id, + price: "25", + date: "2024-12-15", + time: "10:00", + description: "Test", + status: "approved" as const, + videoCall: "https://meet.google.com/test", + isRegularStudent: false, + weeklySchedule: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + await ReviewModel.create({ + teacherId: testTeacher.id, + studentId: testStudent.id, + bookingId: appointment.id, + rating: 5, + review: "Great!", + subject: "Math", + studentName: `${testStudent.firstName} ${testStudent.lastName}`, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const reviews = await ReviewModel.find({ + teacherId: testTeacher.id, + }).exec(); + + expect(reviews).toHaveLength(1); + expect(reviews[0].teacherId).toBe(testTeacher.id); + }); + + it("should calculate average rating", async () => { + const appointment1 = await AppointmentModel.create({ + id: randomUUID(), + studentId: testStudent.id, + teacherId: testTeacher.id, + lesson: "Math", + level: "Beginner", + teacher: testTeacher.id, + student: testStudent.id, + price: "25", + date: "2024-12-15", + time: "10:00", + description: "Test", + status: "approved" as const, + videoCall: "https://meet.google.com/test1", + isRegularStudent: false, + weeklySchedule: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + const appointment2 = await AppointmentModel.create({ + id: randomUUID(), + studentId: testStudent.id, + teacherId: testTeacher.id, + lesson: "Math", + level: "Beginner", + teacher: testTeacher.id, + student: testStudent.id, + price: "25", + date: "2024-12-15", + time: "11:00", + description: "Test", + status: "approved" as const, + videoCall: "https://meet.google.com/test2", + isRegularStudent: false, + weeklySchedule: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + await ReviewModel.create({ + teacherId: testTeacher.id, + studentId: testStudent.id, + bookingId: appointment1.id, + rating: 5, + review: "Excellent", + subject: "Math", + studentName: `${testStudent.firstName} ${testStudent.lastName}`, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await ReviewModel.create({ + teacherId: testTeacher.id, + studentId: testStudent.id, + bookingId: appointment2.id, + rating: 3, + review: "Good", + subject: "Math", + studentName: `${testStudent.firstName} ${testStudent.lastName}`, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await ReviewModel.aggregate([ + { $match: { teacherId: testTeacher.id } }, + { + $group: { + _id: "$teacherId", + averageRating: { $avg: "$rating" }, + totalReviews: { $sum: 1 }, + }, + }, + ]); + + expect(result[0].averageRating).toBe(4); + expect(result[0].totalReviews).toBe(2); + }); + + it("should delete review", async () => { + const appointment = await AppointmentModel.create({ + id: randomUUID(), + studentId: testStudent.id, + teacherId: testTeacher.id, + lesson: "Math", + level: "Beginner", + teacher: testTeacher.id, + student: testStudent.id, + price: "25", + date: "2024-12-15", + time: "10:00", + description: "Test", + status: "approved" as const, + videoCall: "https://meet.google.com/test", + isRegularStudent: false, + weeklySchedule: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + const created = await ReviewModel.create({ + teacherId: testTeacher.id, + studentId: testStudent.id, + bookingId: appointment.id, + rating: 5, + review: "Great", + subject: "Math", + studentName: `${testStudent.firstName} ${testStudent.lastName}`, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await ReviewModel.findByIdAndDelete(created._id).exec(); + + const deleted = await ReviewModel.findById(created._id).exec(); + expect(deleted).toBeNull(); + }); + + it("should prevent duplicate reviews for same appointment", async () => { + const appointment = await AppointmentModel.create({ + id: randomUUID(), + studentId: testStudent.id, + teacherId: testTeacher.id, + lesson: "Math", + level: "Beginner", + teacher: testTeacher.id, + student: testStudent.id, + price: "25", + date: "2024-12-15", + time: "10:00", + description: "Test", + status: "approved" as const, + videoCall: "https://meet.google.com/test", + isRegularStudent: false, + weeklySchedule: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + await ReviewModel.create({ + teacherId: testTeacher.id, + studentId: testStudent.id, + bookingId: appointment.id, + rating: 5, + review: "First review", + subject: "Math", + studentName: `${testStudent.firstName} ${testStudent.lastName}`, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const existingReview = await ReviewModel.findOne({ + bookingId: appointment.id, + }).exec(); + + expect(existingReview).not.toBeNull(); + expect(existingReview?.bookingId).toBe(appointment.id); + }); + }); +});