diff --git a/src/jobs/poll/poll.activate.handler.ts b/src/jobs/poll/poll.activate.handler.ts index 95bd087..a6463f5 100644 --- a/src/jobs/poll/poll.activate.handler.ts +++ b/src/jobs/poll/poll.activate.handler.ts @@ -9,55 +9,106 @@ import { createLimit } from '#core/utils/Limiter'; import prisma from '#core/prisma'; import { logger } from '#core/logger'; -import { sendSseNotification } from '#sse/sseEmitter'; -import type { NotificationPayload } from '#sse/types'; import { isPast, isWithinInterval, getDelayMs } from './poll.time.utils'; +import { getUserIdsForApartment } from '#modules/auth/auth.service'; +import { getAdminIdByApartmentName } from '#modules/auth/auth.repo'; +import { createAndSendNotification } from '#core/utils/notificationHelper'; + const limit = createLimit(5); +/** + * @description Poll 시작 시 주민 + 관리자에게 종료 알림 전송 + */ +export const sendPollStartNotifications = async (pollId: string) => { + // Poll → Board → Apartment 정보 조회 + const poll = await prisma.poll.findUnique({ + where: { id: pollId }, + select: { + board: { + select: { + apartment: { select: { apartmentName: true } }, + }, + }, + }, + }); + + if (!poll?.board?.apartment?.apartmentName) { + logger.polls.warn(`Poll ${pollId}: 알림 전송 실패 — apartmentName 조회 실패`); + return; + } + + const apartmentName = poll.board.apartment.apartmentName; + + // 주민 ID 조회 (service → repo 경유) + const residentUserIds = (await getUserIdsForApartment(apartmentName)).map((u) => u.id); + + // 관리자 조회 (아파트의 adminId 기반) + let adminIds: string[] = []; + try { + const aptAdmin = await getAdminIdByApartmentName(apartmentName); + const adminId = aptAdmin?.adminId ? String(aptAdmin.adminId) : null; + adminIds = adminId ? [adminId] : []; + } catch (e) { + logger.polls.warn(`Poll ${pollId}: 관리자 조회 실패 — ${e instanceof Error ? e.message : String(e)}`); + } + + // 중복 제거 + const targetIds = Array.from(new Set([...residentUserIds, ...adminIds])); + + if (targetIds.length === 0) { + logger.polls.debug(`Poll ${pollId}: 알림 전송 대상 없음`); + return; + } + + const content = '새로운 투표가 시작되었습니다.'; + + // 동시성 제한 비동기 병렬 전송 (best-effort) + const tasks = targetIds.map((uid) => + limit(async () => { + try { + await createAndSendNotification( + { + content, + notificationType: 'POLL_REG', + recipientId: uid, + pollId, + }, + uid + ); + } catch { + // 전송 실패 무시 (best-effort) + } + }) + ); + + // limit 내부에서 실패가 발생해도 전체 흐름을 깨지 않도록 allSettled + await Promise.allSettled(tasks); + + logger.polls.debug(`Poll ${pollId}: 알림 전송 완료 (총 ${targetIds.length}명)`); +}; + /** * 개별 Poll 활성화 처리 * @param pollId - 활성화 대상 Poll ID * @param userId - Poll 작성자 ID */ -export const activatePoll = async (pollId: string, userId: string): Promise => { +export const activatePoll = async (pollId: string): Promise => { try { // 상태 변경 - const updated = await prisma.poll.update({ + await prisma.poll.update({ where: { id: pollId }, data: { status: 'IN_PROGRESS' }, }); - // Notification 생성 - const notification = await prisma.notification.create({ - data: { - content: '새로운 투표가 시작되었습니다.', - notificationType: 'POLL_REG', - pollId: updated.id, - recipientId: userId, - }, - }); - - // SSE 전송 - const payload: NotificationPayload = { - notificationId: notification.id, - content: notification.content, - notificationType: notification.notificationType, - notifiedAt: notification.notifiedAt.toISOString(), - isChecked: notification.isChecked, - complaintId: notification.complaintId, - noticeId: notification.noticeId, - pollId: notification.pollId, - }; - - sendSseNotification(payload); + // 알림 함수 호출 + await sendPollStartNotifications(pollId); logger.polls.debug(`투표 ${pollId} 활성화 완료`); } catch (error) { logger.polls.error(error as Error, `투표 ${pollId} 활성화 중 오류 발생`); } }; - /** * 활성화 가능한 Poll 전체 처리 */ @@ -65,7 +116,7 @@ export const activateReadyPolls = async (): Promise => { try { const polls = await prisma.poll.findMany({ where: { status: 'PENDING' }, - select: { id: true, userId: true, startDate: true }, + select: { id: true, startDate: true }, }); if (polls.length === 0) { @@ -76,12 +127,12 @@ export const activateReadyPolls = async (): Promise => { const tasks = polls.map((poll) => limit(async () => { if (isPast(poll.startDate)) { - await activatePoll(poll.id, poll.userId); + await activatePoll(poll.id); } else if (isWithinInterval(poll.startDate)) { const delay = getDelayMs(poll.startDate); setTimeout(async () => { try { - await activatePoll(poll.id, poll.userId); + await activatePoll(poll.id); } catch (err) { logger.polls.error(err as Error, `예약된 투표 ${poll.id} 활성화 실패`); } @@ -91,7 +142,6 @@ export const activateReadyPolls = async (): Promise => { ); await Promise.all(tasks); - logger.polls.debug(`투표 활성화 처리 완료 (${polls.length}건 검사)`); } catch (error) { logger.polls.error(error as Error, '투표 활성화 처리 중 오류 발생'); diff --git a/src/jobs/poll/poll.expire.handler.ts b/src/jobs/poll/poll.expire.handler.ts index 0b749d3..5f02214 100644 --- a/src/jobs/poll/poll.expire.handler.ts +++ b/src/jobs/poll/poll.expire.handler.ts @@ -9,12 +9,88 @@ import { createLimit } from '#core/utils/Limiter'; import prisma from '#core/prisma'; import { logger } from '#core/logger'; -import { sendSseNotification } from '#sse/sseEmitter'; -import type { NotificationPayload } from '#core/sse/types'; import { isPast, isWithinInterval, getDelayMs } from './poll.time.utils'; +import { getUserIdsForApartment } from '#modules/auth/auth.service'; +import { getAdminIdByApartmentName } from '#modules/auth/auth.repo'; +import { createAndSendNotification } from '#core/utils/notificationHelper'; + const limit = createLimit(5); +/** + * @description Poll 종료 시 주민 + 관리자에게 종료 알림 전송 + */ +export const sendPollCloseNotifications = async (pollId: string) => { + // Poll → Board → Apartment 정보 조회 + const poll = await prisma.poll.findUnique({ + where: { id: pollId }, + select: { + board: { + select: { + apartment: { + select: { apartmentName: true }, + }, + }, + }, + }, + }); + + if (!poll?.board?.apartment?.apartmentName) { + logger.polls.warn(`Poll ${pollId}: 알림 전송 실패 — apartmentName 조회 실패`); + return; + } + + const apartmentName = poll.board.apartment.apartmentName; + + // 주민 ID 조회 (service → repo 경유) + const residentUserIds = (await getUserIdsForApartment(apartmentName)).map((u) => u.id); + + // 관리자 조회 (아파트의 adminId 기반) + const aptAdmin = await getAdminIdByApartmentName(apartmentName); + const adminId = + (aptAdmin as unknown as { adminId?: string | null }).adminId ?? + (aptAdmin as unknown as { admin?: { id: string } | null }).admin?.id ?? + null; + + const adminIds = adminId ? [adminId] : []; + + // 중복 제거 + const targetIds = Array.from(new Set([...residentUserIds, ...adminIds])); + + if (targetIds.length === 0) { + logger.polls.debug(`Poll ${pollId}: 종료 알림 대상 없음`); + return; + } + + const content = '투표가 종료되었습니다.'; + + // 동시성 제한 비동기 병렬 전송 (best-effort) + const tasks = targetIds.map((uid) => + limit(async () => { + try { + await createAndSendNotification( + { + content, + notificationType: 'POLL_CLOSED', + recipientId: uid, + pollId, + }, + uid + ); + } catch { + // 전송 실패 무시 (best-effort) + } + }) + ); + + // 전체 흐름 깨지 않도록 allSettled + await Promise.allSettled(tasks); + + await Promise.all(tasks); + + logger.polls.debug(`Poll ${pollId}: 종료 알림 전송 완료 (총 ${targetIds.length}명)`); +}; + /** * 개별 Poll 만료 처리 * @param pollId - 만료 처리 대상 Poll ID @@ -22,34 +98,13 @@ const limit = createLimit(5); export const closePoll = async (pollId: string): Promise => { try { // 상태 변경 - const updated = await prisma.poll.update({ + await prisma.poll.update({ where: { id: pollId }, data: { status: 'CLOSED' }, }); - // Notification 생성 - const notification = await prisma.notification.create({ - data: { - content: '투표가 종료되었습니다.', - notificationType: 'POLL_CLOSED', - pollId, - recipientId: updated.userId, - }, - }); - - // SSE 전송 - const payload: NotificationPayload = { - notificationId: notification.id, - content: notification.content, - notificationType: notification.notificationType, - notifiedAt: notification.notifiedAt.toISOString(), - isChecked: notification.isChecked, - complaintId: notification.complaintId, - noticeId: notification.noticeId, - pollId: notification.pollId, - }; - - sendSseNotification(payload); + // 알림 함수 호출 + await sendPollCloseNotifications(pollId); logger.polls.debug(`투표 ${pollId} 만료 처리 완료`); } catch (error) { @@ -72,11 +127,14 @@ export const closeExpiredPolls = async (): Promise => { return; } - const tasks = polls.map((poll) => { + const tasks = polls.map((poll) => limit(async () => { if (isPast(poll.endDate)) { await closePoll(poll.id); - } else if (isWithinInterval(poll.endDate)) { + return; + } + + if (isWithinInterval(poll.endDate)) { const delay = getDelayMs(poll.endDate); setTimeout(async () => { try { @@ -86,8 +144,8 @@ export const closeExpiredPolls = async (): Promise => { } }, delay); } - }); - }); + }) + ); await Promise.all(tasks); logger.polls.debug(`투표 만료 처리 완료 (${polls.length}건 검사)`); diff --git a/src/modules/apartments/apartments.repo.ts b/src/modules/apartments/apartments.repo.ts index 0775c79..54ff7fe 100644 --- a/src/modules/apartments/apartments.repo.ts +++ b/src/modules/apartments/apartments.repo.ts @@ -106,3 +106,16 @@ export const getCount = async (query: ApartmentRequestQueryDto, userRole: UserRo where, }); }; + +/** + * 아파트 ID → apartmentName 조회 + * @returns apartmentName | null + */ +export const getApartmentNameByIdRepo = async (apartmentId: string) => { + const apt = await prisma.apartment.findUnique({ + where: { id: apartmentId }, + select: { apartmentName: true }, + }); + + return apt?.apartmentName ?? null; +}; diff --git a/src/modules/auth/auth.repo.ts b/src/modules/auth/auth.repo.ts index 3d98925..deec524 100644 --- a/src/modules/auth/auth.repo.ts +++ b/src/modules/auth/auth.repo.ts @@ -354,3 +354,27 @@ export const getAdminIdByApartmentName = async (apartmentName: string) => { }, }); }; + +/** + * 특정 아파트에 속한 USER 유저들의 ID 리스트 조회 + * + * @description + * - 알림 전송 등의 내부 용도에서 사용하기 위해 id만 반환합니다. + * - User → Resident → Apartment 관계를 기반으로 필터링합니다. + * + * @param apartmentName 조회할 아파트 이름 + * @returns USER id 리스트 + */ +export const getUserIdListByApartmentName = async (apartmentName: string) => { + return prisma.user.findMany({ + where: { + role: UserRole.USER, + resident: { + apartment: { + apartmentName, + }, + }, + }, + select: { id: true }, + }); +}; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 50a7fae..7eee8dc 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -15,6 +15,7 @@ import { deleteApartmentRepo, getSuperAdminIdList, getAdminIdByApartmentName, + getUserIdListByApartmentName, } from './auth.repo'; import { SignupSuperAdminRequestDto, SignupAdminRequestDto, SignupUserRequestDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; @@ -185,3 +186,16 @@ export const cleanupRejectedUsers = async (role: UserRole, adminId: string | und } await deleteRejectedUser(targetRole, apartmentName); }; + +/** + * 특정 아파트에 속한 USER ID 리스트 반환 + * + * @description + * - 외부(controller)에서 호출되는 비즈니스 로직 계층입니다. + * - 알림 전송, SSE 대상 조회 등에서 재사용됩니다. + * + * @param apartmentName 조회 대상 아파트 이름 + */ +export const getUserIdsForApartment = async (apartmentName: string) => { + return await getUserIdListByApartmentName(apartmentName); +}; diff --git a/src/modules/notices/notices.repo.ts b/src/modules/notices/notices.repo.ts index 99efc63..59c820a 100644 --- a/src/modules/notices/notices.repo.ts +++ b/src/modules/notices/notices.repo.ts @@ -79,6 +79,7 @@ export const getApartmentIdByAdminId = async (adminId: string) => { }, select: { id: true, + apartmentName: true, }, }); }; diff --git a/src/modules/notices/notices.service.ts b/src/modules/notices/notices.service.ts index 24cde52..c314452 100644 --- a/src/modules/notices/notices.service.ts +++ b/src/modules/notices/notices.service.ts @@ -12,13 +12,60 @@ import { updateNoticeRepo, } from './notices.repo'; import ApiError from '#errors/ApiError'; +import { createLimit } from '#core/utils/Limiter'; +import { getUserIdsForApartment } from '#modules/auth/auth.service'; +import { createAndSendNotification } from '#core/utils/notificationHelper'; +const limit = createLimit(5); + +/** + * 공지사항 생성 + 해당 아파트 주민 전체에 알림 전송 + * + * @description + * - 오직 createNotice 시점에만 알림을 전송한다. + * - 알림 대상: 해당 아파트 USER 전체 + * - 알림 내용: "새로운 공지사항이 등록되었습니다." + */ export const createNoticeService = async (userId: string, data: NoticeCreateDTO) => { - const apartmentId = await getApartmentIdByAdminId(userId); - if (!apartmentId) { - throw ApiError.badRequest(); + // 1. 관리자가 속한 아파트 ID 조회 + const apartment = await getApartmentIdByAdminId(userId); + if (!apartment) throw ApiError.badRequest(); + + // 2. 공지 생성 + const notice = await createNoticeRepo(data, apartment.id); + + // 3. 아파트 주민 전체 ID 조회 + const residentUserIds = (await getUserIdsForApartment(apartment.apartmentName)).map((u) => u.id); + + if (residentUserIds.length === 0) { + return notice; } - return await createNoticeRepo(data, apartmentId.id); + + // 4. 알림 내용 + const content = '새로운 공지사항이 등록되었습니다.'; + + // 5. 병렬 알림 전송 + const tasks = residentUserIds.map((uid) => + limit(async () => { + try { + await createAndSendNotification( + { + content, + notificationType: 'NOTICE_REG', + recipientId: uid, + noticeId: notice.id, + }, + uid + ); + } catch { + // 전송 실패는 무시 (best-effort) + } + }) + ); + + await Promise.allSettled(tasks); + + return notice; }; export const getNoticeListService = async (data: NoticeListQueryDTO, role: UserRole, userId: string) => { diff --git a/tests/jobs/poll/poll.activate.integration.test.ts b/tests/jobs/poll/poll.activate.integration.test.ts index d7a0651..0d62f68 100644 --- a/tests/jobs/poll/poll.activate.integration.test.ts +++ b/tests/jobs/poll/poll.activate.integration.test.ts @@ -3,34 +3,41 @@ * @description Poll 활성화 통합 테스트 (Prisma + 실제 예약 로직) */ -import { describe, it, beforeAll, afterAll, beforeEach, expect, jest } from '@jest/globals'; +import { describe, it, beforeAll, afterAll, beforeEach, afterEach, expect, jest } from '@jest/globals'; import prisma from '#core/prisma'; import { activateReadyPolls } from '#jobs/poll/poll.activate.handler'; -import { sendSseNotification } from '#sse/sseEmitter'; -import { PollStatus, BoardType, UserRole } from '@prisma/client'; +import { sendSseToUser } from '#sse/sseEmitter'; +import { PollStatus, BoardType, UserRole, JoinStatus } from '@prisma/client'; + process.env.__SKIP_GLOBAL_DB_CLEANUP__ = 'true'; +/** + * SSE만 mock (DB/알림 생성/대상 조회는 실제로 탐) + */ jest.mock('#sse/sseEmitter', () => ({ - sendSseNotification: jest.fn(), + sendSseToUser: jest.fn(), })); jest.mock('#core/logger', () => ({ - logger: { polls: { debug: jest.fn(), error: jest.fn() } }, + logger: { + polls: { + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, + }, })); const TEST_APT = 'PollActivateAPT'; -const TEST_USER = 'poll_user@test.com'; +const USER_EMAIL = 'poll_user@test.com'; +const ADMIN_EMAIL = 'poll_admin@test.com'; const cleanupTestScope = async () => { await prisma.$transaction([ - prisma.event.deleteMany({ - where: { apartment: { apartmentName: TEST_APT } }, - }), prisma.notification.deleteMany({ - where: { recipient: { email: TEST_USER } }, - }), - prisma.comment.deleteMany({ - where: { board: { apartment: { apartmentName: TEST_APT } } }, + where: { + OR: [{ recipient: { email: USER_EMAIL } }, { recipient: { email: ADMIN_EMAIL } }], + }, }), prisma.pollVote.deleteMany({ where: { poll: { board: { apartment: { apartmentName: TEST_APT } } } }, @@ -44,12 +51,15 @@ const cleanupTestScope = async () => { prisma.board.deleteMany({ where: { apartment: { apartmentName: TEST_APT }, type: BoardType.POLL }, }), + prisma.user.deleteMany({ + where: { email: { in: [USER_EMAIL, ADMIN_EMAIL] } }, + }), + prisma.resident.deleteMany({ + where: { apartment: { apartmentName: TEST_APT } }, + }), prisma.apartment.deleteMany({ where: { apartmentName: TEST_APT }, }), - prisma.user.deleteMany({ - where: { email: TEST_USER }, - }), ]); }; @@ -59,13 +69,19 @@ beforeAll(async () => { beforeEach(async () => { jest.clearAllMocks(); + + // timer는 fake timers로 통제 + 정리 가능하게 만든다 jest.useFakeTimers({ legacyFakeTimers: true }); + jest.clearAllTimers(); + await cleanupTestScope(); }); afterEach(async () => { + // 테스트 끝나고 남은 timer를 정리해서 CI hang 방지 jest.runOnlyPendingTimers(); await new Promise(setImmediate); + jest.clearAllTimers(); jest.useRealTimers(); }); @@ -91,18 +107,54 @@ describe('[PollActivateHandler] Integration', () => { }, }); + const resident = await prisma.resident.create({ + data: { + name: '일반유저', + contact: '01000000061', + building: '101', + unitNumber: '1001', + apartment: { connect: { id: apt.id } }, + isRegistered: true, + approvalStatus: 'APPROVED', + residenceStatus: 'RESIDENCE', + isHouseholder: 'HOUSEHOLDER', + }, + }); + const user = await prisma.user.create({ data: { username: 'poll_user', password: 'pw', contact: '01000000061', name: '테스트유저', - email: TEST_USER, + email: USER_EMAIL, role: UserRole.USER, avatar: 'https://test.com/avatar.png', + resident: { connect: { id: resident.id } }, + joinStatus: JoinStatus.APPROVED, + isActive: true, + }, + }); + + const admin = await prisma.user.create({ + data: { + username: 'poll_admin', + password: 'pw', + contact: '01000000062', + name: '관리자', + email: ADMIN_EMAIL, + role: UserRole.ADMIN, + avatar: 'https://test.com/avatar.png', + joinStatus: JoinStatus.APPROVED, + isActive: true, }, }); + await prisma.apartment.update({ + where: { id: apt.id }, + data: { admin: { connect: { id: admin.id } } }, + }); + const board = await prisma.board.create({ data: { type: BoardType.POLL, @@ -110,10 +162,10 @@ describe('[PollActivateHandler] Integration', () => { }, }); - return { user, board, apt }; + return { apt, user, admin, board }; }; - it('startDate가 이미 지난 Poll은 즉시 IN_PROGRESS로 변경되어야 함', async () => { + it('startDate가 이미 지난 Poll은 즉시 IN_PROGRESS로 변경 + SSE 호출됨', async () => { const { user, board } = await createBaseEnv(); const poll = await prisma.poll.create({ @@ -129,13 +181,14 @@ describe('[PollActivateHandler] Integration', () => { }); await activateReadyPolls(); - const updated = await prisma.poll.findUnique({ where: { id: poll.id } }); + const updated = await prisma.poll.findUnique({ where: { id: poll.id } }); expect(updated?.status).toBe(PollStatus.IN_PROGRESS); - expect(sendSseNotification).toHaveBeenCalledTimes(1); + + expect(sendSseToUser).toHaveBeenCalled(); }); - it('startDate가 미래인 Poll은 변경되지 않아야 함', async () => { + it('startDate가 미래면 변경 X + SSE 없음', async () => { const { user, board } = await createBaseEnv(); const poll = await prisma.poll.create({ @@ -151,13 +204,13 @@ describe('[PollActivateHandler] Integration', () => { }); await activateReadyPolls(); - const unchanged = await prisma.poll.findUnique({ where: { id: poll.id } }); + const unchanged = await prisma.poll.findUnique({ where: { id: poll.id } }); expect(unchanged?.status).toBe(PollStatus.PENDING); - expect(sendSseNotification).not.toHaveBeenCalled(); + expect(sendSseToUser).not.toHaveBeenCalled(); }); - it('startDate가 3초 내라면 setTimeout 예약이 생성되어야 함', async () => { + it('startDate가 3초 내이면 setTimeout 예약 + 실행 완료 후 IN_PROGRESS + SSE 2회', async () => { const { user, board } = await createBaseEnv(); await prisma.poll.create({ @@ -173,31 +226,41 @@ describe('[PollActivateHandler] Integration', () => { }); let capturedDelay: number | undefined; + const pending: Promise[] = []; + // setTimeout을 spyOn + mockImplementation으로 가로채고 즉시 실행 const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((( - fn: (...args: any[]) => void, + fn: (...args: any[]) => any, delay?: number, ...args: any[] ) => { capturedDelay = delay as number; - // 타이머 즉시 실행 - fn(...args); + const ret = fn(...args); + if (ret && typeof ret.then === 'function') pending.push(ret); // fake timer id 반환 return 0 as unknown as NodeJS.Timeout; }) as unknown as typeof setTimeout); - await activateReadyPolls(); + try { + await activateReadyPolls(); - expect(setTimeoutSpy).toHaveBeenCalled(); - expect(typeof capturedDelay).toBe('number'); - expect(capturedDelay!).toBeGreaterThanOrEqual(2500); - expect(capturedDelay!).toBeLessThanOrEqual(3500); + // 콜백이 async일 수 있으니 내부 작업까지 끝까지 대기 + await Promise.all(pending); + await new Promise(setImmediate); - const updated = await prisma.poll.findFirst({ where: { title: '직후 예약 투표' } }); - expect(updated?.status).toBe(PollStatus.IN_PROGRESS); + expect(setTimeoutSpy).toHaveBeenCalled(); + expect(typeof capturedDelay).toBe('number'); + expect(capturedDelay!).toBeGreaterThanOrEqual(2500); + expect(capturedDelay!).toBeLessThanOrEqual(3500); + + const updated = await prisma.poll.findFirst({ where: { title: '직후 예약 투표' } }); + expect(updated?.status).toBe(PollStatus.IN_PROGRESS); - setTimeoutSpy.mockRestore(); + expect(sendSseToUser).toHaveBeenCalled(); + } finally { + setTimeoutSpy.mockRestore(); + } }); }); diff --git a/tests/jobs/poll/poll.expire.integration.test.ts b/tests/jobs/poll/poll.expire.integration.test.ts index ecc6e13..1019ab5 100644 --- a/tests/jobs/poll/poll.expire.integration.test.ts +++ b/tests/jobs/poll/poll.expire.integration.test.ts @@ -3,78 +3,157 @@ * @description Poll 만료 통합 테스트 (Prisma + 실제 예약 로직) */ -import { describe, it, beforeAll, afterAll, beforeEach, expect, jest } from '@jest/globals'; +import { describe, it, beforeAll, afterAll, beforeEach, afterEach, expect, jest } from '@jest/globals'; +import { setImmediate as nodeSetImmediate } from 'timers'; import prisma from '#core/prisma'; import { closeExpiredPolls } from '#jobs/poll/poll.expire.handler'; -import { sendSseNotification } from '#core/sse/sseEmitter'; -import { PollStatus, BoardType, UserRole } from '@prisma/client'; +import { sendSseToUser } from '#sse/sseEmitter'; +import { PollStatus, BoardType, UserRole, JoinStatus } from '@prisma/client'; + process.env.__SKIP_GLOBAL_DB_CLEANUP__ = 'true'; -jest.mock('#core/sse/sseEmitter', () => ({ - sendSseNotification: jest.fn(), +/** + * SSE만 mock (DB/알림 생성/대상 조회는 실제로 탐) + */ +jest.mock('#sse/sseEmitter', () => ({ + sendSseToUser: jest.fn(), })); jest.mock('#core/logger', () => ({ - logger: { polls: { debug: jest.fn(), error: jest.fn() } }, + logger: { + polls: { + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, + }, })); -const TEST_APARTMENT_NAME = 'ExpireTestAPT'; -const TEST_USER_EMAIL = 'expire_user@test.com'; -const TEST_USER_CONTACT = '01000000071'; +const TEST_APT = 'ExpireTestAPT'; +const USER_EMAIL = 'expire_user@test.com'; +const ADMIN_EMAIL = 'expire_admin@test.com'; + +const sseMock = sendSseToUser as unknown as jest.Mock; + +/** + * SSE payload에서 notification id를 최대한 안전하게 뽑아내는 함수 + * - 케이스1) { notificationId: '...' } + * - 케이스2) { id: '...', notificationType: ..., isChecked: ... } (DB row 그대로 보내는 타입) + * - 케이스3) { data: { ... } } 같은 nested + */ +const extractNotificationId = (v: any): string | undefined => { + if (!v) return undefined; + + if (Array.isArray(v)) { + for (const item of v) { + const found = extractNotificationId(item); + if (found) return found; + } + return undefined; + } -const cleanupTestData = async () => { + if (typeof v !== 'object') return undefined; + + if (typeof (v as any).notificationId === 'string') return (v as any).notificationId; + + // notification row로 보이는 경우에만 id를 인정 (userId 같은 string 혼동 방지) + if ( + typeof (v as any).id === 'string' && + ('notificationType' in v || 'isChecked' in v || 'notifiedAt' in v || 'recipientId' in v) + ) { + return (v as any).id; + } + + for (const key of Object.keys(v)) { + const found = extractNotificationId((v as any)[key]); + if (found) return found; + } + + return undefined; +}; + +const flushMicrotasks = async (ticks = 2) => { + for (let i = 0; i < ticks; i++) { + await Promise.resolve(); + await new Promise((resolve) => nodeSetImmediate(resolve)); + } +}; + +const waitForNotificationCount = async (pollId: string, expected: number, maxTicks = 20) => { + for (let i = 0; i < maxTicks; i++) { + const count = await prisma.notification.count({ where: { pollId } }); + if (count === expected) return; + await flushMicrotasks(1); + } +}; + +const getSseCallsForPoll = async (pollId: string) => { + const notifIds = ( + await prisma.notification.findMany({ + where: { pollId }, + select: { id: true }, + }) + ).map((n) => n.id); + + if (notifIds.length === 0) return []; + + const calls = sseMock.mock.calls as any[][]; + return calls.filter((call) => { + const nid = extractNotificationId(call); + return typeof nid === 'string' && notifIds.includes(nid); + }); +}; + +const cleanupTestScope = async () => { await prisma.$transaction([ - prisma.event.deleteMany({ - where: { apartment: { apartmentName: TEST_APARTMENT_NAME } }, - }), prisma.notification.deleteMany({ - where: { recipient: { email: TEST_USER_EMAIL } }, - }), - prisma.comment.deleteMany({ - where: { board: { apartment: { apartmentName: TEST_APARTMENT_NAME } } }, + where: { + OR: [{ recipient: { email: USER_EMAIL } }, { recipient: { email: ADMIN_EMAIL } }], + }, }), prisma.pollVote.deleteMany({ - where: { poll: { board: { apartment: { apartmentName: TEST_APARTMENT_NAME } } } }, + where: { poll: { board: { apartment: { apartmentName: TEST_APT } } } }, }), prisma.pollOption.deleteMany({ - where: { poll: { board: { apartment: { apartmentName: TEST_APARTMENT_NAME } } } }, + where: { poll: { board: { apartment: { apartmentName: TEST_APT } } } }, }), prisma.poll.deleteMany({ - where: { board: { apartment: { apartmentName: TEST_APARTMENT_NAME } } }, + where: { board: { apartment: { apartmentName: TEST_APT } } }, }), prisma.board.deleteMany({ - where: { - apartment: { apartmentName: TEST_APARTMENT_NAME }, - type: BoardType.POLL, - }, + where: { apartment: { apartmentName: TEST_APT }, type: BoardType.POLL }, }), prisma.user.deleteMany({ - where: { - OR: [{ email: TEST_USER_EMAIL }, { contact: TEST_USER_CONTACT }, { username: 'expire_user' }], - }, + where: { email: { in: [USER_EMAIL, ADMIN_EMAIL] } }, + }), + prisma.resident.deleteMany({ + where: { apartment: { apartmentName: TEST_APT } }, }), prisma.apartment.deleteMany({ - where: { apartmentName: TEST_APARTMENT_NAME }, + where: { apartmentName: TEST_APT }, }), ]); }; beforeAll(async () => { - await cleanupTestData(); + await cleanupTestScope(); }); beforeEach(async () => { jest.clearAllMocks(); + + // 이 파일 한정으로 fake timers 적용 jest.useFakeTimers({ legacyFakeTimers: true }); jest.clearAllTimers(); - await cleanupTestData(); + await cleanupTestScope(); }); afterEach(async () => { - jest.runOnlyPendingTimers(); - await new Promise(setImmediate); + // “미래 만료 예약 setTimeout”이 남아 CI를 붙잡지 않도록 정리 + jest.clearAllTimers(); jest.useRealTimers(); + await flushMicrotasks(); }); afterAll(async () => { @@ -86,7 +165,7 @@ describe('[PollExpireHandler] Integration', () => { const createBaseEnv = async () => { const apt = await prisma.apartment.create({ data: { - apartmentName: TEST_APARTMENT_NAME, + apartmentName: TEST_APT, apartmentAddress: 'Seoul', startComplexNumber: '1', endComplexNumber: '10', @@ -99,18 +178,54 @@ describe('[PollExpireHandler] Integration', () => { }, }); + const resident = await prisma.resident.create({ + data: { + name: '만료유저', + contact: '01000000071', + building: '101', + unitNumber: '1001', + apartment: { connect: { id: apt.id } }, + isRegistered: true, + approvalStatus: 'APPROVED', + residenceStatus: 'RESIDENCE', + isHouseholder: 'HOUSEHOLDER', + }, + }); + const user = await prisma.user.create({ data: { username: 'expire_user', password: 'pw', - contact: TEST_USER_CONTACT, + contact: '01000000071', name: '만료유저', - email: TEST_USER_EMAIL, + email: USER_EMAIL, role: UserRole.USER, avatar: 'https://test.com/avatar.png', + resident: { connect: { id: resident.id } }, + joinStatus: JoinStatus.APPROVED, + isActive: true, }, }); + const admin = await prisma.user.create({ + data: { + username: 'expire_admin', + password: 'pw', + contact: '01000000072', + name: '관리자', + email: ADMIN_EMAIL, + role: UserRole.ADMIN, + avatar: 'https://test.com/admin.png', + joinStatus: JoinStatus.APPROVED, + isActive: true, + }, + }); + + await prisma.apartment.update({ + where: { id: apt.id }, + data: { admin: { connect: { id: admin.id } } }, + }); + const board = await prisma.board.create({ data: { type: BoardType.POLL, @@ -118,10 +233,10 @@ describe('[PollExpireHandler] Integration', () => { }, }); - return { user, board }; + return { apt, user, admin, board }; }; - it('endDate가 지난 Poll은 즉시 CLOSED로 변경되어야 함', async () => { + it('endDate가 지난 Poll은 즉시 CLOSED + SSE 2회', async () => { const { user, board } = await createBaseEnv(); const poll = await prisma.poll.create({ @@ -137,13 +252,19 @@ describe('[PollExpireHandler] Integration', () => { }); await closeExpiredPolls(); + await flushMicrotasks(); const updated = await prisma.poll.findUnique({ where: { id: poll.id } }); expect(updated?.status).toBe(PollStatus.CLOSED); - expect(sendSseNotification).toHaveBeenCalledTimes(1); + + // DB에 알림 2개가 생길 때까지 잠깐 기다린 뒤, 그 알림 id로 SSE를 매칭 + await waitForNotificationCount(poll.id, 2); + + const calls = await getSseCallsForPoll(poll.id); + expect(calls).toHaveLength(2); }); - it('endDate가 충분히 남은 Poll은 변경되지 않아야 함', async () => { + it('endDate가 충분히 남으면 변경 X + SSE 없음', async () => { const { user, board } = await createBaseEnv(); const poll = await prisma.poll.create({ @@ -159,16 +280,22 @@ describe('[PollExpireHandler] Integration', () => { }); await closeExpiredPolls(); + await flushMicrotasks(); const unchanged = await prisma.poll.findUnique({ where: { id: poll.id } }); expect(unchanged?.status).toBe(PollStatus.IN_PROGRESS); - expect(sendSseNotification).not.toHaveBeenCalled(); + + const notifCount = await prisma.notification.count({ where: { pollId: poll.id } }); + expect(notifCount).toBe(0); + + const calls = await getSseCallsForPoll(poll.id); + expect(calls).toHaveLength(0); }); - it('endDate가 3초 내 도래하면 setTimeout 예약이 생성되고, 만료 시 CLOSED로 변경되어야 함', async () => { + it('endDate가 3초 내면 setTimeout 예약 + 실행 완료 후 CLOSED + SSE 2회', async () => { const { user, board } = await createBaseEnv(); - await prisma.poll.create({ + const poll = await prisma.poll.create({ data: { title: '단기 만료 투표', content: '3초 내 만료 예정', @@ -180,26 +307,33 @@ describe('[PollExpireHandler] Integration', () => { }, }); - const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout'); - await closeExpiredPolls(); + try { + await closeExpiredPolls(); - expect(setTimeoutSpy).toHaveBeenCalledTimes(1); - const [fn, delay] = setTimeoutSpy.mock.calls[0] as [() => void, number]; + const candidates = (setTimeoutSpy.mock.calls as any[][]) + .map((c) => ({ fn: c[0], delay: Number(c[1]), args: c.slice(2) })) + .filter((t) => t.delay >= 2500 && t.delay <= 3500); - expect(typeof fn).toBe('function'); - expect(delay).toBeGreaterThanOrEqual(2500); - expect(delay).toBeLessThanOrEqual(3500); + expect(candidates.length).toBeGreaterThanOrEqual(1); - // 예약된 타이머 콜백을 직접 실행해서 실제 만료 로직까지 확인 - await (fn as any)(); - await new Promise(setImmediate); + // 후보 중 1개만 실행(중복 실행/오염 방지) + const t = candidates[0]; + const ret = t.fn(...t.args); + if (ret && typeof ret.then === 'function') await ret; - const updated = await prisma.poll.findFirst({ - where: { title: '단기 만료 투표' }, - }); + await flushMicrotasks(); - expect(updated?.status).toBe(PollStatus.CLOSED); - expect(sendSseNotification).toHaveBeenCalledTimes(1); + const updated = await prisma.poll.findUnique({ where: { id: poll.id } }); + expect(updated?.status).toBe(PollStatus.CLOSED); + + await waitForNotificationCount(poll.id, 2); + + const calls = await getSseCallsForPoll(poll.id); + expect(calls).toHaveLength(2); + } finally { + setTimeoutSpy.mockRestore(); + } }); }); diff --git a/tests/modules/notifications/notifications.test.ts b/tests/modules/notifications/notifications.test.ts index ff03484..fe389e9 100644 --- a/tests/modules/notifications/notifications.test.ts +++ b/tests/modules/notifications/notifications.test.ts @@ -47,7 +47,7 @@ describe('[Notifications] 통합 테스트', () => { data: { username: 'notification_user', password: 'pw', - contact: '01000000061', + contact: '01000001061', name: '알림유저', email: TEST_EMAILS[0], role: 'USER', @@ -58,7 +58,7 @@ describe('[Notifications] 통합 테스트', () => { data: { username: 'notification_other', password: 'pw', - contact: '01000000062', + contact: '01000001062', name: '다른유저', email: TEST_EMAILS[1], role: 'USER',