diff --git a/app/common/ValueCache.test.mjs b/app/common/ValueCache.test.mjs new file mode 100644 index 0000000..8841a59 --- /dev/null +++ b/app/common/ValueCache.test.mjs @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('fs/promises', () => ({ + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + }, +})); + +const fs = (await import('fs/promises')).default; + +const { default: ValueCache } = await import('./ValueCache.mjs'); + +describe('ValueCache', () => { + let cache; + + beforeEach(() => { + cache = new ValueCache('test-key'); + vi.clearAllMocks(); + }); + + describe('path', () => { + it('returns storage/cache/{key}.json', () => { + expect(cache.path).toBe('storage/cache/test-key.json'); + }); + + it('uses the key from constructor', () => { + const other = new ValueCache('other'); + expect(other.path).toBe('storage/cache/other.json'); + }); + }); + + describe('get', () => { + it('returns parsed JSON when valid and not expired', async () => { + const item = { data: 'hello', expires: Date.now() + 60000, cachedAt: new Date().toISOString() }; + fs.readFile.mockResolvedValue(JSON.stringify(item)); + const result = await cache.get(); + expect(result).toEqual(item); + }); + + it('returns null when expired', async () => { + const item = { data: 'hello', expires: Date.now() - 60000, cachedAt: new Date().toISOString() }; + fs.readFile.mockResolvedValue(JSON.stringify(item)); + expect(await cache.get()).toBeNull(); + }); + + it('returns item when no expiry is set', async () => { + const item = { data: 'hello', expires: null, cachedAt: new Date().toISOString() }; + fs.readFile.mockResolvedValue(JSON.stringify(item)); + const result = await cache.get(); + expect(result).toEqual(item); + }); + + it('returns null when file is missing', async () => { + fs.readFile.mockRejectedValue(new Error('ENOENT')); + expect(await cache.get()).toBeNull(); + }); + }); + + describe('getData', () => { + it('returns .data field from cached item', async () => { + const item = { data: { foo: 'bar' }, expires: Date.now() + 60000, cachedAt: new Date().toISOString() }; + fs.readFile.mockResolvedValue(JSON.stringify(item)); + expect(await cache.getData()).toEqual({ foo: 'bar' }); + }); + + it('returns null when no cache', async () => { + fs.readFile.mockRejectedValue(new Error('ENOENT')); + expect(await cache.getData()).toBeNull(); + }); + }); + + describe('getCachedAt', () => { + it('returns Date from cachedAt field', async () => { + const cachedAt = '2024-06-15T10:00:00.000Z'; + const item = { data: 'x', cachedAt }; + fs.readFile.mockResolvedValue(JSON.stringify(item)); + const result = await cache.getCachedAt(); + expect(result).toEqual(new Date(cachedAt)); + }); + + it('returns null when no data', async () => { + fs.readFile.mockRejectedValue(new Error('ENOENT')); + expect(await cache.getCachedAt()).toBeNull(); + }); + }); + + describe('setData', () => { + it('writes JSON with data, expires, and cachedAt', async () => { + fs.mkdir.mockResolvedValue(undefined); + fs.writeFile.mockResolvedValue(undefined); + + await cache.setData({ key: 'value' }, Date.now() + 60000); + + expect(fs.mkdir).toHaveBeenCalledWith('storage/cache', { recursive: true }); + expect(fs.writeFile).toHaveBeenCalledWith( + 'storage/cache/test-key.json', + expect.stringContaining('"key": "value"'), + ); + + const written = JSON.parse(fs.writeFile.mock.calls[0][1]); + expect(written.data).toEqual({ key: 'value' }); + expect(written.expires).toBeDefined(); + expect(written.cachedAt).toBeDefined(); + }); + + it('passes null expires when not specified', async () => { + fs.mkdir.mockResolvedValue(undefined); + fs.writeFile.mockResolvedValue(undefined); + + await cache.setData('test'); + + const written = JSON.parse(fs.writeFile.mock.calls[0][1]); + expect(written.expires).toBeNull(); + }); + }); +}); diff --git a/app/common/fs.test.mjs b/app/common/fs.test.mjs new file mode 100644 index 0000000..b01fc8e --- /dev/null +++ b/app/common/fs.test.mjs @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; +import { mkdirp, exists, olderThan } from './fs.mjs'; + +vi.mock('fs/promises', () => ({ + default: { + mkdir: vi.fn(), + access: vi.fn(), + stat: vi.fn(), + }, +})); + +const fs = (await import('fs/promises')).default; + +describe('mkdirp', () => { + it('calls fs.mkdir with recursive: true', async () => { + fs.mkdir.mockResolvedValue(undefined); + await mkdirp('/some/path'); + expect(fs.mkdir).toHaveBeenCalledWith('/some/path', { recursive: true }); + }); +}); + +describe('exists', () => { + it('returns true when fs.access succeeds', async () => { + fs.access.mockResolvedValue(undefined); + expect(await exists('/some/file')).toBe(true); + }); + + it('returns false when fs.access throws', async () => { + fs.access.mockRejectedValue(new Error('ENOENT')); + expect(await exists('/missing/file')).toBe(false); + }); +}); + +describe('olderThan', () => { + it('returns true when mtime is before cutoff', async () => { + fs.stat.mockResolvedValue({ mtime: new Date('2024-01-01') }); + expect(await olderThan('/file', new Date('2024-06-01'))).toBe(true); + }); + + it('returns false when mtime is after cutoff', async () => { + fs.stat.mockResolvedValue({ mtime: new Date('2024-06-01') }); + expect(await olderThan('/file', new Date('2024-01-01'))).toBe(false); + }); + + it('returns true when file is missing', async () => { + fs.stat.mockRejectedValue(new Error('ENOENT')); + expect(await olderThan('/missing', new Date())).toBe(true); + }); +});