import { describe, it, expect, vi, beforeEach } from "vitest"; import * as XLSX from "xlsx-js-style"; import { validateFileType, validateFileSize, getFileErrorMessage, filterValidFiles, getFileErrors, processExcelFile, MAX_FILE_SIZE, SUPPORTED_EXTENSIONS, } from "../fileProcessor"; // xlsx-js-style 모킹 (통합 처리) vi.mock("xlsx-js-style", () => ({ read: vi.fn(() => ({ SheetNames: ["Sheet1"], Sheets: { Sheet1: { A1: { v: "테스트" }, B1: { v: "한글" }, C1: { v: "데이터" }, "!ref": "A1:C2", }, }, })), write: vi.fn(() => new ArrayBuffer(1024)), // XLSX.write 모킹 추가 utils: { sheet_to_json: vi.fn(() => [ ["테스트", "한글", "데이터"], ["값1", "값2", "값3"], ]), decode_range: vi.fn((_ref) => ({ s: { r: 0, c: 0 }, e: { r: 1, c: 2 }, })), encode_cell: vi.fn( (cell) => `${String.fromCharCode(65 + cell.c)}${cell.r + 1}`, ), aoa_to_sheet: vi.fn(() => ({ A1: { v: "테스트" }, B1: { v: "한글" }, C1: { v: "데이터" }, "!ref": "A1:C1", })), book_new: vi.fn(() => ({ SheetNames: [], Sheets: {} })), book_append_sheet: vi.fn(), }, })); // LuckyExcel 모킹 vi.mock("luckyexcel", () => ({ transformExcelToLucky: vi.fn( (_arrayBuffer, successCallback, _errorCallback) => { // 성공적인 변환 결과 모킹 const mockResult = { sheets: [ { name: "Sheet1", index: "0", status: 1, order: 0, row: 2, column: 3, celldata: [ { r: 0, c: 0, v: { v: "테스트", m: "테스트", ct: { fa: "General", t: "g" } }, }, { r: 0, c: 1, v: { v: "한글", m: "한글", ct: { fa: "General", t: "g" } }, }, { r: 0, c: 2, v: { v: "데이터", m: "데이터", ct: { fa: "General", t: "g" } }, }, { r: 1, c: 0, v: { v: "값1", m: "값1", ct: { fa: "General", t: "g" } }, }, { r: 1, c: 1, v: { v: "값2", m: "값2", ct: { fa: "General", t: "g" } }, }, { r: 1, c: 2, v: { v: "값3", m: "값3", ct: { fa: "General", t: "g" } }, }, ], }, ], }; // 성공 콜백 비동기 호출 (ArrayBuffer 매개변수 대응) if (typeof successCallback === "function") { setTimeout(() => successCallback(mockResult, null), 0); } }, ), })); // 파일 생성 도우미 함수 function createMockFile( name: string, size: number, type: string = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ): File { const mockFile = new Blob(["mock file content"], { type }); Object.defineProperty(mockFile, "name", { value: name, writable: false, }); Object.defineProperty(mockFile, "size", { value: size, writable: false, }); // ArrayBuffer 메서드 모킹 Object.defineProperty(mockFile, "arrayBuffer", { value: async () => new ArrayBuffer(size), writable: false, }); return mockFile as File; } // FileList 모킹 class MockFileList { private _files: File[]; constructor(files: File[]) { this._files = files; } get length(): number { return this._files.length; } item(index: number): File | null { return this._files[index] || null; } get files(): FileList { const files = this._files; return Object.assign(files, { item: (index: number) => files[index] || null, [Symbol.iterator]: function* (): Generator { for (let i = 0; i < files.length; i++) { yield files[i]; } }, }) as unknown as FileList; } } describe("fileProcessor", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("validateFileType", () => { it("지원하는 파일 확장자를 승인해야 함", () => { const validFiles = [ createMockFile("test.xlsx", 1000), createMockFile("test.xls", 1000), createMockFile("test.csv", 1000), createMockFile("한글파일.xlsx", 1000), ]; validFiles.forEach((file) => { expect(validateFileType(file)).toBe(true); }); }); it("지원하지 않는 파일 확장자를 거부해야 함", () => { const invalidFiles = [ createMockFile("test.txt", 1000), createMockFile("test.pdf", 1000), createMockFile("test.doc", 1000), createMockFile("test", 1000), ]; invalidFiles.forEach((file) => { expect(validateFileType(file)).toBe(false); }); }); it("대소문자를 무시하고 파일 확장자를 검증해야 함", () => { const files = [ createMockFile("test.XLSX", 1000), createMockFile("test.XLS", 1000), createMockFile("test.CSV", 1000), ]; files.forEach((file) => { expect(validateFileType(file)).toBe(true); }); }); }); describe("validateFileSize", () => { it("허용된 크기의 파일을 승인해야 함", () => { const smallFile = createMockFile("small.xlsx", 1000); expect(validateFileSize(smallFile)).toBe(true); }); it("최대 크기를 초과한 파일을 거부해야 함", () => { const largeFile = createMockFile("large.xlsx", MAX_FILE_SIZE + 1); expect(validateFileSize(largeFile)).toBe(false); }); }); describe("getFileErrorMessage", () => { it("유효한 파일에 대해 빈 문자열을 반환해야 함", () => { const validFile = createMockFile("valid.xlsx", 1000); expect(getFileErrorMessage(validFile)).toBe(""); }); it("잘못된 파일 형식에 대해 적절한 오류 메시지를 반환해야 함", () => { const invalidFile = createMockFile("invalid.txt", 1000); const message = getFileErrorMessage(invalidFile); expect(message).toContain("지원되지 않는 파일 형식"); expect(message).toContain(SUPPORTED_EXTENSIONS.join(", ")); }); it("파일 크기 초과에 대해 적절한 오류 메시지를 반환해야 함", () => { const largeFile = createMockFile("large.xlsx", MAX_FILE_SIZE + 1); const message = getFileErrorMessage(largeFile); expect(message).toContain("파일 크기가 너무 큽니다"); }); }); describe("filterValidFiles", () => { it("유효한 파일들만 필터링해야 함", () => { const fileList = new MockFileList([ createMockFile("valid1.xlsx", 1000), createMockFile("invalid.txt", 1000), createMockFile("valid2.csv", 1000), createMockFile("large.xlsx", MAX_FILE_SIZE + 1), ]).files; const validFiles = filterValidFiles(fileList); expect(validFiles).toHaveLength(2); expect(validFiles[0].name).toBe("valid1.xlsx"); expect(validFiles[1].name).toBe("valid2.csv"); }); }); describe("getFileErrors", () => { it("무효한 파일들의 오류 목록을 반환해야 함", () => { const fileList = new MockFileList([ createMockFile("valid.xlsx", 1000), createMockFile("invalid.txt", 1000), createMockFile("large.xlsx", MAX_FILE_SIZE + 1), ]).files; const errors = getFileErrors(fileList); expect(errors).toHaveLength(2); expect(errors[0].file.name).toBe("invalid.txt"); expect(errors[0].error).toContain("지원되지 않는 파일 형식"); expect(errors[1].file.name).toBe("large.xlsx"); expect(errors[1].error).toContain("파일 크기가 너무 큽니다"); }); }); describe("SheetJS 통합 파일 처리", () => { beforeEach(() => { vi.clearAllMocks(); }); it("XLSX 파일을 성공적으로 처리해야 함", async () => { const xlsxFile = createMockFile( "test.xlsx", 1024, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ); const result = await processExcelFile(xlsxFile); expect(result.success).toBe(true); expect(result.data).toBeDefined(); expect(Array.isArray(result.data)).toBe(true); expect(result.data).toHaveLength(1); expect(result.data![0].name).toBe("Sheet1"); // XLSX 파일은 변환 없이 직접 처리되므로 XLSX.write가 호출되지 않음 }); it("XLS 파일을 성공적으로 처리해야 함", async () => { const xlsFile = createMockFile( "test.xls", 1024, "application/vnd.ms-excel", ); const result = await processExcelFile(xlsFile); expect(result.success).toBe(true); expect(result.data).toBeDefined(); expect(Array.isArray(result.data)).toBe(true); expect(result.data).toHaveLength(1); expect(result.data![0].name).toBe("Sheet1"); // XLS 파일은 SheetJS를 통해 XLSX로 변환 후 처리 expect(XLSX.write).toHaveBeenCalled(); }); it("CSV 파일을 성공적으로 처리해야 함", async () => { const csvFile = createMockFile("test.csv", 1024, "text/csv"); const result = await processExcelFile(csvFile); expect(result.success).toBe(true); expect(result.data).toBeDefined(); expect(Array.isArray(result.data)).toBe(true); expect(result.data).toHaveLength(1); expect(result.data![0].name).toBe("Sheet1"); // CSV 파일은 SheetJS를 통해 XLSX로 변환 후 처리 expect(XLSX.write).toHaveBeenCalled(); }); it("한글 파일명을 올바르게 처리해야 함", async () => { const koreanFile = createMockFile( "한글파일명_테스트데이터.xlsx", 1024, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ); const result = await processExcelFile(koreanFile); expect(result.success).toBe(true); expect(result.data).toBeDefined(); }); it("빈 파일을 적절히 처리해야 함", async () => { const emptyFile = createMockFile("empty.xlsx", 0); const result = await processExcelFile(emptyFile); expect(result.success).toBe(false); expect(result.error).toContain("파일이 비어있습니다"); }); it("유효하지 않은 workbook 을 처리해야 함", async () => { (XLSX.read as any).mockReturnValueOnce(null); const invalidFile = createMockFile("invalid.xlsx", 1024); const result = await processExcelFile(invalidFile); expect(result.success).toBe(false); expect(result.error).toContain("워크북을 생성할 수 없습니다"); }); it("시트가 없는 workbook을 처리해야 함", async () => { (XLSX.read as any).mockReturnValueOnce({ SheetNames: [], Sheets: {}, }); const noSheetsFile = createMockFile("no-sheets.xlsx", 1024); const result = await processExcelFile(noSheetsFile); expect(result.success).toBe(false); expect(result.error).toContain("시트 이름 정보가 없습니다"); }); it("Sheets 속성이 없는 workbook을 처리해야 함", async () => { (XLSX.read as any).mockReturnValueOnce({ SheetNames: ["Sheet1"], // Sheets 속성 누락 }); const corruptedFile = createMockFile("corrupted.xlsx", 1024); const result = await processExcelFile(corruptedFile); expect(result.success).toBe(false); expect(result.error).toContain("유효한 시트가 없습니다"); }); it("XLSX.read 실패 시 대체 인코딩을 시도해야 함", async () => { // 첫 번째 호출은 실패, 두 번째 호출은 성공 (XLSX.read as any) .mockImplementationOnce(() => { throw new Error("UTF-8 read failed"); }) .mockReturnValueOnce({ SheetNames: ["Sheet1"], Sheets: { Sheet1: { A1: { v: "성공" } }, }, }); const fallbackFile = createMockFile("fallback.csv", 1024, "text/csv"); const result = await processExcelFile(fallbackFile); expect(result.success).toBe(true); expect(XLSX.read).toHaveBeenCalledTimes(2); // CSV 파일은 TextDecoder를 사용하여 문자열로 읽어서 처리 expect(XLSX.read).toHaveBeenNthCalledWith( 2, expect.any(String), expect.objectContaining({ type: "string", codepage: 949, // EUC-KR 대체 인코딩 }), ); }); it("모든 읽기 시도가 실패하면 적절한 오류를 반환해야 함", async () => { (XLSX.read as any).mockImplementation(() => { throw new Error("Read completely failed"); }); const failedFile = createMockFile("failed.xlsx", 1024); const result = await processExcelFile(failedFile); expect(result.success).toBe(false); expect(result.error).toContain("파일을 읽을 수 없습니다"); }); it("한글 데이터를 올바르게 처리해야 함", async () => { // beforeEach에서 설정된 기본 모킹을 그대로 사용하지만, // 실제로는 시트명이 변경되지 않는 것이 정상 동작입니다. // LuckyExcel에서 변환할 때 시트명은 일반적으로 유지되지만, // 모킹 데이터에서는 "Sheet1"로 설정되어 있으므로 이를 맞춰야 합니다. // 한글 데이터가 포함된 시트 모킹 (XLSX.read as any).mockReturnValueOnce({ SheetNames: ["한글시트"], Sheets: { 한글시트: { A1: { v: "이름" }, B1: { v: "나이" }, C1: { v: "주소" }, A2: { v: "김철수" }, B2: { v: 30 }, C2: { v: "서울시 강남구" }, "!ref": "A1:C2", }, }, }); const koreanDataFile = createMockFile("한글데이터.xlsx", 1024); const result = await processExcelFile(koreanDataFile); expect(result.success).toBe(true); expect(result.data).toBeDefined(); expect(Array.isArray(result.data)).toBe(true); expect(result.data).toHaveLength(1); // 실제 모킹 데이터에서는 "Sheet1"을 사용하므로 이를 확인합니다. expect(result.data![0].name).toBe("Sheet1"); // 모킹 데이터의 실제 시트명 }); }); });