| import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from 'vitest'; |
| import fs from 'fs/promises'; |
| import path from 'path'; |
| import os from 'os'; |
| import { secureFs } from '@automaker/platform'; |
| import { |
| atomicWriteJson, |
| readJsonFile, |
| updateJsonAtomically, |
| readJsonWithRecovery, |
| } from '../src/atomic-writer'; |
|
|
| |
| vi.mock('@automaker/platform', () => ({ |
| secureFs: { |
| writeFile: vi.fn(), |
| readFile: vi.fn(), |
| rename: vi.fn(), |
| unlink: vi.fn(), |
| readdir: vi.fn(), |
| copyFile: vi.fn(), |
| access: vi.fn(), |
| lstat: vi.fn(), |
| mkdir: vi.fn(), |
| }, |
| })); |
|
|
| |
| vi.mock('../src/logger.js', () => ({ |
| createLogger: () => ({ |
| info: vi.fn(), |
| warn: vi.fn(), |
| error: vi.fn(), |
| debug: vi.fn(), |
| }), |
| })); |
|
|
| describe('atomic-writer.ts', () => { |
| let tempDir: string; |
|
|
| beforeEach(async () => { |
| |
| tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'atomic-writer-test-')); |
| vi.clearAllMocks(); |
| |
| (secureFs.lstat as unknown as MockInstance).mockResolvedValue({ |
| isDirectory: () => true, |
| isSymbolicLink: () => false, |
| }); |
| }); |
|
|
| afterEach(async () => { |
| |
| try { |
| await fs.rm(tempDir, { recursive: true, force: true }); |
| } catch { |
| |
| } |
| }); |
|
|
| describe('atomicWriteJson', () => { |
| it('should write JSON data atomically', async () => { |
| const filePath = path.join(tempDir, 'test.json'); |
| const data = { key: 'value', number: 42 }; |
|
|
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await atomicWriteJson(filePath, data); |
|
|
| |
| |
| expect(secureFs.writeFile).toHaveBeenCalledTimes(1); |
| const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0]; |
| expect(writeCall[0]).toMatch(/\.tmp\.\d+\.[a-f0-9]+$/); |
| expect(writeCall[1]).toBe(JSON.stringify(data, null, 2)); |
| expect(writeCall[2]).toBe('utf-8'); |
|
|
| |
| expect(secureFs.rename).toHaveBeenCalledTimes(1); |
| const renameCall = (secureFs.rename as unknown as MockInstance).mock.calls[0]; |
| expect(renameCall[0]).toMatch(/\.tmp\.\d+\.[a-f0-9]+$/); |
| expect(renameCall[1]).toBe(path.resolve(filePath)); |
| }); |
|
|
| it('should use custom indentation', async () => { |
| const filePath = path.join(tempDir, 'test.json'); |
| const data = { key: 'value' }; |
|
|
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await atomicWriteJson(filePath, data, { indent: 4 }); |
|
|
| const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0]; |
| expect(writeCall[1]).toBe(JSON.stringify(data, null, 4)); |
| }); |
|
|
| it('should clean up temp file on write failure', async () => { |
| const filePath = path.join(tempDir, 'test.json'); |
| const data = { key: 'value' }; |
|
|
| const writeError = new Error('Write failed'); |
| (secureFs.writeFile as unknown as MockInstance).mockRejectedValue(writeError); |
| (secureFs.unlink as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await expect(atomicWriteJson(filePath, data)).rejects.toThrow('Write failed'); |
|
|
| expect(secureFs.unlink).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it('should clean up temp file on rename failure', async () => { |
| const filePath = path.join(tempDir, 'test.json'); |
| const data = { key: 'value' }; |
|
|
| const renameError = new Error('Rename failed'); |
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockRejectedValue(renameError); |
| (secureFs.unlink as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await expect(atomicWriteJson(filePath, data)).rejects.toThrow('Rename failed'); |
|
|
| expect(secureFs.unlink).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it('should ignore cleanup errors', async () => { |
| const filePath = path.join(tempDir, 'test.json'); |
| const data = { key: 'value' }; |
|
|
| const writeError = new Error('Write failed'); |
| const unlinkError = new Error('Unlink failed'); |
| (secureFs.writeFile as unknown as MockInstance).mockRejectedValue(writeError); |
| (secureFs.unlink as unknown as MockInstance).mockRejectedValue(unlinkError); |
|
|
| |
| await expect(atomicWriteJson(filePath, data)).rejects.toThrow('Write failed'); |
| }); |
|
|
| it('should resolve relative paths', async () => { |
| const relativePath = 'test.json'; |
| const data = { key: 'value' }; |
|
|
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await atomicWriteJson(relativePath, data); |
|
|
| const renameCall = (secureFs.rename as unknown as MockInstance).mock.calls[0]; |
| expect(renameCall[1]).toBe(path.resolve(relativePath)); |
| }); |
|
|
| it('should handle arrays as data', async () => { |
| const filePath = path.join(tempDir, 'array.json'); |
| const data = [1, 2, 3, { nested: 'value' }]; |
|
|
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await atomicWriteJson(filePath, data); |
|
|
| const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0]; |
| expect(writeCall[1]).toBe(JSON.stringify(data, null, 2)); |
| }); |
|
|
| it('should handle null and primitive values', async () => { |
| const filePath = path.join(tempDir, 'primitive.json'); |
|
|
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await atomicWriteJson(filePath, null); |
| expect((secureFs.writeFile as unknown as MockInstance).mock.calls[0][1]).toBe('null'); |
|
|
| await atomicWriteJson(filePath, 'string'); |
| expect((secureFs.writeFile as unknown as MockInstance).mock.calls[1][1]).toBe('"string"'); |
|
|
| await atomicWriteJson(filePath, 123); |
| expect((secureFs.writeFile as unknown as MockInstance).mock.calls[2][1]).toBe('123'); |
| }); |
|
|
| it('should always create parent directories before writing', async () => { |
| const filePath = path.join(tempDir, 'nested', 'deep', 'test.json'); |
| const data = { key: 'value' }; |
|
|
| |
| const enoentError = new Error('Not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
| (secureFs.lstat as unknown as MockInstance).mockRejectedValue(enoentError); |
| (secureFs.mkdir as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await atomicWriteJson(filePath, data); |
|
|
| |
| expect(secureFs.mkdir).toHaveBeenCalledWith( |
| path.resolve(path.join(tempDir, 'nested', 'deep')), |
| { recursive: true } |
| ); |
| expect(secureFs.writeFile).toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe('readJsonFile', () => { |
| it('should read and parse JSON file', async () => { |
| const filePath = path.join(tempDir, 'read.json'); |
| const data = { key: 'value', count: 5 }; |
|
|
| (secureFs.readFile as unknown as MockInstance).mockResolvedValue(JSON.stringify(data)); |
|
|
| const result = await readJsonFile(filePath, {}); |
|
|
| expect(result).toEqual(data); |
| expect(secureFs.readFile).toHaveBeenCalledWith(path.resolve(filePath), 'utf-8'); |
| }); |
|
|
| it('should return default value when file does not exist', async () => { |
| const filePath = path.join(tempDir, 'nonexistent.json'); |
| const defaultValue = { default: true }; |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
| (secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError); |
|
|
| const result = await readJsonFile(filePath, defaultValue); |
|
|
| expect(result).toEqual(defaultValue); |
| }); |
|
|
| it('should return default value when JSON is invalid', async () => { |
| const filePath = path.join(tempDir, 'invalid.json'); |
| const defaultValue = { default: true }; |
|
|
| (secureFs.readFile as unknown as MockInstance).mockResolvedValue('not valid json'); |
|
|
| const result = await readJsonFile(filePath, defaultValue); |
|
|
| expect(result).toEqual(defaultValue); |
| }); |
|
|
| it('should return default value for other read errors', async () => { |
| const filePath = path.join(tempDir, 'error.json'); |
| const defaultValue = { default: true }; |
|
|
| const accessError = new Error('Access denied') as NodeJS.ErrnoException; |
| accessError.code = 'EACCES'; |
| (secureFs.readFile as unknown as MockInstance).mockRejectedValue(accessError); |
|
|
| const result = await readJsonFile(filePath, defaultValue); |
|
|
| expect(result).toEqual(defaultValue); |
| }); |
|
|
| it('should handle empty object as default', async () => { |
| const filePath = path.join(tempDir, 'nonexistent.json'); |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
| (secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError); |
|
|
| const result = await readJsonFile<Record<string, unknown>>(filePath, {}); |
|
|
| expect(result).toEqual({}); |
| }); |
|
|
| it('should handle array as default', async () => { |
| const filePath = path.join(tempDir, 'nonexistent.json'); |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
| (secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError); |
|
|
| const result = await readJsonFile<string[]>(filePath, []); |
|
|
| expect(result).toEqual([]); |
| }); |
|
|
| it('should parse nested objects correctly', async () => { |
| const filePath = path.join(tempDir, 'nested.json'); |
| const data = { |
| level1: { |
| level2: { |
| value: 'deep', |
| array: [1, 2, { nested: true }], |
| }, |
| }, |
| }; |
|
|
| (secureFs.readFile as unknown as MockInstance).mockResolvedValue(JSON.stringify(data)); |
|
|
| const result = await readJsonFile(filePath, {}); |
|
|
| expect(result).toEqual(data); |
| }); |
| }); |
|
|
| describe('updateJsonAtomically', () => { |
| it('should read, update, and write file atomically', async () => { |
| const filePath = path.join(tempDir, 'update.json'); |
| const initialData = { count: 5 }; |
| const defaultValue = { count: 0 }; |
|
|
| (secureFs.readFile as unknown as MockInstance).mockResolvedValue(JSON.stringify(initialData)); |
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await updateJsonAtomically(filePath, defaultValue, (data) => ({ |
| ...data, |
| count: data.count + 1, |
| })); |
|
|
| |
| const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0]; |
| const writtenData = JSON.parse(writeCall[1]); |
| expect(writtenData.count).toBe(6); |
| }); |
|
|
| it('should use default value when file does not exist', async () => { |
| const filePath = path.join(tempDir, 'new.json'); |
| const defaultValue = { count: 0 }; |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
| (secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError); |
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await updateJsonAtomically(filePath, defaultValue, (data) => ({ |
| ...data, |
| count: data.count + 1, |
| })); |
|
|
| const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0]; |
| const writtenData = JSON.parse(writeCall[1]); |
| expect(writtenData.count).toBe(1); |
| }); |
|
|
| it('should support async updater function', async () => { |
| const filePath = path.join(tempDir, 'async.json'); |
| const initialData = { value: 'initial' }; |
|
|
| (secureFs.readFile as unknown as MockInstance).mockResolvedValue(JSON.stringify(initialData)); |
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await updateJsonAtomically(filePath, {}, async (data) => { |
| await new Promise((resolve) => setTimeout(resolve, 10)); |
| return { ...data, value: 'updated' }; |
| }); |
|
|
| const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0]; |
| const writtenData = JSON.parse(writeCall[1]); |
| expect(writtenData.value).toBe('updated'); |
| }); |
|
|
| it('should pass through options to atomicWriteJson', async () => { |
| const filePath = path.join(tempDir, 'options.json'); |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
| (secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError); |
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await updateJsonAtomically(filePath, { key: 'value' }, (d) => d, { indent: 4 }); |
|
|
| const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0]; |
| expect(writeCall[1]).toBe(JSON.stringify({ key: 'value' }, null, 4)); |
| }); |
| }); |
|
|
| describe('readJsonWithRecovery', () => { |
| it('should return main file data when available', async () => { |
| const filePath = path.join(tempDir, 'main.json'); |
| const data = { main: true }; |
|
|
| (secureFs.readFile as unknown as MockInstance).mockResolvedValue(JSON.stringify(data)); |
|
|
| const result = await readJsonWithRecovery(filePath, {}); |
|
|
| expect(result.data).toEqual(data); |
| expect(result.recovered).toBe(false); |
| expect(result.source).toBe('main'); |
| expect(result.error).toBeUndefined(); |
| }); |
|
|
| it('should recover from temp file when main file is missing', async () => { |
| const filePath = path.join(tempDir, 'data.json'); |
| const tempData = { fromTemp: true }; |
| const fileName = path.basename(filePath); |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
|
|
| (secureFs.readFile as unknown as MockInstance) |
| .mockRejectedValueOnce(enoentError) |
| .mockResolvedValueOnce(JSON.stringify(tempData)); |
|
|
| (secureFs.readdir as unknown as MockInstance).mockResolvedValue([ |
| `${fileName}.tmp.1234567890`, |
| 'other-file.json', |
| ]); |
|
|
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| const result = await readJsonWithRecovery(filePath, {}); |
|
|
| expect(result.data).toEqual(tempData); |
| expect(result.recovered).toBe(true); |
| expect(result.source).toBe('temp'); |
| expect(result.error).toBe('File does not exist'); |
| }); |
|
|
| it('should recover from backup file when main and temp are unavailable', async () => { |
| const filePath = path.join(tempDir, 'data.json'); |
| const backupData = { fromBackup: true }; |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
|
|
| (secureFs.readFile as unknown as MockInstance) |
| .mockRejectedValueOnce(enoentError) |
| .mockRejectedValueOnce(enoentError) |
| .mockResolvedValueOnce(JSON.stringify(backupData)); |
|
|
| (secureFs.readdir as unknown as MockInstance).mockResolvedValue([]); |
|
|
| (secureFs.copyFile as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| const result = await readJsonWithRecovery(filePath, {}); |
|
|
| expect(result.data).toEqual(backupData); |
| expect(result.recovered).toBe(true); |
| expect(result.source).toBe('backup'); |
| }); |
|
|
| it('should return default when all recovery attempts fail', async () => { |
| const filePath = path.join(tempDir, 'data.json'); |
| const defaultValue = { default: true }; |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
|
|
| (secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError); |
| (secureFs.readdir as unknown as MockInstance).mockResolvedValue([]); |
|
|
| const result = await readJsonWithRecovery(filePath, defaultValue); |
|
|
| expect(result.data).toEqual(defaultValue); |
| expect(result.recovered).toBe(true); |
| expect(result.source).toBe('default'); |
| expect(result.error).toBe('File does not exist'); |
| }); |
|
|
| it('should try multiple temp files in order', async () => { |
| const filePath = path.join(tempDir, 'data.json'); |
| const fileName = path.basename(filePath); |
| const validTempData = { valid: true }; |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
|
|
| (secureFs.readFile as unknown as MockInstance) |
| .mockRejectedValueOnce(enoentError) |
| .mockResolvedValueOnce('invalid json') |
| .mockResolvedValueOnce(JSON.stringify(validTempData)); |
|
|
| (secureFs.readdir as unknown as MockInstance).mockResolvedValue([ |
| `${fileName}.tmp.9999999999`, |
| `${fileName}.tmp.1111111111`, |
| ]); |
|
|
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| const result = await readJsonWithRecovery(filePath, {}); |
|
|
| expect(result.data).toEqual(validTempData); |
| expect(result.source).toBe('temp'); |
| }); |
|
|
| it('should try multiple backup files in order', async () => { |
| const filePath = path.join(tempDir, 'data.json'); |
| const backupData = { backup2: true }; |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
|
|
| (secureFs.readFile as unknown as MockInstance) |
| .mockRejectedValueOnce(enoentError) |
| .mockRejectedValueOnce(enoentError) |
| .mockResolvedValueOnce(JSON.stringify(backupData)); |
|
|
| (secureFs.readdir as unknown as MockInstance).mockResolvedValue([]); |
|
|
| (secureFs.copyFile as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| const result = await readJsonWithRecovery(filePath, {}); |
|
|
| expect(result.data).toEqual(backupData); |
| expect(result.source).toBe('backup'); |
|
|
| |
| expect(secureFs.readFile).toHaveBeenNthCalledWith( |
| 2, |
| `${path.resolve(filePath)}.bak1`, |
| 'utf-8' |
| ); |
| }); |
|
|
| it('should respect maxBackups option', async () => { |
| const filePath = path.join(tempDir, 'data.json'); |
| const defaultValue = { default: true }; |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
|
|
| (secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError); |
| (secureFs.readdir as unknown as MockInstance).mockResolvedValue([]); |
|
|
| const result = await readJsonWithRecovery(filePath, defaultValue, { maxBackups: 1 }); |
|
|
| expect(result.source).toBe('default'); |
| |
| expect(secureFs.readFile).toHaveBeenCalledTimes(2); |
| }); |
|
|
| it('should not auto-restore when autoRestore is false', async () => { |
| const filePath = path.join(tempDir, 'data.json'); |
| const fileName = path.basename(filePath); |
| const tempData = { fromTemp: true }; |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
|
|
| (secureFs.readFile as unknown as MockInstance) |
| .mockRejectedValueOnce(enoentError) |
| .mockResolvedValueOnce(JSON.stringify(tempData)); |
|
|
| (secureFs.readdir as unknown as MockInstance).mockResolvedValue([`${fileName}.tmp.123`]); |
|
|
| const result = await readJsonWithRecovery(filePath, {}, { autoRestore: false }); |
|
|
| expect(result.data).toEqual(tempData); |
| expect(secureFs.rename).not.toHaveBeenCalled(); |
| expect(secureFs.copyFile).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should handle directory read errors gracefully', async () => { |
| const filePath = path.join(tempDir, 'data.json'); |
| const backupData = { backup: true }; |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
|
|
| (secureFs.readFile as unknown as MockInstance) |
| .mockRejectedValueOnce(enoentError) |
| .mockResolvedValueOnce(JSON.stringify(backupData)); |
|
|
| (secureFs.readdir as unknown as MockInstance).mockRejectedValue(new Error('Dir read failed')); |
| (secureFs.copyFile as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| const result = await readJsonWithRecovery(filePath, {}); |
|
|
| |
| expect(result.data).toEqual(backupData); |
| expect(result.source).toBe('backup'); |
| }); |
|
|
| it('should handle corrupted main file with valid error message', async () => { |
| const filePath = path.join(tempDir, 'corrupted.json'); |
| const defaultValue = { default: true }; |
|
|
| const parseError = new SyntaxError('Unexpected token'); |
| (secureFs.readFile as unknown as MockInstance).mockResolvedValueOnce('{{invalid'); |
| (secureFs.readdir as unknown as MockInstance).mockResolvedValue([]); |
|
|
| |
| (secureFs.readFile as unknown as MockInstance).mockImplementationOnce(() => { |
| return Promise.resolve('{{invalid json'); |
| }); |
|
|
| const result = await readJsonWithRecovery(filePath, defaultValue); |
|
|
| expect(result.recovered).toBe(true); |
| expect(result.error).toContain('Failed to parse'); |
| }); |
|
|
| it('should handle restore failures gracefully', async () => { |
| const filePath = path.join(tempDir, 'data.json'); |
| const fileName = path.basename(filePath); |
| const tempData = { fromTemp: true }; |
|
|
| const enoentError = new Error('File not found') as NodeJS.ErrnoException; |
| enoentError.code = 'ENOENT'; |
|
|
| (secureFs.readFile as unknown as MockInstance) |
| .mockRejectedValueOnce(enoentError) |
| .mockResolvedValueOnce(JSON.stringify(tempData)); |
|
|
| (secureFs.readdir as unknown as MockInstance).mockResolvedValue([`${fileName}.tmp.123`]); |
| (secureFs.rename as unknown as MockInstance).mockRejectedValue(new Error('Restore failed')); |
|
|
| const result = await readJsonWithRecovery(filePath, {}); |
|
|
| |
| expect(result.data).toEqual(tempData); |
| expect(result.source).toBe('temp'); |
| }); |
| }); |
|
|
| describe('Edge cases', () => { |
| it('should handle empty file path gracefully', async () => { |
| (secureFs.readFile as unknown as MockInstance).mockRejectedValue(new Error('Invalid path')); |
|
|
| const result = await readJsonFile('', { default: true }); |
|
|
| expect(result).toEqual({ default: true }); |
| }); |
|
|
| it('should handle special characters in file path', async () => { |
| const filePath = path.join(tempDir, 'file with spaces & special!.json'); |
| const data = { special: 'chars' }; |
|
|
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await atomicWriteJson(filePath, data); |
|
|
| expect(secureFs.writeFile).toHaveBeenCalled(); |
| }); |
|
|
| it('should handle very large objects', async () => { |
| const filePath = path.join(tempDir, 'large.json'); |
| const largeArray = Array.from({ length: 10000 }, (_, i) => ({ |
| id: i, |
| data: `item-${i}`, |
| })); |
|
|
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await atomicWriteJson(filePath, largeArray); |
|
|
| const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0]; |
| expect(JSON.parse(writeCall[1])).toEqual(largeArray); |
| }); |
|
|
| it('should handle unicode content', async () => { |
| const filePath = path.join(tempDir, 'unicode.json'); |
| const data = { emoji: '🎉', japanese: 'こんにちは', chinese: '你好' }; |
|
|
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await atomicWriteJson(filePath, data); |
|
|
| const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0]; |
| expect(JSON.parse(writeCall[1])).toEqual(data); |
| }); |
|
|
| it('should handle circular reference error in JSON', async () => { |
| const filePath = path.join(tempDir, 'circular.json'); |
| const circular: Record<string, unknown> = { key: 'value' }; |
| circular.self = circular; |
|
|
| await expect(atomicWriteJson(filePath, circular)).rejects.toThrow(); |
| }); |
| }); |
|
|
| describe('Type safety', () => { |
| interface TestConfig { |
| version: number; |
| settings: { |
| enabled: boolean; |
| name: string; |
| }; |
| } |
|
|
| it('should preserve types in readJsonFile', async () => { |
| const filePath = path.join(tempDir, 'config.json'); |
| const expected: TestConfig = { |
| version: 1, |
| settings: { enabled: true, name: 'test' }, |
| }; |
|
|
| (secureFs.readFile as unknown as MockInstance).mockResolvedValue(JSON.stringify(expected)); |
|
|
| const result = await readJsonFile<TestConfig>(filePath, { |
| version: 0, |
| settings: { enabled: false, name: '' }, |
| }); |
|
|
| expect(result.version).toBe(1); |
| expect(result.settings.enabled).toBe(true); |
| expect(result.settings.name).toBe('test'); |
| }); |
|
|
| it('should preserve types in updateJsonAtomically', async () => { |
| const filePath = path.join(tempDir, 'counter.json'); |
|
|
| interface Counter { |
| count: number; |
| } |
|
|
| (secureFs.readFile as unknown as MockInstance).mockResolvedValue( |
| JSON.stringify({ count: 5 }) |
| ); |
| (secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined); |
| (secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined); |
|
|
| await updateJsonAtomically<Counter>(filePath, { count: 0 }, (data) => ({ |
| count: data.count + 1, |
| })); |
|
|
| const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0]; |
| const writtenData: Counter = JSON.parse(writeCall[1]); |
| expect(writtenData.count).toBe(6); |
| }); |
| }); |
| }); |
|
|