canvas-website/tests/unit/offline/document-mapping.test.ts

390 lines
12 KiB
TypeScript

/**
* Unit tests for IndexedDB document mapping
*
* Tests the persistence layer that maps room IDs to Automerge document IDs
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { resetIndexedDB, createTestMappingsDB, seedTestMapping } from '../../mocks/indexeddb'
const DB_NAME = 'canvas-document-mappings'
const STORE_NAME = 'mappings'
describe('IndexedDB Document Mapping', () => {
beforeEach(() => {
// Reset IndexedDB for clean state
resetIndexedDB()
})
describe('Database Setup', () => {
it('can create mappings database', async () => {
const db = await createTestMappingsDB()
expect(db).toBeDefined()
expect(db.name).toBe(DB_NAME)
db.close()
})
it('database has correct object store', async () => {
const db = await createTestMappingsDB()
expect(db.objectStoreNames.contains(STORE_NAME)).toBe(true)
db.close()
})
it('object store has correct key path', async () => {
const db = await createTestMappingsDB()
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
expect(store.keyPath).toBe('roomId')
db.close()
})
})
describe('Save Document ID', () => {
it('can save a room to document mapping', async () => {
const db = await createTestMappingsDB()
await seedTestMapping(db, 'room-123', 'automerge:abc123')
// Verify it was saved
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.get('room-123')
await new Promise<void>((resolve, reject) => {
request.onsuccess = () => {
expect(request.result).toBeDefined()
expect(request.result.documentId).toBe('automerge:abc123')
resolve()
}
request.onerror = () => reject(request.error)
})
db.close()
})
it('saves timestamp on creation', async () => {
const db = await createTestMappingsDB()
const beforeTime = Date.now()
await seedTestMapping(db, 'room-456', 'automerge:def456')
const afterTime = Date.now()
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.get('room-456')
await new Promise<void>((resolve, reject) => {
request.onsuccess = () => {
expect(request.result.createdAt).toBeGreaterThanOrEqual(beforeTime)
expect(request.result.createdAt).toBeLessThanOrEqual(afterTime)
resolve()
}
request.onerror = () => reject(request.error)
})
db.close()
})
it('can update existing mapping', async () => {
const db = await createTestMappingsDB()
// Save initial mapping
await seedTestMapping(db, 'room-789', 'automerge:old-id')
// Update with new document ID
await seedTestMapping(db, 'room-789', 'automerge:new-id')
// Verify update
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.get('room-789')
await new Promise<void>((resolve, reject) => {
request.onsuccess = () => {
expect(request.result.documentId).toBe('automerge:new-id')
resolve()
}
request.onerror = () => reject(request.error)
})
db.close()
})
})
describe('Get Document ID', () => {
it('retrieves existing mapping', async () => {
const db = await createTestMappingsDB()
await seedTestMapping(db, 'room-abc', 'automerge:xyz')
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.get('room-abc')
await new Promise<void>((resolve, reject) => {
request.onsuccess = () => {
expect(request.result).toBeDefined()
expect(request.result.documentId).toBe('automerge:xyz')
resolve()
}
request.onerror = () => reject(request.error)
})
db.close()
})
it('returns undefined for non-existent room', async () => {
const db = await createTestMappingsDB()
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.get('non-existent-room')
await new Promise<void>((resolve, reject) => {
request.onsuccess = () => {
expect(request.result).toBeUndefined()
resolve()
}
request.onerror = () => reject(request.error)
})
db.close()
})
})
describe('Update Last Accessed', () => {
it('updates lastAccessedAt timestamp', async () => {
const db = await createTestMappingsDB()
await seedTestMapping(db, 'room-update-test', 'automerge:test')
// Get initial lastAccessedAt
const tx1 = db.transaction(STORE_NAME, 'readonly')
const store1 = tx1.objectStore(STORE_NAME)
const initialRequest = store1.get('room-update-test')
const initialTime = await new Promise<number>((resolve, reject) => {
initialRequest.onsuccess = () => resolve(initialRequest.result.lastAccessedAt)
initialRequest.onerror = () => reject(initialRequest.error)
})
// Wait a bit and update
await new Promise(resolve => setTimeout(resolve, 10))
const tx2 = db.transaction(STORE_NAME, 'readwrite')
const store2 = tx2.objectStore(STORE_NAME)
const getRequest = store2.get('room-update-test')
await new Promise<void>((resolve, reject) => {
getRequest.onsuccess = () => {
const record = getRequest.result
record.lastAccessedAt = Date.now()
store2.put(record)
resolve()
}
getRequest.onerror = () => reject(getRequest.error)
})
// Verify update
const tx3 = db.transaction(STORE_NAME, 'readonly')
const store3 = tx3.objectStore(STORE_NAME)
const finalRequest = store3.get('room-update-test')
await new Promise<void>((resolve, reject) => {
finalRequest.onsuccess = () => {
expect(finalRequest.result.lastAccessedAt).toBeGreaterThan(initialTime)
resolve()
}
finalRequest.onerror = () => reject(finalRequest.error)
})
db.close()
})
})
describe('Cleanup Old Mappings', () => {
it('can delete old mappings', async () => {
const db = await createTestMappingsDB()
// Create an old mapping (manually set old timestamp)
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const oldTimestamp = Date.now() - (31 * 24 * 60 * 60 * 1000) // 31 days ago
await new Promise<void>((resolve, reject) => {
const request = store.put({
roomId: 'old-room',
documentId: 'automerge:old',
createdAt: oldTimestamp,
lastAccessedAt: oldTimestamp,
})
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
// Delete the old mapping
const tx2 = db.transaction(STORE_NAME, 'readwrite')
const store2 = tx2.objectStore(STORE_NAME)
const deleteRequest = store2.delete('old-room')
await new Promise<void>((resolve, reject) => {
deleteRequest.onsuccess = () => resolve()
deleteRequest.onerror = () => reject(deleteRequest.error)
})
// Verify deletion
const tx3 = db.transaction(STORE_NAME, 'readonly')
const store3 = tx3.objectStore(STORE_NAME)
const getRequest = store3.get('old-room')
await new Promise<void>((resolve, reject) => {
getRequest.onsuccess = () => {
expect(getRequest.result).toBeUndefined()
resolve()
}
getRequest.onerror = () => reject(getRequest.error)
})
db.close()
})
it('preserves recent mappings during cleanup', async () => {
const db = await createTestMappingsDB()
// Create a recent mapping
await seedTestMapping(db, 'recent-room', 'automerge:recent')
// Create an old mapping
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const oldTimestamp = Date.now() - (31 * 24 * 60 * 60 * 1000)
await new Promise<void>((resolve, reject) => {
const request = store.put({
roomId: 'old-room',
documentId: 'automerge:old',
createdAt: oldTimestamp,
lastAccessedAt: oldTimestamp,
})
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
// Simulate cleanup: delete only old entries
const maxAge = 30 * 24 * 60 * 60 * 1000 // 30 days
const cutoffTime = Date.now() - maxAge
const tx2 = db.transaction(STORE_NAME, 'readwrite')
const store2 = tx2.objectStore(STORE_NAME)
const cursorRequest = store2.openCursor()
await new Promise<void>((resolve, reject) => {
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result
if (cursor) {
if (cursor.value.lastAccessedAt < cutoffTime) {
cursor.delete()
}
cursor.continue()
} else {
resolve()
}
}
cursorRequest.onerror = () => reject(cursorRequest.error)
})
// Verify: recent still exists, old is deleted
const tx3 = db.transaction(STORE_NAME, 'readonly')
const store3 = tx3.objectStore(STORE_NAME)
const recentRequest = store3.get('recent-room')
const oldRequest = store3.get('old-room')
await new Promise<void>((resolve, reject) => {
let checkedRecent = false
let checkedOld = false
recentRequest.onsuccess = () => {
expect(recentRequest.result).toBeDefined()
checkedRecent = true
if (checkedOld) resolve()
}
oldRequest.onsuccess = () => {
expect(oldRequest.result).toBeUndefined()
checkedOld = true
if (checkedRecent) resolve()
}
recentRequest.onerror = () => reject(recentRequest.error)
oldRequest.onerror = () => reject(oldRequest.error)
})
db.close()
})
})
describe('Multiple Rooms', () => {
it('can store multiple room mappings', async () => {
const db = await createTestMappingsDB()
await seedTestMapping(db, 'room-1', 'automerge:doc1')
await seedTestMapping(db, 'room-2', 'automerge:doc2')
await seedTestMapping(db, 'room-3', 'automerge:doc3')
// Count all entries
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const countRequest = store.count()
await new Promise<void>((resolve, reject) => {
countRequest.onsuccess = () => {
expect(countRequest.result).toBe(3)
resolve()
}
countRequest.onerror = () => reject(countRequest.error)
})
db.close()
})
it('each room maps to correct document ID', async () => {
const db = await createTestMappingsDB()
const mappings = [
{ room: 'room-a', doc: 'automerge:aaa' },
{ room: 'room-b', doc: 'automerge:bbb' },
{ room: 'room-c', doc: 'automerge:ccc' },
]
for (const m of mappings) {
await seedTestMapping(db, m.room, m.doc)
}
// Verify each mapping
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
for (const m of mappings) {
const request = store.get(m.room)
await new Promise<void>((resolve, reject) => {
request.onsuccess = () => {
expect(request.result.documentId).toBe(m.doc)
resolve()
}
request.onerror = () => reject(request.error)
})
}
db.close()
})
})
})