Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions apps/realtime/src/handlers/file-doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,177 @@ describe('setupWorkspaceFileDocHandlers', () => {
expect(mockFetchFileDocPersist).toHaveBeenCalled()
})

describe('FLUSH', () => {
/** Joins, seeds, and lands one real user edit so the room is `edited` and worth persisting. */
async function joinAndEdit(handlers: Record<string, Handler>) {
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
await flushMicrotasks()
const edit = new Y.Doc()
edit.getText(FILE_DOC_FIELD).insert(0, 'user typed this')
handlers[FILE_DOC_EVENTS.MESSAGE](
frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) =>
syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit))
)
)
await flushMicrotasks()
}

function flushAcks(socket: { emit: ReturnType<typeof vi.fn> }) {
return socket.emit.mock.calls
.filter((call: unknown[]) => call[0] === FILE_DOC_EVENTS.FLUSH_COMPLETE)
.map((call: unknown[]) => call[1])
}

it('persists immediately and acks with the resulting version', async () => {
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
mockFetchFileDocPersist.mockResolvedValue({ status: 'persisted', version: 77 })
const { io } = createIo()
const { handlers, socket } = setup('socket-1', io)
await joinAndEdit(handlers)

await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })

expect(mockFetchFileDocPersist).toHaveBeenCalled()
expect(flushAcks(socket)).toEqual([{ fileId: 'file-1', status: 'persisted', version: 77 }])
})

it('acks unchanged — never persisted — for a doc nobody edited', async () => {
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
const { io } = createIo()
const { handlers, socket } = setup('socket-1', io)
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
await flushMicrotasks()

await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })

// Projecting an unedited seed back over the file is the clobber the `edited` gate exists to
// prevent, so a flush must not force one.
expect(mockFetchFileDocPersist).not.toHaveBeenCalled()
expect(flushAcks(socket)).toEqual([{ fileId: 'file-1', status: 'unchanged' }])
})

it('acks skipped — not persisted — when the durable file advanced out-of-band', async () => {
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
mockFetchFileDocPersist.mockResolvedValue({ status: 'conflict' })
const { io } = createIo()
const { handlers, socket } = setup('socket-1', io)
await joinAndEdit(handlers)

await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })

// The caller must be able to tell this from a real write: the durable bytes are NOT current.
expect(flushAcks(socket)).toEqual([{ fileId: 'file-1', status: 'skipped' }])
})

it('acks skipped when the persist request fails', async () => {
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
mockFetchFileDocPersist.mockRejectedValue(new Error('app unreachable'))
const { io } = createIo()
const { handlers, socket } = setup('socket-1', io)
await joinAndEdit(handlers)

await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })

expect(flushAcks(socket)).toEqual([{ fileId: 'file-1', status: 'skipped' }])
})

it('cancels the pending debounce so no redundant second write follows', async () => {
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
mockFetchFileDocPersist.mockResolvedValue({ status: 'persisted', version: 5 })
vi.useFakeTimers()
try {
const { io } = createIo()
const { handlers } = setup('socket-1', io)
await joinAndEdit(handlers)
// The edit armed the debounce; the flush must disarm it.
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })
expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1)

await vi.advanceTimersByTimeAsync(30_000)
expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})

it('does not re-project edits the durable file already has', async () => {
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
mockFetchFileDocPersist.mockResolvedValue({ status: 'persisted', version: 9 })
const { io } = createIo()
const { handlers, socket } = setup('socket-1', io)
await joinAndEdit(handlers)

await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })

// `edited` never clears, so without an edit-sequence check each repeat would mint another blob
// version. Only the first has anything to write; the rest are honest no-ops.
expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1)
expect(flushAcks(socket)).toEqual([
{ fileId: 'file-1', status: 'persisted', version: 9 },
{ fileId: 'file-1', status: 'unchanged' },
{ fileId: 'file-1', status: 'unchanged' },
])
})

it('writes again once a new edit lands after a flush', async () => {
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
mockFetchFileDocPersist.mockResolvedValue({ status: 'persisted', version: 9 })
const { io } = createIo()
const { handlers } = setup('socket-1', io)
await joinAndEdit(handlers)
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })

const more = new Y.Doc()
more.getText(FILE_DOC_FIELD).insert(0, 'and more typing')
handlers[FILE_DOC_EVENTS.MESSAGE](
frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) =>
syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(more))
)
)
await flushMicrotasks()
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })

// The dedup must bound redundant writes without ever swallowing real edits.
expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(2)
})

it('leaves the edits pending when a persist did not land', async () => {
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
mockFetchFileDocPersist.mockResolvedValue({ status: 'conflict' })
const { io } = createIo()
const { handlers } = setup('socket-1', io)
await joinAndEdit(handlers)

await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })

// A conflict wrote nothing, so the second attempt must NOT be deduped away as already-durable.
expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(2)
})

it('refuses a flush for a file this socket never joined', async () => {
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
const { io } = createIo()
const { handlers, socket } = setup('socket-1', io)
await joinAndEdit(handlers)

await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'someone-elses-file' })

// Membership IS the authorization here — a socket must not force a write to another document.
expect(mockFetchFileDocPersist).not.toHaveBeenCalled()
expect(flushAcks(socket)).toEqual([{ fileId: 'someone-elses-file', status: 'unchanged' }])
})

it('ignores a payload with no fileId', async () => {
const { io } = createIo()
const { handlers, socket } = setup('socket-1', io)
await handlers[FILE_DOC_EVENTS.FLUSH]({})
expect(flushAcks(socket)).toEqual([])
})
})

it('drops document frames and evicts once the editor loses write access mid-session', async () => {
// The join-time check is not a standing right: a collaborator downgraded to `read`
// (or removed) must stop landing durable edits on the socket they already hold.
Expand Down
Loading
Loading