Files
frontend/apps/cs/tests/unit/use-session-extend-confirm.test.ts
T
changwng e93a83163e [cs frontend] 세션 체크 로직 핵심 구현 내역
1번 요건 (탭 동기화):
                        localStorage 연동을 통해 한 탭에서 연장에 성공하면, 다른 모든 탭의 열린 다이얼로그가 자동으로 닫히며(onCancel() 실행) 타이머가 새 만료 시간으로 동기화됩니다.
                        2번 요건 (포커스 보정):
                        브라우저 탭 비활성화 등으로 타이머가 지연된 뒤 복귀(visibilitychange/focus)하는 순간 토큰 유효 기간을 즉시 재검증하여, 만료 시 강제 로그아웃을 유도하고 유효한 경우 남은 시간을 정확하게 보정하여 재예약하도록 개선하였습니다.
                        4번 요건 (비프음 및 푸시 알림):
                        파일 의존성 없이 Web Audio API를 통해 팝업 노출 시 즉각적인 비프(Beep) 경고 사운드를 재생하며, 브라우저 Notification API를 활용해 백그라운드 상태에서도 시스템 경고 알림을 전달합니다. (헤더 컴포넌트 마운트 시 알림 권한 정중히 요청)
                        단위 테스트 통과:
                        신규 15~18번 검증 테스트 케이스를 포함하여 총 18개 단위 테스트가 100% 정상 작동 및 성공(Pass) 하였습니다.
2026-07-10 15:45:03 +09:00

653 lines
25 KiB
TypeScript

import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useSessionExtendConfirm } from "../../../../packages/common/components/src/lib/use-session-extend-confirm";
import { getRemainingMs } from "@common/auth";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
// useConfirm 모킹
const mockConfirm = vi.fn();
const mockOnCancel = vi.fn();
vi.mock("@frontend-opensource/use-react-hooks", () => ({
useConfirm: () => ({
confirm: mockConfirm,
onCancel: mockOnCancel,
}),
}));
// @common/auth 모킹
const mockGetRemainingMs = vi.fn();
vi.mock("@common/auth", () => ({
getRemainingMs: (...args: any[]) => mockGetRemainingMs(...args),
SESSION_EXTEND_CONFIRM_BUFFER_MS: 300000,
}));
// 글로벌 API 모킹 정의
const mockAudioContext = vi.fn().mockImplementation(function (this: any) {
this.createOscillator = vi.fn().mockImplementation(() => ({
connect: vi.fn(),
type: "sine",
frequency: { setValueAtTime: vi.fn() },
start: vi.fn(),
stop: vi.fn(),
}));
this.createGain = vi.fn().mockImplementation(() => ({
connect: vi.fn(),
gain: { setValueAtTime: vi.fn() },
}));
this.destination = {};
this.currentTime = 0;
});
const mockNotification = vi.fn();
(mockNotification as any).permission = "granted";
const mockLocalStorageStore: Record<string, string> = {};
const mockLocalStorage = {
getItem: vi.fn().mockImplementation((key) => mockLocalStorageStore[key] || null),
setItem: vi.fn().mockImplementation((key, val) => {
mockLocalStorageStore[key] = val;
}),
removeItem: vi.fn().mockImplementation((key) => {
delete mockLocalStorageStore[key];
}),
clear: vi.fn().mockImplementation(() => {
for (const k in mockLocalStorageStore) delete mockLocalStorageStore[k];
}),
};
describe("useSessionExtendConfirm 커스텀 훅 테스트 (Vitest)", () => {
const mockGetAccessToken = vi.fn();
const mockSilentRefresh = vi.fn();
const mockDeclineSessionExtend = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
vi.stubGlobal("AudioContext", mockAudioContext);
vi.stubGlobal("webkitAudioContext", mockAudioContext);
vi.stubGlobal("Notification", mockNotification);
vi.stubGlobal("localStorage", mockLocalStorage);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("1. enabled가 false일 때는 어떤 타이머나 컨펌도 동작하지 않아야 함", () => {
mockGetAccessToken.mockReturnValue("mock-token-123");
mockGetRemainingMs.mockReturnValue(10000); // 10초 뒤 컨펌 필요
renderHook(() =>
useSessionExtendConfirm({
enabled: false,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
// 타이머가 동작하지 않으므로 시간 경과 후에도 confirm이 실행되지 않아야 함
vi.advanceTimersByTime(10000);
expect(mockConfirm).not.toHaveBeenCalled();
});
it("2. 토큰 만료 버퍼 진입 전인 경우, 해당 시간만큼 대기 후 컨펌창이 나타나야 함", async () => {
mockGetAccessToken.mockReturnValue("mock-token-123");
// getRemainingMs(token, buffer) -> 10000ms (10초 후에 버퍼 시점 도달)
// getRemainingMs(token, 0) -> 310000ms (토큰 유효함)
mockGetRemainingMs.mockImplementation((token, buffer) => {
return buffer === 0 ? 310000 : 10000;
});
mockConfirm.mockResolvedValue(true);
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
// 9초 지났을 때는 아직 컨펌이 뜨지 않아야 함
vi.advanceTimersByTime(9000);
expect(mockConfirm).not.toHaveBeenCalled();
// 10초째에 진입했을 때 컨펌창 노출
await vi.advanceTimersByTimeAsync(1000);
expect(mockConfirm).toHaveBeenCalled();
expect(mockConfirm.mock.calls[0][0].props.message).toBe("로그인을 연장하시겠습니까?");
});
it("3. 컨펌창에서 연장을 승인(Accept)하면 silentRefresh가 호출되고 재스케줄링되어야 함", async () => {
let token = "mock-token-123";
mockGetAccessToken.mockImplementation(() => token);
mockGetRemainingMs.mockImplementation((t, buffer) => {
return buffer === 0 ? 310000 : 10000;
});
mockConfirm.mockResolvedValue(true); // 연장 승인
mockSilentRefresh.mockImplementation(async () => {
token = "new-mock-token-456"; // 토큰 갱신 모사
});
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
// 10초 대기하여 컨펌 활성화 및 연장 승인 처리 진행
await vi.advanceTimersByTimeAsync(10000);
expect(mockSilentRefresh).toHaveBeenCalled();
// 신규 발급된 토큰("new-mock-token-456")을 기반으로 getAccessToken이 재차 호출되었는지 확인
expect(mockGetAccessToken).toHaveBeenCalled();
});
it("4. 컨펌창에서 연장을 거절(Decline)하면 declineSessionExtend가 실행되고, 해당 토큰에 대해 재확인하지 않아야 함", async () => {
mockGetAccessToken.mockReturnValue("mock-token-123");
mockGetRemainingMs.mockImplementation((t, buffer) => {
return buffer === 0 ? 310000 : 10000;
});
mockConfirm.mockResolvedValue(false); // 연장 거절
const { rerender } = renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
// 10초 대기하여 거절 실행
await vi.advanceTimersByTimeAsync(10000);
expect(mockDeclineSessionExtend).toHaveBeenCalled();
expect(mockSilentRefresh).not.toHaveBeenCalled();
// 거절 이후 훅이 다시 실행되어도(혹은 상태가 업데이트되어 스케줄이 재호출되어도)
// dismissedTokenRef에 동일 토큰이 설정되어 있으므로 더 이상 컨펌창이 호출되지 않아야 함
mockConfirm.mockClear();
rerender();
await vi.advanceTimersByTimeAsync(10000);
expect(mockConfirm).not.toHaveBeenCalled();
});
it("5. 이미 만료 버퍼 이하로 남은 경우, 대기 시간 없이 즉시 컨펌창이 나타나야 함", async () => {
mockGetAccessToken.mockReturnValue("mock-token-123");
// getRemainingMs(token, buffer) -> 0ms (이미 버퍼 시간 이내임)
// getRemainingMs(token, 0) -> 50000ms (토큰 자체는 살아있음)
mockGetRemainingMs.mockImplementation((t, buffer) => {
return buffer === 0 ? 50000 : 0;
});
mockConfirm.mockResolvedValue(false); // 거절 처리하여 무한 루프 방지
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
// 타이머 흐름 대기 없이 즉시 동기/비동기 마이크로태스크 단계에서 컨펌 실행
await vi.advanceTimersByTimeAsync(1);
expect(mockConfirm).toHaveBeenCalled();
});
it("6. 토큰이 아예 유효하지 않거나 이미 만료된 경우 컨펌창이 뜨지 않아야 함", () => {
mockGetAccessToken.mockReturnValue("expired-token");
// getRemainingMs(token, 0) -> 0ms (만료됨)
mockGetRemainingMs.mockReturnValue(0);
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
vi.advanceTimersByTime(10000);
expect(mockConfirm).not.toHaveBeenCalled();
});
it("7. silentRefresh가 내부에서 실패를 처리(catch)하고 토큰을 제거하면 scheduleConfirm이 재실행되지 않아야 함", async () => {
// 실제 auth-service.ts의 silentRefresh는 내부에서 catch하고 re-throw하지 않음
// 대신 tokenManager.clearAuthToken()을 호출하여 토큰을 제거함
let tokenCleared = false;
mockGetAccessToken.mockImplementation(() =>
tokenCleared ? undefined : "mock-token-123"
);
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
return buffer === 0 ? 310000 : 10000;
});
mockConfirm.mockResolvedValue(true); // 연장 승인
// 실제 silentRefresh처럼 내부에서 오류를 catch하고 토큰을 제거하는 동작 모사
mockSilentRefresh.mockImplementation(async () => {
tokenCleared = true; // clearAuthToken → getAccessToken이 undefined 반환
});
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
// 10초 대기하여 컨펌 활성화 및 승인 후 silentRefresh 호출
await vi.advanceTimersByTimeAsync(10000);
expect(mockSilentRefresh).toHaveBeenCalled();
// silentRefresh 후 토큰이 제거되어 scheduleConfirm에서 token이 없으므로 early return
// 이후 추가 시간이 지나도 confirm이 다시 호출되지 않아야 함
mockConfirm.mockClear();
await vi.advanceTimersByTimeAsync(20000);
expect(mockConfirm).not.toHaveBeenCalled();
});
it("8. silentRefresh 성공 후 scheduleConfirm 재호출을 통해 다음 컨펌이 차단 없이 스케줄링되어야 함", async () => {
let callCount = 0;
let currentToken = "mock-token-123";
mockGetAccessToken.mockImplementation(() => currentToken);
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
return buffer === 0 ? 310000 : 5000; // 5초 뒤 컨펌 트리거
});
// 첫 번째 호출은 승인, 두 번째 호출은 거절
mockConfirm.mockImplementation(async () => {
callCount++;
return callCount === 1; // 첫 번째만 승인
});
// silentRefresh 성공 시 새 토큰 발급 모사
mockSilentRefresh.mockImplementation(async () => {
currentToken = "refreshed-token-456";
});
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
// 5초 대기하여 첫 번째 컨펌 활성화 → 승인 → silentRefresh → scheduleConfirm 재실행
await vi.advanceTimersByTimeAsync(5000);
expect(mockSilentRefresh).toHaveBeenCalledTimes(1);
expect(callCount).toBe(1);
// scheduleConfirm 재호출 후 5초 뒤 두 번째 컨펌 트리거
await vi.advanceTimersByTimeAsync(5000);
// isConfirmingRef가 올바르게 해제되어 두 번째 confirm이 호출됨
expect(callCount).toBe(2);
});
it("9. [취약점 검증] 다이얼로그 표시 중 토큰이 자연 만료되어도 확인 클릭 시 silentRefresh가 호출됨 (confirm 후 토큰 재검증 미수행)", async () => {
// ⚠️ 이 테스트는 훅의 취약점을 문서화합니다.
// showConfirm()은 confirm() 호출 전에만 토큰을 검증하고,
// confirm() resolve 후에는 토큰 상태를 재검증하지 않습니다.
let tokenExpired = false;
mockGetAccessToken.mockReturnValue("mock-token-123");
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
if (tokenExpired) {
return 0; // 토큰 만료됨
}
return buffer === 0 ? 310000 : 0; // 즉시 컨펌 트리거, 토큰 유효
});
// confirm이 호출되면, 응답 전에 토큰을 만료시킴
mockConfirm.mockImplementation(async () => {
tokenExpired = true; // 사용자가 응답하기 전에 토큰 만료
return true; // "확인" 클릭
});
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
await vi.advanceTimersByTimeAsync(1);
expect(mockConfirm).toHaveBeenCalled();
// ⚠️ 훅이 confirm() 후 토큰을 재검증하지 않으므로 silentRefresh가 호출됨
// 이것은 현재 코드의 의도적 단순화이거나 취약점임
expect(mockSilentRefresh).toHaveBeenCalled();
expect(mockDeclineSessionExtend).not.toHaveBeenCalled();
});
it("10. [취약점 검증] 다이얼로그 대기 중 토큰이 undefined로 변경되어도 확인 클릭 시 silentRefresh가 호출됨 (confirm 후 토큰 재검증 미수행)", async () => {
// ⚠️ 이 테스트는 훅의 취약점을 문서화합니다.
// showConfirm() 시작 시점에 캡처한 currentToken으로만 검증하고,
// confirm() resolve 후에는 getAccessToken()을 재호출하지 않습니다.
let tokenRemoved = false;
mockGetAccessToken.mockImplementation(() => {
return tokenRemoved ? undefined : "mock-token-123";
});
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
return buffer === 0 ? 310000 : 0; // 즉시 컨펌 트리거
});
// confirm이 호출되면, 응답 전에 토큰을 제거 (다른 탭에서 로그아웃 시뮬레이션)
mockConfirm.mockImplementation(async () => {
tokenRemoved = true;
return true;
});
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
await vi.advanceTimersByTimeAsync(1);
expect(mockConfirm).toHaveBeenCalled();
// ⚠️ 훅이 confirm() 후 토큰을 재검증하지 않으므로 silentRefresh가 호출됨
// 다른 탭에서 로그아웃되었지만 silentRefresh가 실행되어 의도치 않은 동작 발생 가능
expect(mockSilentRefresh).toHaveBeenCalled();
expect(mockDeclineSessionExtend).not.toHaveBeenCalled();
});
it("11. confirm 대기 중 enabled가 토글되어도 isConfirmingRef 가드가 중복 호출을 차단해야 함", async () => {
mockGetAccessToken.mockReturnValue("mock-token-123");
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
return buffer === 0 ? 310000 : 0; // 즉시 컨펌 트리거
});
// confirm을 수동으로 지연 제어
let resolveConfirm: ((value: boolean) => void) | null = null;
mockConfirm.mockImplementation(() => {
return new Promise<boolean>((resolve) => {
resolveConfirm = resolve;
});
});
let enabled = true;
const { rerender } = renderHook(
({ enabled: e }: { enabled: boolean }) =>
useSessionExtendConfirm({
enabled: e,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
}),
{ initialProps: { enabled } }
);
// showConfirm이 시작됨 (confirm 대기 중)
await vi.advanceTimersByTimeAsync(1);
expect(mockConfirm).toHaveBeenCalledTimes(1);
// enabled를 false → true로 토글하여 스케줄 재실행 유도
rerender({ enabled: false });
rerender({ enabled: true });
await vi.advanceTimersByTimeAsync(1);
// isConfirmingRef 가드로 인해 추가 confirm은 호출되지 않아야 함
expect(mockConfirm).toHaveBeenCalledTimes(1);
// 기존 confirm을 resolve
if (resolveConfirm) resolveConfirm(false);
await vi.advanceTimersByTimeAsync(1);
expect(mockDeclineSessionExtend).toHaveBeenCalled();
});
it("12. dismiss 후 새 토큰으로 갱신되면 해당 토큰에 대해 컨펌이 재스케줄링되어야 함", async () => {
let currentToken = "old-token-111";
mockGetAccessToken.mockImplementation(() => currentToken);
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
return buffer === 0 ? 310000 : 0; // 즉시 컨펌 트리거
});
// 첫 번째 호출은 거절
mockConfirm.mockResolvedValueOnce(false);
let enabled = true;
const { rerender } = renderHook(
({ enabled: e }: { enabled: boolean }) =>
useSessionExtendConfirm({
enabled: e,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
}),
{ initialProps: { enabled } }
);
await vi.advanceTimersByTimeAsync(1);
expect(mockConfirm).toHaveBeenCalledTimes(1);
expect(mockDeclineSessionExtend).toHaveBeenCalledTimes(1);
// 거절 후 동일 토큰이면 재노출되지 않아야 함
mockConfirm.mockClear();
rerender({ enabled: false });
rerender({ enabled: true });
await vi.advanceTimersByTimeAsync(1);
expect(mockConfirm).not.toHaveBeenCalled();
// 새 토큰으로 변경 (다른 탭에서 로그인 등)
currentToken = "new-token-222";
mockConfirm.mockResolvedValueOnce(false);
// enabled 토글로 useEffect 재실행 유도
rerender({ enabled: false });
rerender({ enabled: true });
await vi.advanceTimersByTimeAsync(1);
// 새 토큰이므로 dismissedTokenRef와 다르므로 컨펌이 다시 노출되어야 함
expect(mockConfirm).toHaveBeenCalledTimes(1);
});
it("13. message 파라미터를 생략하면 기본 메시지('로그인을 연장하시겠습니까?')로 컨펌이 호출되어야 함", async () => {
mockGetAccessToken.mockReturnValue("mock-token-123");
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
return buffer === 0 ? 310000 : 0; // 즉시 컨펌 트리거
});
mockConfirm.mockResolvedValue(false);
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
// message 파라미터 의도적으로 생략
})
);
await vi.advanceTimersByTimeAsync(1);
expect(mockConfirm).toHaveBeenCalled();
expect(mockConfirm.mock.calls[0][0].props.message).toBe("로그인을 연장하시겠습니까?");
});
it("14. 카운트다운 시간이 경과하여 SessionTimeoutMessage에서 onTimeout이 호출되면 declineSessionExtend와 onTimeout이 즉시 호출되어야 함", async () => {
mockGetAccessToken.mockReturnValue("mock-token-123");
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
return buffer === 0 ? 300000 : 0; // 즉시 컨펌 트리거
});
const mockOnTimeout = vi.fn();
mockConfirm.mockImplementation(() => new Promise(() => {})); // confirm 대기 상태 유지
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
onTimeout: mockOnTimeout,
})
);
await vi.advanceTimersByTimeAsync(1);
expect(mockConfirm).toHaveBeenCalled();
// confirm에 전달된 React Element 꺼내기
const confirmElement = mockConfirm.mock.calls[0][0];
expect(confirmElement.props.initialRemainingMs).toBe(300000);
// 타이머 만료 시뮬레이션: 컴포넌트의 onTimeout을 직접 트리거
confirmElement.props.onTimeout();
// 연장 거부 및 타임아웃 콜백 정상 호출 검증
expect(mockDeclineSessionExtend).toHaveBeenCalled();
expect(mockOnTimeout).toHaveBeenCalled();
});
it("15. 1번 요건: 다른 탭에서 세션이 연장(storage 이벤트)되면 노출 중인 컨펌 다이얼로그가 자동으로 닫히고 재스케줄링되어야 함", async () => {
mockGetAccessToken.mockReturnValue("mock-token-123");
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
return buffer === 0 ? 300000 : 0; // 즉시 컨펌 트리거
});
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
// 즉시 다이얼로그 노출 확인
await vi.advanceTimersByTimeAsync(1);
expect(mockConfirm).toHaveBeenCalled();
// 다른 탭의 연장 성공 시나리오 모사: storage 이벤트 발송
const storageEvent = new StorageEvent("storage", {
key: "session-extended-trigger",
newValue: Date.now().toString(),
});
window.dispatchEvent(storageEvent);
// mockOnCancel이 호출되어 컨펌 창이 자동으로 닫혔는지 검증
expect(mockOnCancel).toHaveBeenCalled();
});
it("16. 2번 요건: 브라우저 활성화(visibilitychange) 시점에 토큰이 이미 만료된 상태라면 즉시 타임아웃 처리가 수행되어야 함", async () => {
let currentToken = "mock-token-123";
mockGetAccessToken.mockImplementation(() => currentToken);
// 0 버퍼 기준, 포커스 복귀 시점에 이미 만료된 것으로 설정
let tokenExpired = false;
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
if (tokenExpired) {
return 0; // 만료됨
}
return buffer === 0 ? 300000 : 10000; // 아직 10초 뒤 팝업 예정
});
const mockOnTimeout = vi.fn();
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
onTimeout: mockOnTimeout,
})
);
// 아직 10초 남았으므로 다이얼로그는 뜨지 않은 상태
await vi.advanceTimersByTimeAsync(1);
expect(mockConfirm).not.toHaveBeenCalled();
// 사용자가 다른 작업 후 복귀하여 탭 활성화 시뮬레이션 + 토큰 만료 상태
tokenExpired = true;
currentToken = "expired-token";
// visibilitychange 이벤트 트리거
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
document.dispatchEvent(new Event("visibilitychange"));
// 즉시 타임아웃 및 세션 정지 처리되었는지 검증
expect(mockDeclineSessionExtend).toHaveBeenCalled();
expect(mockOnTimeout).toHaveBeenCalled();
});
it("17. 2번 요건: 브라우저 활성화 시점에 토큰이 아직 유효하다면 남은 시간 기준 스케줄 오차가 보정되어야 함", async () => {
let currentToken = "mock-token-123";
mockGetAccessToken.mockImplementation(() => currentToken);
// 최초에는 10초 뒤 팝업
let confirmMs = 10000;
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
return buffer === 0 ? 310000 : confirmMs;
});
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
// 탭 복귀 시점에 남은 시간이 3초로 줄어듦 (시간 경과 보정)
confirmMs = 3000;
// visibilitychange 이벤트 트리거
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
document.dispatchEvent(new Event("visibilitychange"));
// 2초 뒤에는 팝업이 뜨지 않아야 함 (3초 남았으므로)
await vi.advanceTimersByTimeAsync(2000);
expect(mockConfirm).not.toHaveBeenCalled();
// 3초가 되는 시점(총 3초 경과)에 팝업이 안전하게 노출되어야 함
await vi.advanceTimersByTimeAsync(1000);
expect(mockConfirm).toHaveBeenCalled();
});
it("18. 4번 요건: 다이얼로그 활성화 시 무자원 오디오 비프음 및 시스템 알림(Notification) 전송 함수가 호출되어야 함", async () => {
mockGetAccessToken.mockReturnValue("mock-token-123");
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
return buffer === 0 ? 300000 : 0; // 즉시 컨펌 트리거
});
renderHook(() =>
useSessionExtendConfirm({
enabled: true,
getAccessToken: mockGetAccessToken,
silentRefresh: mockSilentRefresh,
declineSessionExtend: mockDeclineSessionExtend,
})
);
// 즉시 다이얼로그 호출
await vi.advanceTimersByTimeAsync(1);
expect(mockConfirm).toHaveBeenCalled();
// Web Audio API의 AudioContext가 인스턴스화되었는지 검증
expect(mockAudioContext).toHaveBeenCalled();
// Notification API가 인스턴스화되었는지 검증
expect(mockNotification).toHaveBeenCalled();
});
});