test(frontend): type sse.test.ts against the real fetch-event-source types

Uses the library's own FetchEventSourceInit/EventSourceMessage types
instead of any for the mocked options object, so the test would catch
a real signature drift in connectSSE's usage of the library.
This commit is contained in:
dextmorgn
2026-08-25 09:33:35 +02:00
parent 7493c37ee4
commit bc087c7dbd
+35 -23
View File
@@ -1,14 +1,16 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { FetchEventSourceInit, EventSourceMessage } from '@microsoft/fetch-event-source'
import type { SSEEnvelope } from './sse'
// Capture the options passed to fetchEventSource so we can drive its callbacks.
const calls: any[] = []
const fetchEventSourceMock = vi.fn((url: string, opts: any) => {
const calls: { url: string; opts: FetchEventSourceInit }[] = []
const fetchEventSourceMock = vi.fn((url: string, opts: FetchEventSourceInit) => {
calls.push({ url, opts })
return new Promise(() => {}) // never resolves; we drive callbacks manually
})
vi.mock('@microsoft/fetch-event-source', () => ({
fetchEventSource: (url: string, opts: any) => fetchEventSourceMock(url, opts)
fetchEventSource: (url: string, opts: FetchEventSourceInit) => fetchEventSourceMock(url, opts)
}))
vi.mock('@/stores/auth-store', () => ({
@@ -20,7 +22,15 @@ import { connectSSE } from './sse'
const lastOpts = () => calls[calls.length - 1].opts
const fakeResponse = (status: number, contentType: string) =>
({ ok: status >= 200 && status < 300, status, headers: { get: () => contentType } }) as any
({
ok: status >= 200 && status < 300,
status,
headers: { get: () => contentType }
}) as unknown as Response
// The tests only ever care about `data` — id/event are required by the
// library's type but irrelevant to connectSSE's own parsing.
const message = (data: string): EventSourceMessage => ({ id: '', event: '', data })
beforeEach(() => {
calls.length = 0
@@ -35,45 +45,47 @@ describe('connectSSE', () => {
})
it('forwards parsed envelopes and skips "connected"', () => {
const received: any[] = []
const received: SSEEnvelope[] = []
connectSSE({ url: 'http://x', onMessage: (m) => received.push(m) })
const o = lastOpts()
o.onmessage({ data: JSON.stringify({ event: 'connected', data: 'hi' }) })
o.onmessage({ data: JSON.stringify({ event: 'log', data: '{"msg":"a"}' }) })
o.onmessage?.(message(JSON.stringify({ event: 'connected', data: 'hi' })))
o.onmessage?.(message(JSON.stringify({ event: 'log', data: '{"msg":"a"}' })))
expect(received).toEqual([{ event: 'log', data: '{"msg":"a"}' }])
})
it('ignores empty and malformed messages without throwing', () => {
const received: any[] = []
const received: SSEEnvelope[] = []
connectSSE({ url: 'http://x', onMessage: (m) => received.push(m) })
const o = lastOpts()
o.onmessage({ data: '' })
o.onmessage({ data: 'not-json' })
o.onmessage?.(message(''))
o.onmessage?.(message('not-json'))
expect(received).toEqual([])
})
it('aborts the request when the disposer is called', () => {
const dispose = connectSSE({ url: 'http://x', onMessage: () => {} })
expect(lastOpts().signal.aborted).toBe(false)
expect(lastOpts().signal?.aborted).toBe(false)
dispose()
expect(lastOpts().signal.aborted).toBe(true)
expect(lastOpts().signal?.aborted).toBe(true)
})
it('throws (no retry) on a non-event-stream open', async () => {
connectSSE({ url: 'http://x', onMessage: () => {} })
await expect(lastOpts().onopen(fakeResponse(401, 'application/json'))).rejects.toThrow()
await expect(lastOpts().onopen?.(fakeResponse(401, 'application/json'))).rejects.toThrow()
})
it('accepts a valid event-stream open', async () => {
connectSSE({ url: 'http://x', onMessage: () => {} })
await expect(lastOpts().onopen(fakeResponse(200, 'text/event-stream'))).resolves.toBeUndefined()
await expect(
lastOpts().onopen?.(fakeResponse(200, 'text/event-stream'))
).resolves.toBeUndefined()
})
it('returns increasing backoff for transient errors', () => {
connectSSE({ url: 'http://x', onMessage: () => {} })
const o = lastOpts()
const first = o.onerror(new Error('network'))
const second = o.onerror(new Error('network'))
const first = o.onerror?.(new Error('network'))
const second = o.onerror?.(new Error('network'))
expect(first).toBe(2000)
expect(second).toBe(4000)
})
@@ -83,26 +95,26 @@ describe('connectSSE', () => {
const o = lastOpts()
let fatal: unknown
try {
await o.onopen(fakeResponse(403, 'application/json'))
await o.onopen?.(fakeResponse(403, 'application/json'))
} catch (e) {
fatal = e
}
expect(() => o.onerror(fatal)).toThrow()
expect(() => o.onerror?.(fatal)).toThrow()
})
it('ignores valid JSON that is not an envelope', () => {
const received: any[] = []
const received: SSEEnvelope[] = []
connectSSE({ url: 'http://x', onMessage: (m) => received.push(m) })
const o = lastOpts()
o.onmessage({ data: '42' })
o.onmessage({ data: 'null' })
o.onmessage({ data: '{"data":"no-event"}' })
o.onmessage?.(message('42'))
o.onmessage?.(message('null'))
o.onmessage?.(message('{"data":"no-event"}'))
expect(received).toEqual([])
})
it('calls onError once when the connection promise rejects (non-fatal)', async () => {
const errors: unknown[] = []
fetchEventSourceMock.mockImplementationOnce((url: string, opts: any) => {
fetchEventSourceMock.mockImplementationOnce((url: string, opts: FetchEventSourceInit) => {
calls.push({ url, opts })
return Promise.reject(new Error('boom'))
})