import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { frameToEntity, entityToSyncedFrame, TeamSync, type TeamSyncConfig } from '../src/team-sync.js'; import type { MemoryFrame } from '@waggle/hive-mind-core'; describe('frameToEntity', () => { it('converts a MemoryFrame to team entity format', () => { const frame: MemoryFrame = { id: 42, frame_type: 'I', gop_id: 'project-context', t: 3, base_frame_id: null, content: 'The project uses React and TypeScript', importance: 'important', access_count: 5, created_at: '2026-03-12T10:00:00.000Z', last_accessed: '2026-03-12T12:00:00.000Z', }; const entity = frameToEntity(frame, 'user-abc', 'Marko'); expect(entity.entityType).toBe('memory_frame'); expect(entity.name).toBe('project-context'); expect(entity.properties.frameType).toBe('I'); expect(entity.properties.t).toBe(3); expect(entity.properties.baseFrameId).toBeNull(); expect(entity.properties.content).toBe('The project uses React and TypeScript'); expect(entity.properties.importance).toBe('important'); expect(entity.properties.authorId).toBe('user-abc'); expect(entity.properties.authorName).toBe('Marko'); expect(entity.properties.localId).toBe(42); }); it('preserves P-frame base_frame_id', () => { const frame: MemoryFrame = { id: 43, frame_type: 'P', gop_id: 'project-context', t: 4, base_frame_id: 42, content: 'Updated: now also using Tailwind', importance: 'normal', access_count: 0, created_at: '2026-03-12T11:00:00.000Z', last_accessed: '2026-03-12T11:00:00.000Z', }; const entity = frameToEntity(frame, 'user-xyz', 'Ana'); expect(entity.properties.frameType).toBe('P'); expect(entity.properties.baseFrameId).toBe(42); expect(entity.properties.authorName).toBe('Ana'); }); }); describe('entityToSyncedFrame', () => { it('converts a team entity back to SyncedFrame', () => { const entity = { id: 'uuid-remote-1', name: 'project-context', properties: { frameType: 'I', t: 3, baseFrameId: null, content: 'The project uses React and TypeScript', importance: 'important', authorId: 'user-abc', authorName: 'Marko', localId: 42, }, createdAt: '2026-03-12T10:00:00.000Z', }; const frame = entityToSyncedFrame(entity); expect(frame.remoteId).toBe('uuid-remote-1'); expect(frame.gopId).toBe('project-context'); expect(frame.t).toBe(3); expect(frame.frameType).toBe('I'); expect(frame.content).toBe('The project uses React and TypeScript'); expect(frame.importance).toBe('important'); expect(frame.authorId).toBe('user-abc'); expect(frame.authorName).toBe('Marko'); expect(frame.createdAt).toBe('2026-03-12T10:00:00.000Z'); }); it('handles missing properties gracefully', () => { const entity = { id: 'uuid-remote-2', name: 'some-gop', properties: {}, createdAt: '2026-03-12T10:00:00.000Z', }; const frame = entityToSyncedFrame(entity); expect(frame.remoteId).toBe('uuid-remote-2'); expect(frame.gopId).toBe('some-gop'); expect(frame.t).toBe(0); expect(frame.frameType).toBe('I'); expect(frame.content).toBe(''); expect(frame.importance).toBe('normal'); expect(frame.authorId).toBe(''); expect(frame.authorName).toBe(''); }); }); describe('TeamSync', () => { const mockConfig: TeamSyncConfig = { teamServerUrl: 'https://team.waggle.dev', teamSlug: 'test-team', authToken: 'test-jwt-token', userId: 'user-abc', displayName: 'Marko', }; let fetchSpy: ReturnType; let globalFetchSpy: ReturnType; beforeEach(() => { fetchSpy = vi.fn(); globalFetchSpy = vi.fn().mockRejectedValue(new Error('raw global fetch used')); vi.stubGlobal('fetch', globalFetchSpy); }); afterEach(() => { vi.restoreAllMocks(); }); describe('pushFrame', () => { it('sends frame to team server entities endpoint', async () => { fetchSpy.mockResolvedValue({ ok: true, json: async () => ({ id: 'remote-uuid-1' }), }); const sync = new TeamSync(mockConfig, fetchSpy); const frame: MemoryFrame = { id: 1, frame_type: 'I', gop_id: 'test-gop', t: 0, base_frame_id: null, content: 'Test content', importance: 'normal', access_count: 0, created_at: '2026-03-12T10:00:00.000Z', last_accessed: '2026-03-12T10:00:00.000Z', }; const result = await sync.pushFrame(frame); expect(result).toEqual({ remoteId: 'remote-uuid-1' }); expect(fetchSpy).toHaveBeenCalledOnce(); expect(globalFetchSpy).not.toHaveBeenCalled(); const [url, opts] = fetchSpy.mock.calls[0]; expect(url).toBe('https://team.waggle.dev/api/teams/test-team/entities'); expect(opts.method).toBe('POST'); expect(opts.headers['Authorization']).toBe('Bearer test-jwt-token'); const body = JSON.parse(opts.body); expect(body.entityType).toBe('memory_frame'); expect(body.name).toBe('test-gop'); expect(body.properties.content).toBe('Test content'); expect(body.properties.authorId).toBe('user-abc'); expect(body.properties.authorName).toBe('Marko'); }); it('returns null on server error', async () => { fetchSpy.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error', }); const sync = new TeamSync(mockConfig, fetchSpy); const frame: MemoryFrame = { id: 1, frame_type: 'I', gop_id: 'test', t: 0, base_frame_id: null, content: 'x', importance: 'normal', access_count: 0, created_at: '2026-03-12T10:00:00.000Z', last_accessed: '2026-03-12T10:00:00.000Z', }; const result = await sync.pushFrame(frame); expect(result).toBeNull(); }); it('returns null on network error', async () => { fetchSpy.mockRejectedValue(new Error('Network unreachable')); const sync = new TeamSync(mockConfig, fetchSpy); const frame: MemoryFrame = { id: 1, frame_type: 'I', gop_id: 'test', t: 0, base_frame_id: null, content: 'x', importance: 'normal', access_count: 0, created_at: '2026-03-12T10:00:00.000Z', last_accessed: '2026-03-12T10:00:00.000Z', }; const result = await sync.pushFrame(frame); expect(result).toBeNull(); }); }); describe('pullFrames', () => { it('fetches frames from team server', async () => { fetchSpy.mockResolvedValue({ ok: true, json: async () => ([ { id: 'uuid-1', name: 'gop-a', properties: { frameType: 'I', t: 0, content: 'First', importance: 'important', authorId: 'u1', authorName: 'Marko' }, createdAt: '2026-03-12T10:00:00.000Z', }, { id: 'uuid-2', name: 'gop-b', properties: { frameType: 'P', t: 1, content: 'Second', importance: 'normal', authorId: 'u2', authorName: 'Ana' }, createdAt: '2026-03-12T11:00:00.000Z', }, ]), }); const sync = new TeamSync(mockConfig, fetchSpy); const frames = await sync.pullFrames(); expect(frames).toHaveLength(2); expect(frames[0].gopId).toBe('gop-a'); expect(frames[0].authorName).toBe('Marko'); expect(frames[1].gopId).toBe('gop-b'); expect(frames[1].authorName).toBe('Ana'); expect(globalFetchSpy).not.toHaveBeenCalled(); const [url, opts] = fetchSpy.mock.calls[0]; expect(url).toContain('/api/teams/test-team/entities?type=memory_frame'); expect(opts.headers['Authorization']).toBe('Bearer test-jwt-token'); }); it('filters by since timestamp when provided', async () => { fetchSpy.mockResolvedValue({ ok: true, json: async () => ([ { id: 'uuid-1', name: 'gop-a', properties: { frameType: 'I', t: 0, content: 'Old', authorId: 'u1', authorName: 'M' }, createdAt: '2026-03-11T10:00:00.000Z', }, { id: 'uuid-2', name: 'gop-b', properties: { frameType: 'I', t: 0, content: 'New', authorId: 'u2', authorName: 'A' }, createdAt: '2026-03-12T15:00:00.000Z', }, ]), }); const sync = new TeamSync(mockConfig, fetchSpy); const frames = await sync.pullFrames('2026-03-12T00:00:00.000Z'); // Only the frame after the since timestamp expect(frames).toHaveLength(1); expect(frames[0].content).toBe('New'); }); it('returns empty array on server error', async () => { fetchSpy.mockResolvedValue({ ok: false, status: 500, statusText: 'Error' }); const sync = new TeamSync(mockConfig, fetchSpy); const frames = await sync.pullFrames(); expect(frames).toEqual([]); }); it('returns empty array on network error', async () => { fetchSpy.mockRejectedValue(new Error('Offline')); const sync = new TeamSync(mockConfig, fetchSpy); const frames = await sync.pullFrames(); expect(frames).toEqual([]); }); }); describe('sync timestamp tracking', () => { it('starts with null timestamp', () => { const sync = new TeamSync(mockConfig, fetchSpy); expect(sync.getLastSyncTimestamp()).toBeNull(); }); it('updates timestamp after successful pull', async () => { fetchSpy.mockResolvedValue({ ok: true, json: async () => ([]) }); const sync = new TeamSync(mockConfig, fetchSpy); await sync.pullFrames(); expect(sync.getLastSyncTimestamp()).toBeTruthy(); }); it('allows manual timestamp setting', () => { const sync = new TeamSync(mockConfig, fetchSpy); sync.setLastSyncTimestamp('2026-03-12T10:00:00.000Z'); expect(sync.getLastSyncTimestamp()).toBe('2026-03-12T10:00:00.000Z'); }); }); });