From e42708d9f4d96f6fe81a42602ddadf1df1a5bf47 Mon Sep 17 00:00:00 2001 From: selentia Date: Thu, 27 Nov 2025 15:26:06 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(alarm-base):=20=EC=95=84=ED=8C=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=EA=B8=B0=EB=B0=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=97=AC=ED=8D=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20auth=20=EC=97=B0=EB=8F=99=20-=20getApartmentNameByIdRepo=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(apartments.repo)=20-=20auth.service=EC=97=90?= =?UTF-8?q?=EC=84=9C=20apartmentName=20=EA=B8=B0=EB=B0=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20-=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=A0=84=EC=86=A1=EC=9A=A9=20getUserIdsForApartmen?= =?UTF-8?q?t(apartmentName)=20=EC=97=B0=EB=8F=99=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/apartments/apartments.repo.ts | 13 ++++++++++++ src/modules/auth/auth.repo.ts | 24 +++++++++++++++++++++++ src/modules/auth/auth.service.ts | 14 +++++++++++++ 3 files changed, 51 insertions(+) diff --git a/src/modules/apartments/apartments.repo.ts b/src/modules/apartments/apartments.repo.ts index 0775c799..54ff7fe3 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 3d989253..deec5241 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 50a7fae1..7eee8dcc 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); +}; From 1e593c46592e3d933a103963f34686e1a800b8f3 Mon Sep 17 00:00:00 2001 From: selentia Date: Thu, 27 Nov 2025 15:29:37 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor(poll):=20Poll=20activate/expire=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=A1=9C=EC=A7=81=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=95=88=EC=A0=95=ED=99=94=20-=20activate/expire?= =?UTF-8?q?=20handler=EC=97=90=EC=84=9C=20apartmentName=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20-=20SSE=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=EC=9D=84=20createAndSendNotification?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=BC=EC=9B=90=ED=99=94=20-=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=8B=A4=ED=8C=A8=20try/catch=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC(best-effort)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/jobs/poll/poll.activate.handler.ts | 114 +++++++++++++++++------- src/jobs/poll/poll.expire.handler.ts | 118 ++++++++++++++++++------- 2 files changed, 170 insertions(+), 62 deletions(-) diff --git a/src/jobs/poll/poll.activate.handler.ts b/src/jobs/poll/poll.activate.handler.ts index 95bd0873..a6463f5e 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 0b749d31..5f022147 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}건 검사)`); From ca929aadf5dc678f47a9dd743ace2e08fa0606e4 Mon Sep 17 00:00:00 2001 From: selentia Date: Thu, 27 Nov 2025 15:29:46 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat(notice):=20=EA=B3=B5=EC=A7=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EC=95=84=ED=8C=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=EC=97=90=20=EC=95=8C=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20-=20creat?= =?UTF-8?q?eNoticeService=EC=97=90=EC=84=9C=20=EC=95=84=ED=8C=8C=ED=8A=B8?= =?UTF-8?q?=20=EC=A3=BC=EB=AF=BC=20=EC=A0=84=EC=B2=B4=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=A0=84=EC=86=A1=20-=20Promise.allSettled=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=B4=20=EC=8B=A4=ED=8C=A8=20=EB=AC=B4=EC=8B=9C=20?= =?UTF-8?q?-=20apartmentName=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/notices/notices.service.ts | 59 ++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/src/modules/notices/notices.service.ts b/src/modules/notices/notices.service.ts index 24cde527..261c451d 100644 --- a/src/modules/notices/notices.service.ts +++ b/src/modules/notices/notices.service.ts @@ -12,13 +12,64 @@ 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'; +import { getApartmentNameByIdRepo } from '#modules/apartments/apartments.repo'; +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?.id) throw ApiError.badRequest(); + + const apartmentName = await getApartmentNameByIdRepo(apartment.id); + if (!apartmentName) throw ApiError.badRequest(); + + // 2. 공지 생성 + const notice = await createNoticeRepo(data, apartment.id); + + // 3. 아파트 주민 전체 ID 조회 + const residentUserIds = (await getUserIdsForApartment(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) => { From 46704e4681a8d6660cd47b98c3b2009246bffcfa Mon Sep 17 00:00:00 2001 From: selentia Date: Thu, 27 Nov 2025 15:30:45 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix(notifications):=20Poll/Notice=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=A0=84=EC=86=A1=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getUserIdsForApartment가 apartmentName을 기준으로 동작하도록 관련 레포/서비스 정비 (apartment.repo, auth.repo, auth.service 에 헬퍼 함수 추가) - 공지 생성 시 주민 전체 알림 전송 로직 보강 (전송 실패 시 개별 ignore, Promise.allSettled 적용) - Poll Activate/Expire 핸들러 알림 전송에 try/catch 적용 및 best-effort 전송으로 변경 - Poll Activate/Expire 통합 테스트에서 pollId 기준 SSE 필터링 로직 추가 (다른 테스트의 타이머·잔여 call 간섭 제거) - notifications 테스트의 mock 유저 충돌 해결 및 겹치는 fixture 정리 - 전체 SSE 호출 흐름 정합성 재검증 및 안정화 --- .../poll/poll.activate.integration.test.ts | 144 ++++++---- .../jobs/poll/poll.expire.integration.test.ts | 256 +++++++++++++----- 2 files changed, 291 insertions(+), 109 deletions(-) diff --git a/tests/jobs/poll/poll.activate.integration.test.ts b/tests/jobs/poll/poll.activate.integration.test.ts index d7a06515..f166a4f2 100644 --- a/tests/jobs/poll/poll.activate.integration.test.ts +++ b/tests/jobs/poll/poll.activate.integration.test.ts @@ -6,31 +6,38 @@ import { describe, it, beforeAll, afterAll, beforeEach, 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,16 +69,9 @@ beforeAll(async () => { beforeEach(async () => { jest.clearAllMocks(); - jest.useFakeTimers({ legacyFakeTimers: true }); await cleanupTestScope(); }); -afterEach(async () => { - jest.runOnlyPendingTimers(); - await new Promise(setImmediate); - jest.useRealTimers(); -}); - afterAll(async () => { process.env.__SKIP_GLOBAL_DB_CLEANUP__ = 'false'; await prisma.$disconnect(); @@ -91,18 +94,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/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, @@ -110,10 +149,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 2회', async () => { const { user, board } = await createBaseEnv(); const poll = await prisma.poll.create({ @@ -129,13 +168,15 @@ 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); + + // 주민 1 + 관리자 1 + expect(sendSseToUser).toHaveBeenCalledTimes(2); }); - it('startDate가 미래인 Poll은 변경되지 않아야 함', async () => { + it('startDate가 미래면 변경 X + SSE 없음', async () => { const { user, board } = await createBaseEnv(); const poll = await prisma.poll.create({ @@ -151,13 +192,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({ @@ -174,30 +215,37 @@ describe('[PollActivateHandler] Integration', () => { let capturedDelay: number | undefined; - const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((( - fn: (...args: any[]) => void, - delay?: number, - ...args: any[] - ) => { + // Prisma 깨지지 않게 fakeTimers 쓰지 말고 setTimeout만 직접 가로챔 + // + setTimeout 콜백이 async일 수 있으니, 반환 Promise를 모아서 await + const pending: Promise[] = []; + const originalSetTimeout = globalThis.setTimeout; + + (globalThis as any).setTimeout = ((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); + }) as typeof setTimeout; - await activateReadyPolls(); + try { + await activateReadyPolls(); - expect(setTimeoutSpy).toHaveBeenCalled(); - expect(typeof capturedDelay).toBe('number'); - expect(capturedDelay!).toBeGreaterThanOrEqual(2500); - expect(capturedDelay!).toBeLessThanOrEqual(3500); + // setTimeout 콜백 내부 작업(activatePoll + 알림 전송)까지 완전히 끝날 때까지 대기 + await Promise.all(pending); + await new Promise(setImmediate); - const updated = await prisma.poll.findFirst({ where: { title: '직후 예약 투표' } }); - expect(updated?.status).toBe(PollStatus.IN_PROGRESS); + 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).toHaveBeenCalledTimes(2); + } finally { + globalThis.setTimeout = originalSetTimeout; + } }); }); diff --git a/tests/jobs/poll/poll.expire.integration.test.ts b/tests/jobs/poll/poll.expire.integration.test.ts index ecc6e139..82932ef3 100644 --- a/tests/jobs/poll/poll.expire.integration.test.ts +++ b/tests/jobs/poll/poll.expire.integration.test.ts @@ -3,78 +3,147 @@ * @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 { 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 new Promise(setImmediate); + } +}; + +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 new Promise(setImmediate); + } +}; + +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(); - jest.useFakeTimers({ legacyFakeTimers: true }); - jest.clearAllTimers(); - - await cleanupTestData(); + await cleanupTestScope(); }); afterEach(async () => { - jest.runOnlyPendingTimers(); - await new Promise(setImmediate); - jest.useRealTimers(); + await flushMicrotasks(); }); afterAll(async () => { @@ -86,7 +155,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 +168,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 +223,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 +242,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 +270,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 +297,43 @@ describe('[PollExpireHandler] Integration', () => { }, }); - const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const pending: Promise[] = []; + const scheduled: Array<{ fn: (...args: any[]) => any; delay: number; args: any[] }> = []; - await closeExpiredPolls(); + const originalSetTimeout = globalThis.setTimeout; - expect(setTimeoutSpy).toHaveBeenCalledTimes(1); - const [fn, delay] = setTimeoutSpy.mock.calls[0] as [() => void, number]; + (globalThis as any).setTimeout = ((fn: any, delay?: any, ...args: any[]) => { + scheduled.push({ fn, delay: Number(delay), args }); + return 0 as unknown as NodeJS.Timeout; + }) as typeof setTimeout; - expect(typeof fn).toBe('function'); - expect(delay).toBeGreaterThanOrEqual(2500); - expect(delay).toBeLessThanOrEqual(3500); + try { + await closeExpiredPolls(); - // 예약된 타이머 콜백을 직접 실행해서 실제 만료 로직까지 확인 - await (fn as any)(); - await new Promise(setImmediate); + // 3초대 후보만 (다른 poll 타이머 오염 방지) + const candidates = scheduled.filter((t) => t.delay >= 2500 && t.delay <= 3500); + expect(candidates.length).toBeGreaterThanOrEqual(1); - const updated = await prisma.poll.findFirst({ - where: { title: '단기 만료 투표' }, - }); + for (const t of candidates) { + const ret = t.fn(...t.args); + if (ret && typeof ret.then === 'function') pending.push(ret); - expect(updated?.status).toBe(PollStatus.CLOSED); - expect(sendSseNotification).toHaveBeenCalledTimes(1); + await Promise.all(pending); + await flushMicrotasks(); + + const updatedMid = await prisma.poll.findUnique({ where: { id: poll.id } }); + if (updatedMid?.status === PollStatus.CLOSED) break; + } + + 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 { + globalThis.setTimeout = originalSetTimeout; + } }); }); From a621ddd194b9feb6af4e721ad350e8b99d22140d Mon Sep 17 00:00:00 2001 From: selentia Date: Thu, 27 Nov 2025 15:31:15 +0900 Subject: [PATCH 5/9] =?UTF-8?q?test(notification):=20mock=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/modules/notifications/notifications.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/modules/notifications/notifications.test.ts b/tests/modules/notifications/notifications.test.ts index ff034849..fe389e9b 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', From 997011f20a0aeb163379e459c99d0c0abbe6809e Mon Sep 17 00:00:00 2001 From: selentia Date: Thu, 27 Nov 2025 16:13:11 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix(test):=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../poll/poll.activate.integration.test.ts | 36 +++++++++++---- .../jobs/poll/poll.expire.integration.test.ts | 46 +++++++++---------- 2 files changed, 49 insertions(+), 33 deletions(-) diff --git a/tests/jobs/poll/poll.activate.integration.test.ts b/tests/jobs/poll/poll.activate.integration.test.ts index f166a4f2..014de0dc 100644 --- a/tests/jobs/poll/poll.activate.integration.test.ts +++ b/tests/jobs/poll/poll.activate.integration.test.ts @@ -3,7 +3,7 @@ * @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 { sendSseToUser } from '#sse/sseEmitter'; @@ -69,9 +69,22 @@ 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(); +}); + afterAll(async () => { process.env.__SKIP_GLOBAL_DB_CLEANUP__ = 'false'; await prisma.$disconnect(); @@ -131,7 +144,7 @@ describe('[PollActivateHandler] Integration', () => { name: '관리자', email: ADMIN_EMAIL, role: UserRole.ADMIN, - avatar: 'https://test.com/admin.png', + avatar: 'https://test.com/avatar.png', joinStatus: JoinStatus.APPROVED, isActive: true, }, @@ -214,28 +227,31 @@ describe('[PollActivateHandler] Integration', () => { }); let capturedDelay: number | undefined; - - // Prisma 깨지지 않게 fakeTimers 쓰지 말고 setTimeout만 직접 가로챔 - // + setTimeout 콜백이 async일 수 있으니, 반환 Promise를 모아서 await const pending: Promise[] = []; - const originalSetTimeout = globalThis.setTimeout; - (globalThis as any).setTimeout = ((fn: (...args: any[]) => any, delay?: number, ...args: any[]) => { + // setTimeout을 spyOn + mockImplementation으로 가로채고 즉시 실행 + const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((( + fn: (...args: any[]) => any, + delay?: number, + ...args: any[] + ) => { capturedDelay = delay as number; const ret = fn(...args); if (ret && typeof ret.then === 'function') pending.push(ret); + // fake timer id 반환 return 0 as unknown as NodeJS.Timeout; - }) as typeof setTimeout; + }) as unknown as typeof setTimeout); try { await activateReadyPolls(); - // setTimeout 콜백 내부 작업(activatePoll + 알림 전송)까지 완전히 끝날 때까지 대기 + // 콜백이 async일 수 있으니 내부 작업까지 끝까지 대기 await Promise.all(pending); await new Promise(setImmediate); + expect(setTimeoutSpy).toHaveBeenCalled(); expect(typeof capturedDelay).toBe('number'); expect(capturedDelay!).toBeGreaterThanOrEqual(2500); expect(capturedDelay!).toBeLessThanOrEqual(3500); @@ -245,7 +261,7 @@ describe('[PollActivateHandler] Integration', () => { expect(sendSseToUser).toHaveBeenCalledTimes(2); } finally { - globalThis.setTimeout = originalSetTimeout; + setTimeoutSpy.mockRestore(); } }); }); diff --git a/tests/jobs/poll/poll.expire.integration.test.ts b/tests/jobs/poll/poll.expire.integration.test.ts index 82932ef3..1019ab5b 100644 --- a/tests/jobs/poll/poll.expire.integration.test.ts +++ b/tests/jobs/poll/poll.expire.integration.test.ts @@ -4,6 +4,7 @@ */ 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 { sendSseToUser } from '#sse/sseEmitter'; @@ -73,7 +74,8 @@ const extractNotificationId = (v: any): string | undefined => { const flushMicrotasks = async (ticks = 2) => { for (let i = 0; i < ticks; i++) { - await new Promise(setImmediate); + await Promise.resolve(); + await new Promise((resolve) => nodeSetImmediate(resolve)); } }; @@ -81,7 +83,7 @@ const waitForNotificationCount = async (pollId: string, expected: number, maxTic for (let i = 0; i < maxTicks; i++) { const count = await prisma.notification.count({ where: { pollId } }); if (count === expected) return; - await new Promise(setImmediate); + await flushMicrotasks(1); } }; @@ -139,10 +141,18 @@ beforeAll(async () => { beforeEach(async () => { jest.clearAllMocks(); + + // 이 파일 한정으로 fake timers 적용 + jest.useFakeTimers({ legacyFakeTimers: true }); + jest.clearAllTimers(); + await cleanupTestScope(); }); afterEach(async () => { + // “미래 만료 예약 setTimeout”이 남아 CI를 붙잡지 않도록 정리 + jest.clearAllTimers(); + jest.useRealTimers(); await flushMicrotasks(); }); @@ -297,33 +307,23 @@ describe('[PollExpireHandler] Integration', () => { }, }); - const pending: Promise[] = []; - const scheduled: Array<{ fn: (...args: any[]) => any; delay: number; args: any[] }> = []; - - const originalSetTimeout = globalThis.setTimeout; - - (globalThis as any).setTimeout = ((fn: any, delay?: any, ...args: any[]) => { - scheduled.push({ fn, delay: Number(delay), args }); - return 0 as unknown as NodeJS.Timeout; - }) as typeof setTimeout; + const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout'); try { await closeExpiredPolls(); - // 3초대 후보만 (다른 poll 타이머 오염 방지) - const candidates = scheduled.filter((t) => t.delay >= 2500 && t.delay <= 3500); - expect(candidates.length).toBeGreaterThanOrEqual(1); + 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); - for (const t of candidates) { - const ret = t.fn(...t.args); - if (ret && typeof ret.then === 'function') pending.push(ret); + expect(candidates.length).toBeGreaterThanOrEqual(1); - await Promise.all(pending); - await flushMicrotasks(); + // 후보 중 1개만 실행(중복 실행/오염 방지) + const t = candidates[0]; + const ret = t.fn(...t.args); + if (ret && typeof ret.then === 'function') await ret; - const updatedMid = await prisma.poll.findUnique({ where: { id: poll.id } }); - if (updatedMid?.status === PollStatus.CLOSED) break; - } + await flushMicrotasks(); const updated = await prisma.poll.findUnique({ where: { id: poll.id } }); expect(updated?.status).toBe(PollStatus.CLOSED); @@ -333,7 +333,7 @@ describe('[PollExpireHandler] Integration', () => { const calls = await getSseCallsForPoll(poll.id); expect(calls).toHaveLength(2); } finally { - globalThis.setTimeout = originalSetTimeout; + setTimeoutSpy.mockRestore(); } }); }); From 3ef99b9866d4e715455354fb7341e9c08d86d3cb Mon Sep 17 00:00:00 2001 From: selentia Date: Thu, 27 Nov 2025 16:16:55 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix(test):=20=ED=98=B8=EC=B6=9C=20=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/jobs/poll/poll.activate.integration.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/jobs/poll/poll.activate.integration.test.ts b/tests/jobs/poll/poll.activate.integration.test.ts index 014de0dc..b2f1c74b 100644 --- a/tests/jobs/poll/poll.activate.integration.test.ts +++ b/tests/jobs/poll/poll.activate.integration.test.ts @@ -165,7 +165,7 @@ describe('[PollActivateHandler] Integration', () => { return { apt, user, admin, board }; }; - it('startDate가 이미 지난 Poll은 즉시 IN_PROGRESS로 변경 + SSE 2회', async () => { + it('startDate가 이미 지난 Poll은 즉시 IN_PROGRESS로 변경 + SSE 호출됨', async () => { const { user, board } = await createBaseEnv(); const poll = await prisma.poll.create({ @@ -185,8 +185,7 @@ describe('[PollActivateHandler] Integration', () => { const updated = await prisma.poll.findUnique({ where: { id: poll.id } }); expect(updated?.status).toBe(PollStatus.IN_PROGRESS); - // 주민 1 + 관리자 1 - expect(sendSseToUser).toHaveBeenCalledTimes(2); + expect(sendSseToUser).toHaveBeenCalled(); }); it('startDate가 미래면 변경 X + SSE 없음', async () => { From 25d8198e425d037ca7d2d3909590137a1b655dd2 Mon Sep 17 00:00:00 2001 From: selentia Date: Thu, 27 Nov 2025 16:21:38 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix(test):=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/jobs/poll/poll.activate.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/jobs/poll/poll.activate.integration.test.ts b/tests/jobs/poll/poll.activate.integration.test.ts index b2f1c74b..0d62f682 100644 --- a/tests/jobs/poll/poll.activate.integration.test.ts +++ b/tests/jobs/poll/poll.activate.integration.test.ts @@ -258,7 +258,7 @@ describe('[PollActivateHandler] Integration', () => { const updated = await prisma.poll.findFirst({ where: { title: '직후 예약 투표' } }); expect(updated?.status).toBe(PollStatus.IN_PROGRESS); - expect(sendSseToUser).toHaveBeenCalledTimes(2); + expect(sendSseToUser).toHaveBeenCalled(); } finally { setTimeoutSpy.mockRestore(); } From 0d8bcfb258cc3b7d21b60674e0fcc83bbc111d27 Mon Sep 17 00:00:00 2001 From: selentia Date: Thu, 27 Nov 2025 16:21:57 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor(notice):=20apartment=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/notices/notices.repo.ts | 1 + src/modules/notices/notices.service.ts | 8 ++------ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/modules/notices/notices.repo.ts b/src/modules/notices/notices.repo.ts index 99efc63d..59c820af 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 261c451d..c3144524 100644 --- a/src/modules/notices/notices.service.ts +++ b/src/modules/notices/notices.service.ts @@ -15,7 +15,6 @@ import ApiError from '#errors/ApiError'; import { createLimit } from '#core/utils/Limiter'; import { getUserIdsForApartment } from '#modules/auth/auth.service'; import { createAndSendNotification } from '#core/utils/notificationHelper'; -import { getApartmentNameByIdRepo } from '#modules/apartments/apartments.repo'; const limit = createLimit(5); @@ -30,16 +29,13 @@ const limit = createLimit(5); export const createNoticeService = async (userId: string, data: NoticeCreateDTO) => { // 1. 관리자가 속한 아파트 ID 조회 const apartment = await getApartmentIdByAdminId(userId); - if (!apartment?.id) throw ApiError.badRequest(); - - const apartmentName = await getApartmentNameByIdRepo(apartment.id); - if (!apartmentName) throw ApiError.badRequest(); + if (!apartment) throw ApiError.badRequest(); // 2. 공지 생성 const notice = await createNoticeRepo(data, apartment.id); // 3. 아파트 주민 전체 ID 조회 - const residentUserIds = (await getUserIdsForApartment(apartmentName)).map((u) => u.id); + const residentUserIds = (await getUserIdsForApartment(apartment.apartmentName)).map((u) => u.id); if (residentUserIds.length === 0) { return notice;