diff --git a/src/feature/create-album/utils/checkImages.test.ts b/src/feature/create-album/utils/checkImages.test.ts new file mode 100644 index 0000000..ff51c0e --- /dev/null +++ b/src/feature/create-album/utils/checkImages.test.ts @@ -0,0 +1,43 @@ +import { checkAvailableCount } from '@/feature/create-album/api/checkAvailableCount'; +import { validateImages } from '@/feature/create-album/utils/validateImages'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { checkImages } from './checkImages'; + +// Mock dependencies +vi.mock('@/feature/create-album/api/checkAvailableCount', () => ({ + checkAvailableCount: vi.fn(), +})); + +vi.mock('@/feature/create-album/utils/validateImages', () => ({ + validateImages: vi.fn(), +})); + +describe('checkImages', () => { + const mockFiles = [new File([], 'test1.jpg')]; + const albumId = 'test-album-id'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return oversized files and available count', async () => { + const mockOversizedFiles = ['oversized.jpg']; + const mockAvailableCount = 10; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (validateImages as any).mockReturnValue({ + oversizedFiles: mockOversizedFiles, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (checkAvailableCount as any).mockResolvedValue(mockAvailableCount); + + const result = await checkImages(mockFiles, albumId); + + expect(result).toEqual({ + oversizedFiles: mockOversizedFiles, + availableCount: mockAvailableCount, + }); + expect(validateImages).toHaveBeenCalledWith(mockFiles); + expect(checkAvailableCount).toHaveBeenCalledWith(albumId); + }); +}); diff --git a/src/feature/create-album/utils/handleFileUpload.test.ts b/src/feature/create-album/utils/handleFileUpload.test.ts new file mode 100644 index 0000000..ff5d1f0 --- /dev/null +++ b/src/feature/create-album/utils/handleFileUpload.test.ts @@ -0,0 +1,114 @@ +import { presignedAndUploadToNCP } from '@/global/api/presignedAndUploadToNCP'; +import { useUploadingStore } from '@/store/useUploadingStore'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getFilesWithCaptureTime } from './getFilesWithCaptureTime'; +import { handleFileUpload } from './handleFileUpload'; +import { convertHeicFilesToJpeg } from './heicToJpeg'; +import { saveFilesToStore } from './saveFilesToStore'; +import { sortImagesByDate } from './sortImagesByDate'; +import { validateUpload } from './validateUpload'; + +// Mock dependencies +vi.mock('@/global/api/presignedAndUploadToNCP', () => ({ + presignedAndUploadToNCP: vi.fn(), +})); + +vi.mock('@/store/useUploadingStore', () => ({ + useUploadingStore: { + getState: vi.fn().mockReturnValue({ + setUploaded: vi.fn(), + setUploadedCount: vi.fn(), + }), + }, +})); + +vi.mock('./getFilesWithCaptureTime', () => ({ + getFilesWithCaptureTime: vi.fn(), +})); + +vi.mock('./heicToJpeg', () => ({ + convertHeicFilesToJpeg: vi.fn(), +})); + +vi.mock('./saveFilesToStore', () => ({ + saveFilesToStore: vi.fn(), +})); + +vi.mock('./sortImagesByDate', () => ({ + sortImagesByDate: vi.fn(), +})); + +vi.mock('./validateUpload', () => ({ + validateUpload: vi.fn(), +})); + +describe('handleFileUpload', () => { + const mockRouter = { push: vi.fn(), replace: vi.fn() }; + const albumId = 'test-album-id'; + const mockFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' }); + const mockEvent = { + target: { + files: [mockFile], + value: 'some-value', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + // Default mock implementations + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (sortImagesByDate as any).mockResolvedValue([mockFile]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (convertHeicFilesToJpeg as any).mockResolvedValue([mockFile]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (validateUpload as any).mockResolvedValue({ ok: true }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (getFilesWithCaptureTime as any).mockResolvedValue([ + { file: mockFile, captureTime: '2023-01-01' }, + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (presignedAndUploadToNCP as any).mockResolvedValue({ + success: 1, + failed: 0, + }); + }); + + it('should orchestrate the upload flow correctly', async () => { + await handleFileUpload(mockEvent, albumId, mockRouter); + + expect(sortImagesByDate).toHaveBeenCalled(); + expect(convertHeicFilesToJpeg).toHaveBeenCalled(); + expect(validateUpload).toHaveBeenCalled(); + expect(useUploadingStore.getState().setUploaded).toHaveBeenCalledWith(true); + expect(mockRouter.push).toHaveBeenCalledWith(`/album/${albumId}/waiting`); + expect(getFilesWithCaptureTime).toHaveBeenCalled(); + expect(presignedAndUploadToNCP).toHaveBeenCalled(); + expect(useUploadingStore.getState().setUploadedCount).toHaveBeenCalledWith( + 1, + ); + }); + + it('should handle validation failure', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (validateUpload as any).mockResolvedValue({ ok: false, reason: 'size' }); + + await handleFileUpload(mockEvent, albumId, mockRouter); + + expect(saveFilesToStore).toHaveBeenCalled(); + expect(mockRouter.push).toHaveBeenCalledWith(`/album/${albumId}/waiting`); + expect(presignedAndUploadToNCP).not.toHaveBeenCalled(); + }); + + it('should not redirect if stay option is true', async () => { + await handleFileUpload(mockEvent, albumId, mockRouter, { stay: true }); + + expect(mockRouter.push).not.toHaveBeenCalled(); + }); + + it('should clear input value after execution', async () => { + await handleFileUpload(mockEvent, albumId, mockRouter); + + expect(mockEvent.target.value).toBe(''); + }); +}); diff --git a/src/feature/create-album/utils/heicToJpeg.test.ts b/src/feature/create-album/utils/heicToJpeg.test.ts new file mode 100644 index 0000000..3ad1fa0 --- /dev/null +++ b/src/feature/create-album/utils/heicToJpeg.test.ts @@ -0,0 +1,75 @@ +import Toast from '@/global/components/toast/Toast'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { convertHeicFilesToJpeg } from './heicToJpeg'; + +// Mock dependencies +vi.mock('@/global/components/toast/Toast', () => ({ + default: { + alert: vi.fn(), + }, +})); + +vi.mock('heic-to', () => ({ + heicTo: vi.fn().mockImplementation(async ({ blob }) => { + // Return a dummy blob as if it were a JPEG + return new Blob(['dummy-jpeg-content'], { type: 'image/jpeg' }); + }), +})); + +describe('convertHeicFilesToJpeg', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return original files if no HEIC files are present', async () => { + const files = [ + new File(['content'], 'test1.jpg', { type: 'image/jpeg' }), + new File(['content'], 'test2.png', { type: 'image/png' }), + ]; + + const result = await convertHeicFilesToJpeg(files); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('test1.jpg'); + expect(result[1].name).toBe('test2.png'); + expect(Toast.alert).not.toHaveBeenCalled(); + }); + + it('should convert HEIC files to JPEG', async () => { + const files = [ + new File(['heic-content'], 'image.heic', { type: 'image/heic' }), + new File(['jpg-content'], 'image.jpg', { type: 'image/jpeg' }), + ]; + + const result = await convertHeicFilesToJpeg(files); + + expect(result).toHaveLength(2); + // The first file should be converted to jpg + expect(result[0].name).toBe('image.jpg'); + expect(result[0].type).toBe('image/jpeg'); + // The second file should remain as is + expect(result[1].name).toBe('image.jpg'); + expect(Toast.alert).toHaveBeenCalledWith( + '1개의 HEIC 파일을 변환 중입니다...', + ); + }); + + it('should handle conversion failure gracefully', async () => { + const { heicTo } = await import('heic-to'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (heicTo as any).mockRejectedValueOnce(new Error('Conversion failed')); + + const files = [ + new File(['heic-content'], 'fail.heic', { type: 'image/heic' }), + ]; + + const result = await convertHeicFilesToJpeg(files); + + expect(result).toHaveLength(1); + // Should return original file on failure + expect(result[0].name).toBe('fail.heic'); + expect(Toast.alert).toHaveBeenCalledWith( + 'fail.heic 파일의 변환에 실패했습니다.', + ); + }); +}); diff --git a/src/feature/create-album/utils/validateUpload.test.ts b/src/feature/create-album/utils/validateUpload.test.ts new file mode 100644 index 0000000..b7fc956 --- /dev/null +++ b/src/feature/create-album/utils/validateUpload.test.ts @@ -0,0 +1,74 @@ +import { checkAvailableCount } from '@/feature/create-album/api/checkAvailableCount'; +import { validateImageCount } from '@/feature/create-album/utils/validateImages'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { validateUpload } from './validateUpload'; + +// Mock dependencies +vi.mock('@/feature/create-album/api/checkAvailableCount', () => ({ + checkAvailableCount: vi.fn(), +})); + +vi.mock('@/feature/create-album/utils/validateImages', () => ({ + validateImageCount: vi.fn(), +})); + +describe('validateUpload', () => { + const mockFiles = [new File([], 'test1.jpg'), new File([], 'test2.jpg')]; + const albumId = 'test-album-id'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return ok: true when validation passes', async () => { + // Mock validateImageCount to return 0 (no oversized files) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (validateImageCount as any).mockReturnValue(0); + // Mock checkAvailableCount to return enough space + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (checkAvailableCount as any).mockResolvedValue(5); + + const result = await validateUpload(mockFiles, albumId); + + expect(result).toEqual({ ok: true }); + }); + + it('should return ok: false with reason: size when files are oversized', async () => { + // Mock validateImageCount to return 1 (1 oversized file) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (validateImageCount as any).mockReturnValue(1); + // Mock checkAvailableCount to return enough space + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (checkAvailableCount as any).mockResolvedValue(5); + + const result = await validateUpload(mockFiles, albumId); + + expect(result).toEqual({ ok: false, reason: 'size' }); + }); + + it('should return ok: false with reason: count when file count exceeds limit', async () => { + // Mock validateImageCount to return 0 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (validateImageCount as any).mockReturnValue(0); + // Mock checkAvailableCount to return less than file count + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (checkAvailableCount as any).mockResolvedValue(1); + + const result = await validateUpload(mockFiles, albumId); + + expect(result).toEqual({ ok: false, reason: 'count' }); + }); + + it('should return ok: false with reason: both when both checks fail', async () => { + // Mock validateImageCount to return 1 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (validateImageCount as any).mockReturnValue(1); + // Mock checkAvailableCount to return less than file count + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (checkAvailableCount as any).mockResolvedValue(1); + + const result = await validateUpload(mockFiles, albumId); + + expect(result).toEqual({ ok: false, reason: 'both' }); + }); +}); diff --git a/src/feature/onboarding/hooks/useOnBoardingMutation.test.tsx b/src/feature/onboarding/hooks/useOnBoardingMutation.test.tsx new file mode 100644 index 0000000..8ce1a8a --- /dev/null +++ b/src/feature/onboarding/hooks/useOnBoardingMutation.test.tsx @@ -0,0 +1,67 @@ +import { api } from '@/global/utils/api'; +import { useMutation } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useOnBoardingMutation } from './useOnBoardingMutation'; + +// Mock api +vi.mock('@/global/utils/api', () => ({ + api: { + post: vi.fn(), + }, +})); + +// Mock useMutation +vi.mock('@tanstack/react-query', () => ({ + useMutation: vi.fn(), +})); + +describe('useOnBoardingMutation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call useMutation with correct mutationFn', () => { + useOnBoardingMutation(); + + expect(useMutation).toHaveBeenCalledWith( + expect.objectContaining({ + mutationFn: expect.any(Function), + }), + ); + }); + + it('should call api.post when mutationFn is executed', async () => { + // Capture the mutationFn passed to useMutation + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let capturedMutationFn: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (useMutation as any).mockImplementation(({ mutationFn }: any) => { + capturedMutationFn = mutationFn; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; + }); + + useOnBoardingMutation(); + + const mockPayload = { + name: 'Test User', + imageCode: 'img-123', + isServiceAgreement: true, + isUserInfoAgreement: true, + isMarketingAgreement: false, + isThirdPartyAgreement: true, + }; + + const mockResponse = { result: { success: true } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (api.post as any).mockResolvedValue(mockResponse); + + // Execute the captured mutationFn + await capturedMutationFn(mockPayload); + + expect(api.post).toHaveBeenCalledWith({ + path: expect.stringContaining('/user/onboarding'), + body: mockPayload, + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..432b08c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', // Default to node, override per file if needed + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});