import { describe, it, expect, beforeAll } from 'vitest' import { fileURLToPath } from 'node:url' import path from 'node:path' import { feathers } from '@feathersjs/feathers' import { MemoryService } from '@feathersjs/memory' import express, { type Application, rest, json } from '@feathersjs/express' import { AnyDocumentId, DocHandle, Repo } from '@automerge/automerge-repo' import { CHANGE_ID, generateUUID, Query, SyncServiceInfo } from '@kalisio/feathers-automerge' import _ from 'lodash' import { automergeServer, getInitialDocuments, SyncOptions, SyncServerOptions, validateSyncServerOptions } from '../src/index.js' import { AutomergeSyncService, RootDocument } from '../src/sync-service.js' type Todo = { id: number title: string completed: boolean username: string } type Message = { id: number text: string username: string } type ServicesDocument = { todos: Record } export function createApp(options: Partial) { const app = express(feathers<{ todos: MemoryService; messages: MemoryService; automerge: AutomergeSyncService }>()) app.use(json()) app.configure(rest()) app.use('todos', new MemoryService()) app.use('messages', new MemoryService()) app.configure( automergeServer({ ...options, syncServicePath: 'automerge', async initializeDocument(servicePath: string, query: Query) { if (servicePath === 'todos') { const { username } = query as { username: string } return app.service('todos').find({ paginate: false, query: username ? { username } : {} }) } if (servicePath === 'messages') { const { username } = query as { username: string } return app.service('messages').find({ paginate: false, query: username ? { username } : {} }) } return [] }, async getDocumentsForData(servicePath: string, data: unknown, documents: SyncServiceInfo[]) { if (servicePath === 'todos') { return documents.filter((doc) => { return !doc.query.username || (data as Todo).username === doc.query.username }) } // This will collect data from every service including 'messages' in it's path if (servicePath.includes('messages')) { return documents.filter((doc) => { return !doc.query.username || (data as Message).username === doc.query.username }) } return [] } } as SyncOptions) ) return app } describe('@kalisio/feathers-automerge-server', () => { // __dirname in es module const __dirname = fileURLToPath(new URL('.', import.meta.url)) const directory = path.join(__dirname, '..', '..', '..', 'data', 'automerge-test') let todo1: Todo let todo2: Todo let app: Application<{ todos: MemoryService messages: MemoryService automerge: AutomergeSyncService }> beforeAll(async () => { app = createApp({ directory, serverId: 'test-server', async authenticate() { return true }, async canAccess() { return true } }) todo1 = await app.service('todos').create({ title: 'First test todo', completed: false, username: 'testuser' }) todo2 = await app.service('todos').create({ title: 'My test todo', completed: false, username: 'otheruser' }) await app.listen(8787) }) it('initialised the automerge service', () => { expect(app.service('automerge')).toBeDefined() expect(todo1).toBeDefined() expect(todo2).toBeDefined() }) it('initialised the root document', async () => { expect(app.service('automerge').rootDocument).toBeDefined() const doc = app.service('automerge').rootDocument?.doc() expect(doc).toEqual({ documents: [] }) }) it('getInitialDocuments', async () => { const doc = await getInitialDocuments(directory, { documents: [] }) expect(doc).toBeDefined() }) it('creates a new document, initialises with correct records and stays up to date', async () => { const info = await app.service('automerge').create({ query: { username: 'testuser' } }) expect(info.url.startsWith('automerge:')).toBe(true) expect(info.query).toEqual({ username: 'testuser' }) const info2 = await app.service('automerge').create({ query: { username: 'testuser' } }) expect(info2.url).toEqual(info.url) const newDocument = await app.service('automerge').repo.find(info.url as AnyDocumentId) const newContents = newDocument.doc() as { todos: Record } expect(newContents.todos).toBeDefined() expect(Object.values(newContents.todos).length).toBe(1) const latestTodo = await app.service('todos').create({ title: 'New test todo', completed: false, username: 'testuser' }) todo1 = await app.service('todos').patch(todo1.id, { title: 'Updated test todo', completed: true }) const getTodos = () => { const updatedContents = newDocument.doc() as { todos: Record } return Object.values(updatedContents.todos).map((todo) => { expect(todo[CHANGE_ID]).toBeDefined() return _.omit(todo, CHANGE_ID) }) } expect(getTodos()).toEqual([ { id: todo1.id, title: 'Updated test todo', completed: true, username: 'testuser' }, { id: latestTodo.id, title: 'New test todo', completed: false, username: 'testuser' } ]) await app.service('todos').remove(latestTodo.id) expect(getTodos()).toEqual([ { id: todo1.id, title: 'Updated test todo', completed: true, username: 'testuser' } ]) }) it('modifying the document syncs with service', async () => { const info = await app.service('automerge').create({ query: { username: 'otheruser' } }) expect(info.query).toEqual({ username: 'otheruser' }) const newDocument = await app.service('automerge').repo.find(info.url as AnyDocumentId) const createdTodo = new Promise((resolve) => app.service('todos').once('created', (todo) => resolve(todo)) ) const patchedTodo = new Promise((resolve) => app.service('todos').once('patched', (todo) => resolve(todo)) ) const removedTodo = new Promise((resolve) => app.service('todos').once('removed', (todo) => resolve(todo)) ) newDocument.change((doc) => { doc.todos['3'] = { id: 3, title: 'Created in document', completed: false, username: 'otheruser', [CHANGE_ID]: generateUUID() } }) expect(await createdTodo).toEqual(await app.service('todos').get(3)) newDocument.change((doc) => { doc.todos['3'] = { id: 3, title: 'Updated in document', completed: true, username: 'otheruser', [CHANGE_ID]: generateUUID() } }) expect(await patchedTodo).toEqual(await app.service('todos').get(3)) newDocument.change((doc) => { delete doc.todos['3'] }) await removedTodo await expect(() => app.service('todos').get(3)).rejects.toThrow() }) it('syncs multiple documents either way, does not end up in loops', async () => { const info = await app.service('automerge').create({ query: { username: 'multiuser', multi: 1 } }) const info2 = await app.service('automerge').create({ query: { username: 'multiuser', multi: 2 } }) const document1 = await app.service('automerge').repo.find(info.url as AnyDocumentId) const document2 = await app.service('automerge').repo.find(info2.url as AnyDocumentId) const createdTodo = new Promise((resolve) => app.service('todos').once('created', (todo) => resolve(todo)) ) const newTodo = { id: 3, title: 'Created in document', completed: false, username: 'multiuser' } expect(info.url).not.toEqual(info2.url) document1.change((doc) => { doc.todos['3'] = { ...newTodo, [CHANGE_ID]: generateUUID() } }) expect(await createdTodo).toEqual(newTodo) expect(document1.doc().todos).toEqual(document2.doc().todos) await app.service('todos').patch(3, { completed: true, title: 'Update from server' }) expect(document1.doc().todos[3].completed).toBe(true) expect(document1.doc().todos[3].title).toEqual('Update from server') expect(document2.doc().todos[3].completed).toBe(true) expect(document2.doc().todos[3].title).toEqual('Update from server') }) it('can delete a document', async () => { const info = await app.service('automerge').create({ query: { username: 'deleteme' } }) const deletedDocument = await app.service('automerge').remove(info.url) expect(deletedDocument).toEqual(info) await expect(() => app.service('automerge').get(info.url)).rejects.toThrow() }) it('removes data from document when query no longer matches after patch', async () => { // Create two documents with different username queries const info1 = await app.service('automerge').create({ query: { username: 'user1' } }) const info2 = await app.service('automerge').create({ query: { username: 'user2' } }) // Create a todo for user1 const todo = await app.service('todos').create({ title: 'User1 todo', completed: false, username: 'user1' }) // Get the documents const document1 = await app.service('automerge').repo.find(info1.url as AnyDocumentId) const document2 = await app.service('automerge').repo.find(info2.url as AnyDocumentId) // Verify todo is in document1 but not in document2 expect(document1.doc().todos[todo.id]).toBeDefined() expect(document1.doc().todos[todo.id].username).toBe('user1') expect(document2.doc().todos[todo.id]).toBeUndefined() // Update the todo to change the username to user2 await app.service('todos').patch(todo.id, { username: 'user2' }) // Wait a bit for the change to propagate await new Promise((resolve) => setTimeout(resolve, 100)) // Verify todo is now in document2 but not in document1 expect(document1.doc().todos[todo.id]).toBeUndefined() expect(document2.doc().todos[todo.id]).toBeDefined() expect(document2.doc().todos[todo.id].username).toBe('user2') }) it('removes data from document when query no longer matches after update', async () => { // Create documents const info1 = await app.service('automerge').create({ query: { username: 'updateuser1' } }) const info2 = await app.service('automerge').create({ query: { username: 'updateuser2' } }) const todo = await app.service('todos').create({ title: 'Update test', completed: false, username: 'updateuser1' }) const document1 = await app.service('automerge').repo.find(info1.url as AnyDocumentId) const document2 = await app.service('automerge').repo.find(info2.url as AnyDocumentId) expect(document1.doc().todos[todo.id]).toBeDefined() expect(document2.doc().todos[todo.id]).toBeUndefined() // Full update changing username await app.service('todos').update(todo.id, { title: 'Updated', completed: true, username: 'updateuser2' }) await new Promise((resolve) => setTimeout(resolve, 100)) expect(document1.doc().todos[todo.id]).toBeUndefined() expect(document2.doc().todos[todo.id]).toBeDefined() }) it('handles data moving across multiple documents', async () => { // Create three documents const info1 = await app.service('automerge').create({ query: { username: 'multiuser1' } }) const info2 = await app.service('automerge').create({ query: { username: 'multiuser2' } }) const info3 = await app.service('automerge').create({ query: { username: 'multiuser3' } }) const todo = await app.service('todos').create({ title: 'Multi user todo', completed: false, username: 'multiuser1' }) const document1 = await app.service('automerge').repo.find(info1.url as AnyDocumentId) const document2 = await app.service('automerge').repo.find(info2.url as AnyDocumentId) const document3 = await app.service('automerge').repo.find(info3.url as AnyDocumentId) // Verify initial state expect(document1.doc().todos[todo.id]).toBeDefined() expect(document2.doc().todos[todo.id]).toBeUndefined() expect(document3.doc().todos[todo.id]).toBeUndefined() // Move to multiuser2 await app.service('todos').patch(todo.id, { username: 'multiuser2' }) await new Promise((resolve) => setTimeout(resolve, 100)) expect(document1.doc().todos[todo.id]).toBeUndefined() expect(document2.doc().todos[todo.id]).toBeDefined() expect(document3.doc().todos[todo.id]).toBeUndefined() // Move to multiuser3 await app.service('todos').patch(todo.id, { username: 'multiuser3' }) await new Promise((resolve) => setTimeout(resolve, 100)) expect(document1.doc().todos[todo.id]).toBeUndefined() expect(document2.doc().todos[todo.id]).toBeUndefined() expect(document3.doc().todos[todo.id]).toBeDefined() }) it('server to server sync', async () => { const existingTodo = await app.service('todos').create({ title: 'Todo to sync', completed: false, username: 'syncuser' }) const directory2 = path.join(__dirname, '..', '..', '..', 'data', 'automerge-test2') const app2 = createApp({ directory: directory2, serverId: 'test-server-2', syncServerUrl: 'http://localhost:8787/', getInitialDocuments: async () => { const document = await app.service('automerge').create({ query: {} }) return [document] }, async authenticate() { return true }, async canAccess() { return true } }) await app2.listen(8989) const documents2 = await app2.service('automerge').find() expect(existingTodo).to.toBeDefined() expect(documents2.length).to.equal(1) const app2TodoCreated = new Promise((resolve) => app2.service('todos').once('created', (todo) => resolve(todo)) ) const syncTodo = await app.service('todos').create({ title: 'Todo to sync', completed: false, username: 'syncuser' }) expect(syncTodo).toEqual(await app2TodoCreated) const app2Todos = await app2.service('todos').find({ paginate: false }) expect(app2Todos.length).toBeGreaterThan(1) await new Promise((resolve) => setTimeout(resolve, 250)) await app.service('automerge').repo.flush() await app.service('automerge').repo.flush() }) describe('services option', () => { it('includes all services by default', async () => { const info = await app.service('automerge').create({ query: { username: 'allservices' } }) const document = await app.service('automerge').repo.find(info.url as AnyDocumentId) const doc = document.doc() as any expect(doc.todos).toBeDefined() expect(doc.messages).toBeDefined() expect(doc.__meta.todos).toBeDefined() expect(doc.__meta.messages).toBeDefined() await app.service('automerge').remove(info.url) }) it('includes only specified services when services option is provided', async () => { const info = await app.service('automerge').create({ query: { username: 'todosonly' }, services: ['todos'] }) const document = await app.service('automerge').repo.find(info.url as AnyDocumentId) const doc = document.doc() as any expect(doc.todos).toBeDefined() expect(doc.messages).toBeUndefined() expect(doc.__meta.todos).toBeDefined() expect(doc.__meta.messages).toBeUndefined() await app.service('automerge').remove(info.url) }) it('includes multiple specified services', async () => { const info = await app.service('automerge').create({ query: { username: 'multipleservices' }, services: ['todos', 'messages'] }) const document = await app.service('automerge').repo.find(info.url as AnyDocumentId) const doc = document.doc() as any expect(doc.todos).toBeDefined() expect(doc.messages).toBeDefined() expect(doc.__meta.todos).toBeDefined() expect(doc.__meta.messages).toBeDefined() await app.service('automerge').remove(info.url) }) it('filters out invalid service names', async () => { const info = await app.service('automerge').create({ query: { username: 'invalidservice' }, services: ['todos', 'nonexistent', 'alsonotreal'] }) const document = await app.service('automerge').repo.find(info.url as AnyDocumentId) const doc = document.doc() as any expect(doc.todos).toBeDefined() expect(doc.nonexistent).toBeUndefined() expect(doc.alsonotreal).toBeUndefined() expect(doc.__meta.todos).toBeDefined() await app.service('automerge').remove(info.url) }) it('creates empty document when all specified services are invalid', async () => { const info = await app.service('automerge').create({ query: { username: 'novalid' }, services: ['nonexistent', 'alsonotreal'] }) const document = await app.service('automerge').repo.find(info.url as AnyDocumentId) const doc = document.doc() as any expect(doc.todos).toBeUndefined() expect(doc.messages).toBeUndefined() expect(doc.__meta).toEqual({}) await app.service('automerge').remove(info.url) }) }) describe('canAccess option', () => { let restrictedApp: Application<{ todos: MemoryService messages: MemoryService automerge: AutomergeSyncService }> beforeAll(async () => { restrictedApp = createApp({ directory, serverId: 'restricted-server', async authenticate() { return true }, async canAccess(query, params) { return (query as any).username === (params as any).user?.username } }) await restrictedApp.listen(9090) }) it('blocks access to create when canAccess returns false', async () => { await expect(() => restrictedApp.service('automerge').create( { query: { username: 'restricted' } }, { provider: 'rest', user: { username: 'otheruser' } } ) ).rejects.toThrow('Access not allowed for this user') }) it('allows access to create when canAccess returns true', async () => { const info = await restrictedApp.service('automerge').create( { query: { username: 'alloweduser' } }, { provider: 'rest', user: { username: 'alloweduser' } } ) expect(info.url).toBeDefined() expect(info.query).toEqual({ username: 'alloweduser' }) }) it('filters documents in find based on canAccess', async () => { // Create documents for different users await restrictedApp.service('automerge').create({ query: { username: 'user1' } }) await restrictedApp.service('automerge').create({ query: { username: 'user2' } }) // User1 should only see their document const user1Docs = await restrictedApp.service('automerge').find({ provider: 'rest', user: { username: 'user1' } }) expect(user1Docs.length).toBe(1) expect(user1Docs[0].query).toEqual({ username: 'user1' }) // User2 should only see their document const user2Docs = await restrictedApp.service('automerge').find({ provider: 'rest', user: { username: 'user2' } }) expect(user2Docs.length).toBe(1) expect(user2Docs[0].query).toEqual({ username: 'user2' }) }) it('blocks access to get when canAccess returns false', async () => { const info = await restrictedApp.service('automerge').create({ query: { username: 'privateuser' } }) await expect(() => restrictedApp.service('automerge').get(info.url, { provider: 'rest', user: { username: 'otheruser' } }) ).rejects.toThrow(`Document ${info.url} not found`) }) it('blocks access to remove when canAccess returns false', async () => { const info = await restrictedApp.service('automerge').create({ query: { username: 'protecteduser' } }) await expect(() => restrictedApp.service('automerge').remove(info.url, { provider: 'rest', user: { username: 'otheruser' } }) ).rejects.toThrow('Access not allowed for this user') }) it('bypasses canAccess check for internal calls without provider', async () => { // Internal calls (without provider) should work even if canAccess returns false const info = await restrictedApp.service('automerge').create({ query: { username: 'internaluser' } }) expect(info.url).toBeDefined() const found = await restrictedApp.service('automerge').find() expect(found.some((doc) => doc.url === info.url)).toBe(true) const removed = await restrictedApp.service('automerge').remove(info.url) expect(removed.url).toBe(info.url) }) }) describe('validateSyncServerOptions', () => { const validOptions: SyncServerOptions = { directory: '/path/to/directory', serverId: 'test-server', syncServicePath: 'automerge', authenticate: async () => true, initializeDocument: async () => [], getDocumentsForData: async () => [], canAccess: async () => true } it('should pass with valid options', () => { expect(() => validateSyncServerOptions(validOptions)).not.toThrow() expect(validateSyncServerOptions(validOptions)).toBe(true) }) it('should throw if options is null or undefined', () => { expect(() => validateSyncServerOptions(null as any)).toThrow('SyncServerOptions must be an object') expect(() => validateSyncServerOptions(undefined as any)).toThrow('SyncServerOptions must be an object') }) it('should pass with all optional properties set', () => { const fullOptions = { ...validOptions, getAccessToken: async () => 'token', syncServerUrl: 'ws://localhost:3030', syncServerWsPath: 'sync' } expect(() => validateSyncServerOptions(fullOptions)).not.toThrow() expect(validateSyncServerOptions(fullOptions)).toBe(true) }) }) describe('listen to dynamically created services feature', () => { let dynamicApp: Application<{ todos: MemoryService messages: MemoryService automerge: AutomergeSyncService }> let fooMessagesDoc: DocHandle beforeAll(async () => { dynamicApp = createApp({ directory, serverId: 'dynamic-server', async authenticate() { return true }, async canAccess(query, params) { return true } }) await dynamicApp.listen(9191) }) it('initializes an app', async () => { // Create an automerge document to gather all objects in all *messages* services // By default app has a single 'messages' service and will collect events // from services whose path includes 'messages', cf. createApp > getDocumentsForData const fooMessagesInfos = await dynamicApp.service('automerge').create({ query: { username: 'foo' } }) fooMessagesDoc = await dynamicApp.service('automerge').repo.find(fooMessagesInfos.url as AnyDocumentId) // Create a message for foo and make sure it exists in the underlying automerge doc const msg1 = await dynamicApp.service('messages').create({ text: 'How are you ?', username: 'foo' }) expect(fooMessagesDoc.doc().messages[msg1.id]).toBeDefined() expect(fooMessagesDoc.doc().messages[msg1.id].username).toBe('foo') expect(fooMessagesDoc.doc().messages[msg1.id].text).toBe('How are you ?') }) it('creates a new service to publish important messages', async () => { // Create a new service for important-messages and make the automerge service aware of this one const servicePath = 'important-messages' dynamicApp.use(servicePath, new MemoryService()) dynamicApp.service('automerge').listenService(servicePath) // Publish an important message and make sure it also exists in the automerge doc const msg2 = await dynamicApp.service('important-messages').create({ text: 'We need you !', username: 'foo' }) expect(fooMessagesDoc.doc()[servicePath][msg2.id]).toBeDefined() expect(fooMessagesDoc.doc()[servicePath][msg2.id].username).toBe('foo') expect(fooMessagesDoc.doc()[servicePath][msg2.id].text).toBe('We need you !') }) }) })