diff --git a/src/hooks/useDebounce.test.jsx b/src/hooks/useDebounce.test.jsx new file mode 100644 index 0000000..ba3c12c --- /dev/null +++ b/src/hooks/useDebounce.test.jsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useDebouncedInput } from '@/hooks/useDebounce'; + +describe('useDebouncedInput', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('지연 시간 전에는 setState가 호출되지 않는다', () => { + const setState = vi.fn(); + const { result } = renderHook(() => useDebouncedInput(setState, 300)); + + act(() => { + result.current('name', '홍길동'); + }); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('지연 시간 후에 setState가 호출된다', () => { + const setState = vi.fn(); + const { result } = renderHook(() => useDebouncedInput(setState, 300)); + + act(() => { + result.current('name', '홍길동'); + vi.advanceTimersByTime(300); + }); + + expect(setState).toHaveBeenCalledTimes(1); + }); + + it('연속 호출 시 마지막 호출만 실행된다', () => { + const setState = vi.fn(); + const { result } = renderHook(() => useDebouncedInput(setState, 300)); + + act(() => { + result.current('name', '첫번째'); + result.current('name', '두번째'); + result.current('name', '세번째'); + vi.advanceTimersByTime(300); + }); + + expect(setState).toHaveBeenCalledTimes(1); + }); + + it('setState에 이전 상태를 기반으로 업데이트 함수를 전달한다', () => { + const setState = vi.fn(); + const { result } = renderHook(() => useDebouncedInput(setState, 300)); + + act(() => { + result.current('email', 'test@example.com'); + vi.advanceTimersByTime(300); + }); + + expect(setState).toHaveBeenCalledWith(expect.any(Function)); + + const updater = setState.mock.calls[0][0]; + const prevState = { name: '홍길동' }; + expect(updater(prevState)).toEqual({ name: '홍길동', email: 'test@example.com' }); + }); + + it('서로 다른 필드를 각각 디바운싱할 수 있다', () => { + const setState = vi.fn(); + const { result } = renderHook(() => useDebouncedInput(setState, 300)); + + act(() => { + result.current('title', '제목'); + vi.advanceTimersByTime(300); + }); + + act(() => { + result.current('content', '내용'); + vi.advanceTimersByTime(300); + }); + + expect(setState).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/utils/calc.test.js b/src/utils/calc.test.js new file mode 100644 index 0000000..a220e05 --- /dev/null +++ b/src/utils/calc.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { itemIdxByPage } from '@/utils/calc'; + +describe('itemIdxByPage', () => { + it('첫 페이지 첫 번째 항목의 역순 인덱스를 반환한다', () => { + // resultCnt=10, 1페이지, pageSize=5, index=0 → 10+1-(0*5+0+1) = 10 + expect(itemIdxByPage(10, 1, 5, 0)).toBe(10); + }); + + it('첫 페이지 마지막 항목의 역순 인덱스를 반환한다', () => { + // resultCnt=10, 1페이지, pageSize=5, index=4 → 10+1-(0*5+4+1) = 6 + expect(itemIdxByPage(10, 1, 5, 4)).toBe(6); + }); + + it('두 번째 페이지 첫 번째 항목의 역순 인덱스를 반환한다', () => { + // resultCnt=10, 2페이지, pageSize=5, index=0 → 10+1-(1*5+0+1) = 5 + expect(itemIdxByPage(10, 2, 5, 0)).toBe(5); + }); + + it('두 번째 페이지 마지막 항목의 역순 인덱스를 반환한다', () => { + // resultCnt=10, 2페이지, pageSize=5, index=4 → 10+1-(1*5+4+1) = 1 + expect(itemIdxByPage(10, 2, 5, 4)).toBe(1); + }); + + it('resultCnt가 0이면 음수 인덱스를 반환할 수 있다', () => { + // resultCnt=0, 1페이지, pageSize=5, index=0 → 0+1-(0+0+1) = 0 + expect(itemIdxByPage(0, 1, 5, 0)).toBe(0); + }); + + it('pageSize=10인 경우도 올바르게 동작한다', () => { + // resultCnt=25, 3페이지, pageSize=10, index=2 → 25+1-(2*10+2+1) = 3 + expect(itemIdxByPage(25, 3, 10, 2)).toBe(3); + }); +}); diff --git a/src/utils/passwordHash.test.js b/src/utils/passwordHash.test.js new file mode 100644 index 0000000..7dadd3d --- /dev/null +++ b/src/utils/passwordHash.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { hashPassword } from '@/utils/passwordHash'; + +describe('hashPassword', () => { + it('동일한 id와 password는 동일한 해시를 반환한다', async () => { + const hash1 = await hashPassword('user01', 'password123'); + const hash2 = await hashPassword('user01', 'password123'); + expect(hash1).toBe(hash2); + }); + + it('Base64 문자열을 반환한다', async () => { + const hash = await hashPassword('user01', 'password123'); + expect(typeof hash).toBe('string'); + // Base64 형식 검증 (알파벳, 숫자, +, /, = 만 허용) + expect(hash).toMatch(/^[A-Za-z0-9+/]+=*$/); + }); + + it('password가 달라지면 다른 해시를 반환한다', async () => { + const hash1 = await hashPassword('user01', 'password123'); + const hash2 = await hashPassword('user01', 'differentPassword'); + expect(hash1).not.toBe(hash2); + }); + + it('id가 달라지면 다른 해시를 반환한다', async () => { + const hash1 = await hashPassword('user01', 'password123'); + const hash2 = await hashPassword('user02', 'password123'); + expect(hash1).not.toBe(hash2); + }); + + it('SHA-256 기반 Base64 결과는 44자이다', async () => { + const hash = await hashPassword('user01', 'password123'); + // SHA-256은 32바이트 → Base64 인코딩 시 44자 (패딩 포함) + expect(hash.length).toBe(44); + }); +}); diff --git a/src/utils/storage.test.js b/src/utils/storage.test.js new file mode 100644 index 0000000..f2281a0 --- /dev/null +++ b/src/utils/storage.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + getLocalItem, + setLocalItem, + removeLocalItem, + getSessionItem, + setSessionItem, + removeSessionItem, +} from '@/utils/storage'; + +function createStorageMock() { + let store = {}; + return { + getItem: (key) => store[key] ?? null, + setItem: (key, value) => { store[key] = String(value); }, + removeItem: (key) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +} + +describe('storage utils', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', createStorageMock()); + vi.stubGlobal('sessionStorage', createStorageMock()); + }); + + describe('localStorage', () => { + it('값을 저장하고 가져온다', () => { + setLocalItem('user', { id: 1, name: '홍길동' }); + expect(getLocalItem('user')).toEqual({ id: 1, name: '홍길동' }); + }); + + it('존재하지 않는 키는 null을 반환한다', () => { + expect(getLocalItem('missing')).toBeNull(); + }); + + it('항목을 삭제하면 null을 반환한다', () => { + setLocalItem('token', 'abc'); + removeLocalItem('token'); + expect(getLocalItem('token')).toBeNull(); + }); + + it('문자열 값을 저장하고 가져온다', () => { + setLocalItem('lang', 'ko'); + expect(getLocalItem('lang')).toBe('ko'); + }); + + it('숫자 값을 저장하고 가져온다', () => { + setLocalItem('count', 42); + expect(getLocalItem('count')).toBe(42); + }); + + it('undefined를 저장하면 null로 직렬화된다', () => { + setLocalItem('empty', undefined); + expect(getLocalItem('empty')).toBeNull(); + }); + }); + + describe('sessionStorage', () => { + it('값을 저장하고 가져온다', () => { + setSessionItem('session', { token: 'xyz' }); + expect(getSessionItem('session')).toEqual({ token: 'xyz' }); + }); + + it('존재하지 않는 키는 null을 반환한다', () => { + expect(getSessionItem('missing')).toBeNull(); + }); + + it('항목을 삭제하면 null을 반환한다', () => { + setSessionItem('state', 'active'); + removeSessionItem('state'); + expect(getSessionItem('state')).toBeNull(); + }); + }); +});