1번 요건 (탭 동기화):
localStorage 연동을 통해 한 탭에서 연장에 성공하면, 다른 모든 탭의 열린 다이얼로그가 자동으로 닫히며(onCancel() 실행) 타이머가 새 만료 시간으로 동기화됩니다.
2번 요건 (포커스 보정):
브라우저 탭 비활성화 등으로 타이머가 지연된 뒤 복귀(visibilitychange/focus)하는 순간 토큰 유효 기간을 즉시 재검증하여, 만료 시 강제 로그아웃을 유도하고 유효한 경우 남은 시간을 정확하게 보정하여 재예약하도록 개선하였습니다.
4번 요건 (비프음 및 푸시 알림):
파일 의존성 없이 Web Audio API를 통해 팝업 노출 시 즉각적인 비프(Beep) 경고 사운드를 재생하며, 브라우저 Notification API를 활용해 백그라운드 상태에서도 시스템 경고 알림을 전달합니다. (헤더 컴포넌트 마운트 시 알림 권한 정중히 요청)
단위 테스트 통과:
신규 15~18번 검증 테스트 케이스를 포함하여 총 18개 단위 테스트가 100% 정상 작동 및 성공(Pass) 하였습니다.
653 lines
25 KiB
TypeScript
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();
|
|
});
|
|
});
|