diff --git a/tests/integration/chat.test.ts b/tests/integration/chat.test.ts new file mode 100644 index 0000000..22a3c44 --- /dev/null +++ b/tests/integration/chat.test.ts @@ -0,0 +1,255 @@ +import { auth, firestore, bucket, realtimeDb } from "@/config"; +import { beforeEach, afterAll, vi } from "vitest"; +import { app } from "@/main"; +import { faker } from "@faker-js/faker"; +import { initializeApp } from "firebase/app"; +import { + connectAuthEmulator, + getAuth, + signInWithCustomToken, +} from "firebase/auth"; +import supertest from "supertest"; +import { beforeAll, describe, expect, test } from "vitest"; +import { ChatService } from "@/module/chat/chat.service"; +import { PresenceService } from "@/module/chat/presence.service"; +import { AuthService } from "@/module/auth/auth.service"; + +const presenceService = new PresenceService({ realtimeDb }); +const chatService = new ChatService({ firestore, bucket, presenceService }); +const authService = new AuthService({ auth, firestore }); + +async function cleanupCollections() { + const collections = ["chat_rooms", "chat_messages", "learners", "tutors"]; + for (const collection of collections) { + const snapshot = await firestore.collection(collection).get(); + const batch = firestore.batch(); + snapshot.docs.forEach((doc) => { + batch.delete(doc.ref); + }); + await batch.commit(); + } +} + +const firebaseApp = initializeApp({ + apiKey: "test-api-key", + authDomain: "localhost", + projectId: "tutortoise-test", +}); +const clientAuth = getAuth(firebaseApp); +connectAuthEmulator(clientAuth, "http://localhost:9099"); + +async function getIdToken(userId: string) { + const customToken = await auth.createCustomToken(userId); + const { user } = await signInWithCustomToken(clientAuth, customToken); + return user.getIdToken(); +} + +async function createTestUser(role: "learner" | "tutor") { + const userData = { + name: faker.person.fullName(), + email: faker.internet.email(), + password: faker.internet.password(), + }; + + let result; + try { + if (role === "learner") { + result = await authService.registerLearner( + userData.name, + userData.email, + userData.password, + ); + + const learnerDoc = await firestore + .collection("learners") + .doc(result.userId) + .get(); + if (!learnerDoc.exists) { + throw new Error("Learner document was not created"); + } + } else { + result = await authService.registerTutor( + userData.name, + userData.email, + userData.password, + ); + + const tutorDoc = await firestore + .collection("tutors") + .doc(result.userId) + .get(); + if (!tutorDoc.exists) { + throw new Error("Tutor document was not created"); + } + } + + const idToken = await getIdToken(result.userId); + return { id: result.userId, token: idToken, name: userData.name }; + } catch (error) { + console.error(`Failed to create ${role}:`, error); + throw error; + } +} + +describe("Chat Features", () => { + let learner: { id: string; token: string; name: string }; + let tutor: { id: string; token: string; name: string }; + let roomId: string; + + beforeAll(async () => { + await cleanupCollections(); + + [learner, tutor] = await Promise.all([ + createTestUser("learner"), + createTestUser("tutor"), + ]); + }); + + beforeEach(async () => { + try { + const room = await chatService.createRoom(learner.id, tutor.id); + roomId = room.id; + } catch (error) { + console.error("Failed to create chat room:", error); + throw error; + } + }); + + afterAll(async () => { + await cleanupCollections(); + }); + + describe("Create chat room", () => { + test("should create a new chat room", async () => { + const res = await supertest(app) + .post("/api/v1/chat/rooms") + .set("Authorization", `Bearer ${learner.token}`) + .send({ + learnerId: learner.id, + tutorId: tutor.id, + }) + .expect(201); + + expect(res.body.status).toBe("success"); + expect(res.body.data).toHaveProperty("id"); + expect(res.body.data.learnerId).toBe(learner.id); + expect(res.body.data.tutorId).toBe(tutor.id); + expect(res.body.data.learnerName).toBe(learner.name); + expect(res.body.data.tutorName).toBe(tutor.name); + }); + + test("should not allow creating room for other users", async () => { + const otherLearnerId = faker.string.uuid(); + await supertest(app) + .post("/api/v1/chat/rooms") + .set("Authorization", `Bearer ${learner.token}`) + .send({ + learnerId: otherLearnerId, + tutorId: tutor.id, + }) + .expect(403); + }); + + test("should require authentication", async () => { + await supertest(app) + .post("/api/v1/chat/rooms") + .send({ + learnerId: learner.id, + tutorId: tutor.id, + }) + .expect(401); + }); + }); + + describe("Send and receive messages", () => { + test("should send an image message", async () => { + const response = await supertest(app) + .post(`/api/v1/chat/rooms/${roomId}/messages/image`) + .set("Authorization", `Bearer ${learner.token}`) + .attach("image", "tests/integration/pictures/bocchi.png"); + + expect(response.status).toBe(201); + expect(response.body.status).toBe("success"); + expect(response.body.data).toHaveProperty("id"); + expect(response.body.data.type).toBe("image"); + }); + + test("should send a text message", async () => { + const message = { + content: faker.lorem.sentence(), + }; + + const res = await supertest(app) + .post(`/api/v1/chat/rooms/${roomId}/messages/text`) + .set("Authorization", `Bearer ${learner.token}`) + .send(message) + .expect(201); + + expect(res.body.status).toBe("success"); + expect(res.body.data).toHaveProperty("id"); + expect(res.body.data.content).toBe(message.content); + expect(res.body.data.type).toBe("text"); + expect(res.body.data.senderId).toBe(learner.id); + }); + + test("should get room messages", async () => { + const message = { + content: "Test message content", + }; + + await supertest(app) + .post(`/api/v1/chat/rooms/${roomId}/messages/text`) + .set("Authorization", `Bearer ${learner.token}`) + .send(message) + .expect(201); + + const res = await supertest(app) + .get(`/api/v1/chat/rooms/${roomId}/messages`) + .set("Authorization", `Bearer ${learner.token}`) + .expect(200); + + expect(res.body.status).toBe("success"); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data.length).toBeGreaterThan(0); + expect(res.body.data[0]).toHaveProperty("id"); + expect(res.body.data[0].content).toBe(message.content); + }); + + test("should paginate messages", async () => { + const messages = [ + { type: "text" as const, content: "First message" }, + { type: "text" as const, content: "Second message" }, + ]; + + for (const msg of messages) { + await supertest(app) + .post(`/api/v1/chat/rooms/${roomId}/messages`) + .set("Authorization", `Bearer ${learner.token}`) + .send(msg); + } + + const firstRes = await supertest(app) + .get(`/api/v1/chat/rooms/${roomId}/messages`) + .set("Authorization", `Bearer ${learner.token}`) + .query({ limit: "1" }) + .expect(200); + + expect(firstRes.body.data).toHaveLength(1); + const firstMessage = firstRes.body.data[0]; + + const secondRes = await supertest(app) + .get(`/api/v1/chat/rooms/${roomId}/messages`) + .set("Authorization", `Bearer ${learner.token}`) + .query({ + before: new Date(firstMessage.sentAt).toISOString(), + limit: "1", + }) + .expect(200); + + expect(secondRes.body.data).toHaveLength(1); + expect(new Date(secondRes.body.data[0].sentAt).getTime()).toBeLessThan( + new Date(firstMessage.sentAt).getTime(), + ); + }); + }); +}); diff --git a/tests/services/chat.test.ts b/tests/services/chat.test.ts index 9b096f0..c875a84 100644 --- a/tests/services/chat.test.ts +++ b/tests/services/chat.test.ts @@ -1,33 +1,57 @@ import { faker } from "@faker-js/faker"; import { ChatService } from "@/module/chat/chat.service"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; describe("ChatService", () => { - const mockBatch = { - update: vi.fn(), - commit: vi.fn(), - }; - - const mockFirestore = { - collection: vi.fn(), - runTransaction: vi.fn(), - batch: vi.fn().mockReturnValue(mockBatch), - }; - - const mockBucket = { - file: vi.fn(), - }; - - const mockPresenceService = { - updateUserPresence: vi.fn(), - updateTypingStatus: vi.fn(), - getRoomPresence: vi.fn(), - }; - - const chatService = new ChatService({ - firestore: mockFirestore as any, - bucket: mockBucket as any, - presenceService: mockPresenceService as any, + let mockFirestore: any; + let mockBucket: any; + let mockPresenceService: any; + let chatService: ChatService; + + beforeEach(() => { + mockFirestore = { + collection: vi.fn().mockReturnValue({ + doc: vi.fn().mockReturnValue({ + get: vi.fn(), + update: vi.fn(), + set: vi.fn(), + }), + where: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + get: vi.fn().mockResolvedValue({ empty: true, docs: [] }), + }), + }), + add: vi.fn().mockReturnValue({ id: "mock-id" }), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + startAfter: vi.fn().mockReturnThis(), + get: vi.fn(), + }), + batch: vi.fn().mockReturnValue({ + set: vi.fn(), + update: vi.fn(), + commit: vi.fn(), + }), + }; + + mockBucket = { + file: vi.fn().mockReturnValue({ + save: vi.fn(), + publicUrl: vi.fn(), + }), + }; + + mockPresenceService = { + updateUserPresence: vi.fn(), + updateTypingStatus: vi.fn(), + getRoomPresence: vi.fn(), + }; + + chatService = new ChatService({ + firestore: mockFirestore, + bucket: mockBucket, + presenceService: mockPresenceService, + }); }); describe("createRoom", () => { @@ -46,37 +70,25 @@ describe("ChatService", () => { data: () => ({ name: "Test Tutor" }), }; - const mockSet = vi.fn(); - - mockFirestore.collection.mockImplementation((name) => ({ - doc: () => ({ + mockFirestore.collection.mockImplementation((name: string) => ({ + doc: (id?: string) => ({ get: () => Promise.resolve( name === "learners" ? mockLearnerDoc : mockTutorDoc, ), - set: mockSet, + set: vi.fn(), id: roomId, }), where: () => ({ where: () => ({ - limit: () => ({ - get: () => Promise.resolve({ empty: true }), - }), + get: () => Promise.resolve({ empty: true }), }), }), + add: vi.fn().mockResolvedValue({ id: roomId }), })); const result = await chatService.createRoom(learnerId, tutorId); - expect(mockSet).toHaveBeenCalledWith( - expect.objectContaining({ - learnerId, - tutorId, - learnerName: "Test Learner", - tutorName: "Test Tutor", - }), - ); - expect(result).toMatchObject({ id: roomId, learnerId, @@ -134,22 +146,14 @@ describe("ChatService", () => { }), }; - const mockTransaction = { - set: vi.fn(), - update: vi.fn(), - }; - mockFirestore.collection.mockImplementation(() => ({ doc: () => ({ get: () => Promise.resolve(mockRoom), - id: messageId, + update: vi.fn(), }), + add: vi.fn().mockResolvedValue({ id: messageId }), })); - mockFirestore.runTransaction.mockImplementation((fn) => - Promise.resolve(fn(mockTransaction)), - ); - const result = await chatService.sendMessage( roomId, senderId, @@ -157,8 +161,6 @@ describe("ChatService", () => { message, ); - expect(mockTransaction.set).toHaveBeenCalled(); - expect(mockTransaction.update).toHaveBeenCalled(); expect(result).toMatchObject({ id: messageId, roomId, @@ -192,7 +194,7 @@ describe("ChatService", () => { }), }; - mockFirestore.collection.mockImplementation((name) => ({ + mockFirestore.collection.mockImplementation((name: string) => ({ doc: () => ({ get: () => Promise.resolve(mockRoom), }),