Files
sftp-proxy/src/__tests__/sync.test.ts
2026-02-20 17:14:35 +11:00

199 lines
5.1 KiB
TypeScript

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { SyncWorker, type SyncableClient } from '../sync.js'
import { MemoryCache } from '../adapters/cache/memory.js'
import type { GenericNode } from '../types.js'
/** Suppress logger output during tests. */
vi.mock('../logger.js', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}))
function makeNode(path: string, isDir = false): GenericNode {
const parts = path.split('/')
return {
path,
name: parts[parts.length - 1] || '/',
isDir,
size: isDir ? 0 : 1024,
modified: new Date('2025-01-01'),
etag: `etag-${path}`,
}
}
/**
* Creates a mock client that returns a pre-configured directory tree.
* The tree is a map from directory path to its direct children.
*/
function createMockClient(tree: Record<string, GenericNode[]>): SyncableClient {
return {
list: vi.fn(async (path: string) => {
return tree[path] ?? []
}),
}
}
describe('SyncWorker', () => {
let cache: MemoryCache
beforeEach(() => {
cache = new MemoryCache()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should sync root directory contents to cache', async () => {
const tree: Record<string, GenericNode[]> = {
'/': [
makeNode('/docs', true),
makeNode('/readme.txt'),
],
'/docs': [
makeNode('/docs/file.txt'),
],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 2)
worker.start()
await worker.waitForDrain()
worker.stop()
expect(await cache.get('/docs')).toBeTruthy()
expect(await cache.get('/readme.txt')).toBeTruthy()
expect(await cache.get('/docs/file.txt')).toBeTruthy()
})
it('should remove deleted nodes from cache', async () => {
// Pre-populate cache with a node that no longer exists on remote
await cache.set('/old-file.txt', makeNode('/old-file.txt'))
const tree: Record<string, GenericNode[]> = {
'/': [makeNode('/new-file.txt')],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 1)
worker.start()
await worker.waitForDrain()
worker.stop()
expect(await cache.get('/old-file.txt')).toBeNull()
expect(await cache.get('/new-file.txt')).toBeTruthy()
})
it('should update changed nodes based on etag', async () => {
const originalNode = makeNode('/file.txt')
originalNode.etag = 'old-etag'
await cache.set('/file.txt', originalNode)
const updatedNode = makeNode('/file.txt')
updatedNode.etag = 'new-etag'
const tree: Record<string, GenericNode[]> = {
'/': [updatedNode],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 1)
worker.start()
await worker.waitForDrain()
worker.stop()
const cached = await cache.get('/file.txt')
expect(cached?.etag).toBe('new-etag')
})
it('should handle forceSync', async () => {
const tree: Record<string, GenericNode[]> = {
'/': [makeNode('/docs', true)],
'/docs': [makeNode('/docs/new.txt')],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 2)
worker.start()
await worker.forceSync('/docs')
worker.stop()
expect(await cache.get('/docs/new.txt')).toBeTruthy()
})
it('should handle prioritise without blocking', async () => {
const tree: Record<string, GenericNode[]> = {
'/': [makeNode('/docs', true)],
'/docs': [makeNode('/docs/file.txt')],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 2)
worker.start()
worker.prioritise('/docs')
await worker.waitForDrain()
worker.stop()
expect(await cache.get('/docs/file.txt')).toBeTruthy()
})
it('should recursively sync nested directories', async () => {
const tree: Record<string, GenericNode[]> = {
'/': [makeNode('/a', true)],
'/a': [makeNode('/a/b', true)],
'/a/b': [makeNode('/a/b/c', true)],
'/a/b/c': [makeNode('/a/b/c/deep.txt')],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 1)
worker.start()
await worker.waitForDrain()
worker.stop()
expect(await cache.get('/a/b/c/deep.txt')).toBeTruthy()
})
it('should handle empty directories', async () => {
const tree: Record<string, GenericNode[]> = {
'/': [makeNode('/empty', true)],
'/empty': [],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 1)
worker.start()
await worker.waitForDrain()
worker.stop()
const children = await cache.children('/empty')
expect(children).toHaveLength(0)
})
it('should handle client.list errors gracefully', async () => {
const client: SyncableClient = {
list: vi.fn(async () => {
throw new Error('Network failure')
}),
}
const worker = new SyncWorker(client, cache, 1)
worker.start()
await worker.waitForDrain()
worker.stop()
// Should not throw, just log the error
expect(await cache.all()).toHaveLength(0)
})
})