| | import { createReadStream } from 'fs'; |
| | import { readFile, stat } from 'fs/promises'; |
| | import { Readable } from 'stream'; |
| | import { readFileAsString, readFileAsBuffer, readJsonFile } from '../files'; |
| |
|
| | jest.mock('fs'); |
| | jest.mock('fs/promises'); |
| |
|
| | describe('File utilities', () => { |
| | const mockFilePath = '/test/file.txt'; |
| | const smallContent = 'Hello, World!'; |
| | const largeContent = 'x'.repeat(11 * 1024 * 1024); |
| |
|
| | beforeEach(() => { |
| | jest.clearAllMocks(); |
| | }); |
| |
|
| | describe('readFileAsString', () => { |
| | it('should read small files directly without streaming', async () => { |
| | const fileSize = Buffer.byteLength(smallContent); |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: fileSize }); |
| | (readFile as jest.Mock).mockResolvedValue(smallContent); |
| |
|
| | const result = await readFileAsString(mockFilePath); |
| |
|
| | expect(result).toEqual({ |
| | content: smallContent, |
| | bytes: fileSize, |
| | }); |
| | expect(stat).toHaveBeenCalledWith(mockFilePath); |
| | expect(readFile).toHaveBeenCalledWith(mockFilePath, 'utf8'); |
| | expect(createReadStream).not.toHaveBeenCalled(); |
| | }); |
| |
|
| | it('should use provided fileSize to avoid stat call', async () => { |
| | const fileSize = Buffer.byteLength(smallContent); |
| |
|
| | (readFile as jest.Mock).mockResolvedValue(smallContent); |
| |
|
| | const result = await readFileAsString(mockFilePath, { fileSize }); |
| |
|
| | expect(result).toEqual({ |
| | content: smallContent, |
| | bytes: fileSize, |
| | }); |
| | expect(stat).not.toHaveBeenCalled(); |
| | expect(readFile).toHaveBeenCalledWith(mockFilePath, 'utf8'); |
| | }); |
| |
|
| | it('should stream large files', async () => { |
| | const fileSize = Buffer.byteLength(largeContent); |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: fileSize }); |
| |
|
| | |
| | const chunks = [ |
| | largeContent.substring(0, 5000000), |
| | largeContent.substring(5000000, 10000000), |
| | largeContent.substring(10000000), |
| | ]; |
| |
|
| | const mockStream = new Readable({ |
| | read() { |
| | if (chunks.length > 0) { |
| | this.push(chunks.shift()); |
| | } else { |
| | this.push(null); |
| | } |
| | }, |
| | }); |
| |
|
| | (createReadStream as jest.Mock).mockReturnValue(mockStream); |
| |
|
| | const result = await readFileAsString(mockFilePath); |
| |
|
| | expect(result).toEqual({ |
| | content: largeContent, |
| | bytes: fileSize, |
| | }); |
| | expect(stat).toHaveBeenCalledWith(mockFilePath); |
| | expect(createReadStream).toHaveBeenCalledWith(mockFilePath, { |
| | encoding: 'utf8', |
| | highWaterMark: 64 * 1024, |
| | }); |
| | expect(readFile).not.toHaveBeenCalled(); |
| | }); |
| |
|
| | it('should use custom encoding', async () => { |
| | const fileSize = 100; |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: fileSize }); |
| | (readFile as jest.Mock).mockResolvedValue(smallContent); |
| |
|
| | await readFileAsString(mockFilePath, { encoding: 'latin1' }); |
| |
|
| | expect(readFile).toHaveBeenCalledWith(mockFilePath, 'latin1'); |
| | }); |
| |
|
| | it('should respect custom stream threshold', async () => { |
| | const customThreshold = 1024; |
| | const fileSize = 2048; |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: fileSize }); |
| |
|
| | const mockStream = new Readable({ |
| | read() { |
| | this.push('test content'); |
| | this.push(null); |
| | }, |
| | }); |
| |
|
| | (createReadStream as jest.Mock).mockReturnValue(mockStream); |
| |
|
| | await readFileAsString(mockFilePath, { streamThreshold: customThreshold }); |
| |
|
| | expect(createReadStream).toHaveBeenCalled(); |
| | expect(readFile).not.toHaveBeenCalled(); |
| | }); |
| |
|
| | it('should handle empty files', async () => { |
| | const fileSize = 0; |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: fileSize }); |
| | (readFile as jest.Mock).mockResolvedValue(''); |
| |
|
| | const result = await readFileAsString(mockFilePath); |
| |
|
| | expect(result).toEqual({ |
| | content: '', |
| | bytes: 0, |
| | }); |
| | }); |
| |
|
| | it('should propagate read errors', async () => { |
| | const error = new Error('File not found'); |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: 100 }); |
| | (readFile as jest.Mock).mockRejectedValue(error); |
| |
|
| | await expect(readFileAsString(mockFilePath)).rejects.toThrow('File not found'); |
| | }); |
| |
|
| | it('should propagate stat errors when fileSize not provided', async () => { |
| | const error = new Error('Permission denied'); |
| |
|
| | (stat as jest.Mock).mockRejectedValue(error); |
| |
|
| | await expect(readFileAsString(mockFilePath)).rejects.toThrow('Permission denied'); |
| | }); |
| |
|
| | it('should propagate stream errors', async () => { |
| | const fileSize = 11 * 1024 * 1024; |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: fileSize }); |
| |
|
| | const mockStream = new Readable({ |
| | read() { |
| | this.emit('error', new Error('Stream error')); |
| | }, |
| | }); |
| |
|
| | (createReadStream as jest.Mock).mockReturnValue(mockStream); |
| |
|
| | await expect(readFileAsString(mockFilePath)).rejects.toThrow('Stream error'); |
| | }); |
| | }); |
| |
|
| | describe('readFileAsBuffer', () => { |
| | const smallBuffer = Buffer.from(smallContent); |
| | const largeBuffer = Buffer.from(largeContent); |
| |
|
| | it('should read small files directly without streaming', async () => { |
| | const fileSize = smallBuffer.length; |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: fileSize }); |
| | (readFile as jest.Mock).mockResolvedValue(smallBuffer); |
| |
|
| | const result = await readFileAsBuffer(mockFilePath); |
| |
|
| | expect(result).toEqual({ |
| | content: smallBuffer, |
| | bytes: fileSize, |
| | }); |
| | expect(stat).toHaveBeenCalledWith(mockFilePath); |
| | expect(readFile).toHaveBeenCalledWith(mockFilePath); |
| | expect(createReadStream).not.toHaveBeenCalled(); |
| | }); |
| |
|
| | it('should use provided fileSize to avoid stat call', async () => { |
| | const fileSize = smallBuffer.length; |
| |
|
| | (readFile as jest.Mock).mockResolvedValue(smallBuffer); |
| |
|
| | const result = await readFileAsBuffer(mockFilePath, { fileSize }); |
| |
|
| | expect(result).toEqual({ |
| | content: smallBuffer, |
| | bytes: fileSize, |
| | }); |
| | expect(stat).not.toHaveBeenCalled(); |
| | }); |
| |
|
| | it('should stream large files', async () => { |
| | const fileSize = largeBuffer.length; |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: fileSize }); |
| |
|
| | |
| | const chunk1 = largeBuffer.slice(0, 5000000); |
| | const chunk2 = largeBuffer.slice(5000000, 10000000); |
| | const chunk3 = largeBuffer.slice(10000000); |
| |
|
| | const chunks = [chunk1, chunk2, chunk3]; |
| |
|
| | const mockStream = new Readable({ |
| | read() { |
| | if (chunks.length > 0) { |
| | this.push(chunks.shift()); |
| | } else { |
| | this.push(null); |
| | } |
| | }, |
| | }); |
| |
|
| | (createReadStream as jest.Mock).mockReturnValue(mockStream); |
| |
|
| | const result = await readFileAsBuffer(mockFilePath); |
| |
|
| | expect(result.bytes).toBe(fileSize); |
| | expect(Buffer.compare(result.content, largeBuffer)).toBe(0); |
| | expect(createReadStream).toHaveBeenCalledWith(mockFilePath, { |
| | highWaterMark: 64 * 1024, |
| | }); |
| | expect(readFile).not.toHaveBeenCalled(); |
| | }); |
| |
|
| | it('should respect custom highWaterMark', async () => { |
| | const fileSize = 11 * 1024 * 1024; |
| | const customHighWaterMark = 128 * 1024; |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: fileSize }); |
| |
|
| | const mockStream = new Readable({ |
| | read() { |
| | this.push(Buffer.from('test')); |
| | this.push(null); |
| | }, |
| | }); |
| |
|
| | (createReadStream as jest.Mock).mockReturnValue(mockStream); |
| |
|
| | await readFileAsBuffer(mockFilePath, { highWaterMark: customHighWaterMark }); |
| |
|
| | expect(createReadStream).toHaveBeenCalledWith(mockFilePath, { |
| | highWaterMark: customHighWaterMark, |
| | }); |
| | }); |
| |
|
| | it('should handle empty buffer files', async () => { |
| | const emptyBuffer = Buffer.alloc(0); |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: 0 }); |
| | (readFile as jest.Mock).mockResolvedValue(emptyBuffer); |
| |
|
| | const result = await readFileAsBuffer(mockFilePath); |
| |
|
| | expect(result).toEqual({ |
| | content: emptyBuffer, |
| | bytes: 0, |
| | }); |
| | }); |
| |
|
| | it('should propagate errors', async () => { |
| | const error = new Error('Access denied'); |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: 100 }); |
| | (readFile as jest.Mock).mockRejectedValue(error); |
| |
|
| | await expect(readFileAsBuffer(mockFilePath)).rejects.toThrow('Access denied'); |
| | }); |
| | }); |
| |
|
| | describe('readJsonFile', () => { |
| | const validJson = { name: 'test', value: 123, nested: { key: 'value' } }; |
| | const jsonString = JSON.stringify(validJson); |
| |
|
| | it('should parse valid JSON files', async () => { |
| | (stat as jest.Mock).mockResolvedValue({ size: jsonString.length }); |
| | (readFile as jest.Mock).mockResolvedValue(jsonString); |
| |
|
| | const result = await readJsonFile(mockFilePath); |
| |
|
| | expect(result).toEqual(validJson); |
| | expect(readFile).toHaveBeenCalledWith(mockFilePath, 'utf8'); |
| | }); |
| |
|
| | it('should parse JSON with provided fileSize', async () => { |
| | const fileSize = jsonString.length; |
| |
|
| | (readFile as jest.Mock).mockResolvedValue(jsonString); |
| |
|
| | const result = await readJsonFile(mockFilePath, { fileSize }); |
| |
|
| | expect(result).toEqual(validJson); |
| | expect(stat).not.toHaveBeenCalled(); |
| | }); |
| |
|
| | it('should handle JSON arrays', async () => { |
| | const jsonArray = [1, 2, 3, { key: 'value' }]; |
| | const arrayString = JSON.stringify(jsonArray); |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: arrayString.length }); |
| | (readFile as jest.Mock).mockResolvedValue(arrayString); |
| |
|
| | const result = await readJsonFile(mockFilePath); |
| |
|
| | expect(result).toEqual(jsonArray); |
| | }); |
| |
|
| | it('should throw on invalid JSON', async () => { |
| | const invalidJson = '{ invalid json }'; |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: invalidJson.length }); |
| | (readFile as jest.Mock).mockResolvedValue(invalidJson); |
| |
|
| | await expect(readJsonFile(mockFilePath)).rejects.toThrow(); |
| | }); |
| |
|
| | it('should throw on empty file', async () => { |
| | (stat as jest.Mock).mockResolvedValue({ size: 0 }); |
| | (readFile as jest.Mock).mockResolvedValue(''); |
| |
|
| | await expect(readJsonFile(mockFilePath)).rejects.toThrow(); |
| | }); |
| |
|
| | it('should handle large JSON files with streaming', async () => { |
| | const largeJson = { data: 'x'.repeat(11 * 1024 * 1024) }; |
| | const largeJsonString = JSON.stringify(largeJson); |
| | const fileSize = largeJsonString.length; |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: fileSize }); |
| |
|
| | |
| | const chunks: string[] = []; |
| | let offset = 0; |
| | const chunkSize = 5 * 1024 * 1024; |
| |
|
| | while (offset < largeJsonString.length) { |
| | chunks.push(largeJsonString.slice(offset, offset + chunkSize)); |
| | offset += chunkSize; |
| | } |
| |
|
| | const mockStream = new Readable({ |
| | read() { |
| | if (chunks.length > 0) { |
| | this.push(chunks.shift()); |
| | } else { |
| | this.push(null); |
| | } |
| | }, |
| | }); |
| |
|
| | (createReadStream as jest.Mock).mockReturnValue(mockStream); |
| |
|
| | const result = await readJsonFile(mockFilePath); |
| |
|
| | expect(result).toEqual(largeJson); |
| | expect(createReadStream).toHaveBeenCalled(); |
| | expect(readFile).not.toHaveBeenCalled(); |
| | }); |
| |
|
| | it('should use custom stream threshold', async () => { |
| | const customThreshold = 100; |
| | const json = { test: 'x'.repeat(200) }; |
| | const jsonStr = JSON.stringify(json); |
| | const fileSize = jsonStr.length; |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: fileSize }); |
| |
|
| | const mockStream = new Readable({ |
| | read() { |
| | this.push(jsonStr); |
| | this.push(null); |
| | }, |
| | }); |
| |
|
| | (createReadStream as jest.Mock).mockReturnValue(mockStream); |
| |
|
| | await readJsonFile(mockFilePath, { streamThreshold: customThreshold }); |
| |
|
| | expect(createReadStream).toHaveBeenCalled(); |
| | }); |
| |
|
| | it('should preserve type with generics', async () => { |
| | interface TestType { |
| | id: number; |
| | name: string; |
| | } |
| |
|
| | const typedJson: TestType = { id: 1, name: 'test' }; |
| | const jsonString = JSON.stringify(typedJson); |
| |
|
| | (stat as jest.Mock).mockResolvedValue({ size: jsonString.length }); |
| | (readFile as jest.Mock).mockResolvedValue(jsonString); |
| |
|
| | const result = await readJsonFile<TestType>(mockFilePath); |
| |
|
| | expect(result).toEqual(typedJson); |
| | expect(result.id).toBe(1); |
| | expect(result.name).toBe('test'); |
| | }); |
| | }); |
| | }); |
| |
|