import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('fs/promises', () => ({ default: { mkdir: vi.fn(async () => undefined), access: vi.fn(async () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }), copyFile: vi.fn(async () => undefined), readdir: vi.fn(async () => []), unlink: vi.fn(async () => undefined), writeFile: vi.fn(async () => undefined), rename: vi.fn(async () => undefined), readFile: vi.fn(), }, })); import fs from 'fs/promises'; import { writeGalleryYaml } from '@/lib/admin/yaml-service'; const gallery = { albums: ['11111111-1111-1111-1111-111111111111'] }; const tmpPaths = () => vi.mocked(fs.writeFile).mock.calls.map((c) => String(c[0])); /** * The rename is what makes the write atomic; the temp filename is what did not * used to be. With a constant `gallery.yaml.tmp`, two saves in flight at once * shared it, so the second writeFile could interleave with the first's rename. */ describe('writeYamlFile temp file handling', () => { beforeEach(() => vi.clearAllMocks()); it('never writes to the bare .tmp path two callers would collide on', async () => { await writeGalleryYaml(gallery); expect(tmpPaths()[0]).not.toMatch(/gallery\.yaml\.tmp$/); }); it('uses a distinct temp path for every write', async () => { await writeGalleryYaml(gallery); await writeGalleryYaml(gallery); await writeGalleryYaml(gallery); const paths = tmpPaths(); expect(paths).toHaveLength(3); expect(new Set(paths).size).toBe(3); }); it('renames the temp file it just wrote, not some other one', async () => { await writeGalleryYaml(gallery); const [written] = tmpPaths(); const [from, to] = vi.mocked(fs.rename).mock.calls[0].map(String); expect(from).toBe(written); expect(to).toMatch(/content\/gallery\.yaml$/); }); it('cleans up the temp file when the write fails', async () => { vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('ENOSPC')); await expect(writeGalleryYaml(gallery)).rejects.toThrow('ENOSPC'); expect(String(vi.mocked(fs.unlink).mock.calls[0][0])).toBe(tmpPaths()[0]); }); it('cleans up when the rename fails', async () => { vi.mocked(fs.rename).mockRejectedValueOnce(new Error('EXDEV')); await expect(writeGalleryYaml(gallery)).rejects.toThrow('EXDEV'); expect(fs.unlink).toHaveBeenCalledOnce(); }); it('does not let a cleanup failure mask the real error', async () => { vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('ENOSPC')); vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('EACCES')); await expect(writeGalleryYaml(gallery)).rejects.toThrow('ENOSPC'); }); it('does not unlink on the happy path', async () => { await writeGalleryYaml(gallery); expect(fs.unlink).not.toHaveBeenCalled(); }); }); /** * "No file yet" and "the backup could not be written" used to share one * catch, so a `.backups/` this save couldn't write to looked exactly like a * brand-new file and the save went ahead with no snapshot (#630). */ describe('writeYamlFile backup failure', () => { beforeEach(() => vi.clearAllMocks()); it('still saves a brand-new file with no backup', async () => { await expect(writeGalleryYaml(gallery)).resolves.toBeUndefined(); expect(fs.copyFile).not.toHaveBeenCalled(); }); it('aborts the save when the file exists but the backup copy fails', async () => { vi.mocked(fs.access).mockResolvedValueOnce(undefined); vi.mocked(fs.copyFile).mockRejectedValueOnce( Object.assign(new Error('EACCES'), { code: 'EACCES' }), ); await expect(writeGalleryYaml(gallery)).rejects.toThrow('EACCES'); expect(fs.writeFile).not.toHaveBeenCalled(); }); it('aborts when checking whether the file exists fails for a reason other than ENOENT', async () => { vi.mocked(fs.access).mockRejectedValueOnce( Object.assign(new Error('EACCES'), { code: 'EACCES' }), ); await expect(writeGalleryYaml(gallery)).rejects.toThrow('EACCES'); expect(fs.writeFile).not.toHaveBeenCalled(); }); });