199 lines
5.1 KiB
TypeScript
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)
|
|
})
|
|
})
|