file-upload.client.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. import { Context } from '@deepseek-ai/cordis'
  2. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { apply } from '../src/client/index.ts'
  5. import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts'
  6. import type { FileUploadBody } from '../src/client/contract.ts'
  7. import type { ClientFileUploadHooks } from '../src/types.ts'
  8. interface UploadGlobal {
  9. __DSH_FILE_UPLOAD__?: ClientFileUploadHooks
  10. }
  11. afterEach(() => {
  12. delete (globalThis as UploadGlobal).__DSH_FILE_UPLOAD__
  13. vi.restoreAllMocks()
  14. vi.unstubAllGlobals()
  15. })
  16. describe('file upload worker body', () => {
  17. it('sends a Blob with credentials and reports progress, completion, and failure', () => {
  18. const posted: unknown[] = []
  19. const scope: {
  20. onmessage: ((event: MessageEvent<{
  21. url: string
  22. body: FileUploadBody
  23. headers: Readonly<Record<string, string>>
  24. }>) => void) | null
  25. postMessage(message: unknown): void
  26. } = { onmessage: null, postMessage: (message: unknown) => { posted.push(message) } }
  27. const xhr = {
  28. upload: { onprogress: null as ((event: ProgressEvent) => void) | null },
  29. status: 201,
  30. responseText: '{"ok":true}',
  31. withCredentials: false,
  32. onload: null as ((event: ProgressEvent) => void) | null,
  33. onerror: null as ((event: ProgressEvent) => void) | null,
  34. open: vi.fn(),
  35. setRequestHeader: vi.fn(),
  36. send: vi.fn(),
  37. }
  38. fileUploadWorker(scope, () => xhr)
  39. const body = new Blob(['large'])
  40. scope.onmessage?.({
  41. data: { url: 'https://harness.test/upload', body, headers: { 'content-type': 'application/octet-stream' } },
  42. } as never)
  43. expect(xhr.open).toHaveBeenCalledWith('POST', 'https://harness.test/upload')
  44. expect(xhr.withCredentials).toBe(true)
  45. expect(xhr.setRequestHeader).toHaveBeenCalledWith('content-type', 'application/octet-stream')
  46. expect(xhr.send).toHaveBeenCalledWith(body)
  47. xhr.upload.onprogress?.({ loaded: 2, total: 4, lengthComputable: true } as ProgressEvent)
  48. xhr.upload.onprogress?.({ loaded: 3, total: 0, lengthComputable: false } as ProgressEvent)
  49. xhr.onload?.({} as ProgressEvent)
  50. xhr.onerror?.({} as ProgressEvent)
  51. expect(posted).toEqual([
  52. { kind: 'progress', loaded: 2, total: 4 },
  53. { kind: 'progress', loaded: 3 },
  54. { kind: 'complete', status: 201, body: '{"ok":true}' },
  55. { kind: 'error', message: 'background upload transport failed' },
  56. ])
  57. })
  58. it('streams Uint8Array chunks through fetch and reports consumed bytes', async () => {
  59. const posted: unknown[] = []
  60. const scope = {
  61. onmessage: null as ((event: MessageEvent) => void) | null,
  62. postMessage: (message: unknown) => { posted.push(message) },
  63. }
  64. const fetch = vi.fn(async (_url: string, init: RequestInit & { readonly duplex: 'half' }) => {
  65. const chunks: number[][] = []
  66. for await (const chunk of init.body as ReadableStream<Uint8Array>) chunks.push([...chunk])
  67. expect(chunks).toEqual([[1, 2], [3]])
  68. expect(init).toMatchObject({
  69. method: 'POST',
  70. headers: { 'x-test': 'yes' },
  71. credentials: 'include',
  72. duplex: 'half',
  73. })
  74. return new Response('stored', { status: 202 })
  75. })
  76. const body = new ReadableStream<Uint8Array>({
  77. start(controller) {
  78. controller.enqueue(Uint8Array.of(1, 2))
  79. controller.enqueue(Uint8Array.of(3))
  80. controller.close()
  81. },
  82. })
  83. fileUploadWorker(scope, () => { throw new Error('XHR must not handle streams') }, fetch)
  84. scope.onmessage?.({ data: { url: 'https://harness.test/upload', body, headers: { 'x-test': 'yes' } } } as never)
  85. await vi.waitFor(() => {
  86. expect(posted).toEqual([
  87. { kind: 'progress', loaded: 2 },
  88. { kind: 'progress', loaded: 3 },
  89. { kind: 'complete', status: 202, body: 'stored' },
  90. ])
  91. })
  92. })
  93. it('propagates cancellation from the fetch body to the source stream', async () => {
  94. const posted: unknown[] = []
  95. const scope = {
  96. onmessage: null as ((event: MessageEvent) => void) | null,
  97. postMessage: (message: unknown) => { posted.push(message) },
  98. }
  99. const cancel = vi.fn()
  100. const source = new ReadableStream<Uint8Array>({ cancel })
  101. fileUploadWorker(
  102. scope,
  103. () => { throw new Error('unused') },
  104. async (_url, init) => {
  105. await (init.body as ReadableStream<Uint8Array>).cancel('fetch stopped')
  106. return new Response('cancelled')
  107. },
  108. )
  109. scope.onmessage?.({ data: { url: '/upload', body: source, headers: {} } } as never)
  110. await vi.waitFor(() => {
  111. expect(cancel).toHaveBeenCalledWith('fetch stopped')
  112. expect(posted.at(-1)).toEqual({ kind: 'complete', status: 200, body: 'cancelled' })
  113. })
  114. })
  115. it('reports invalid bodies, stream chunks, and fetch failures', async () => {
  116. const posted: unknown[] = []
  117. const scope = {
  118. onmessage: null as ((event: MessageEvent) => void) | null,
  119. postMessage: (message: unknown) => { posted.push(message) },
  120. }
  121. fileUploadWorker(scope, () => { throw new Error('unused') })
  122. scope.onmessage?.({ data: { url: '/upload', body: 'bad', headers: {} } } as never)
  123. expect(posted).toEqual([{ kind: 'error', message: 'background upload worker received an invalid body' }])
  124. const badChunk = new ReadableStream({ start(controller) { controller.enqueue('bad'); controller.close() } })
  125. fileUploadWorker(
  126. scope,
  127. () => { throw new Error('unused') },
  128. async (_url, init) => {
  129. await new Response(init.body).arrayBuffer()
  130. return new Response()
  131. },
  132. )
  133. scope.onmessage?.({ data: { url: '/upload', body: badChunk, headers: {} } } as never)
  134. await vi.waitFor(() => {
  135. expect(posted.at(-1)).toEqual({
  136. kind: 'error', message: 'background upload stream produced a non-Uint8Array chunk',
  137. })
  138. })
  139. const body = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
  140. fileUploadWorker(
  141. scope,
  142. () => { throw new Error('unused') },
  143. () => Promise.reject(new Error('offline')),
  144. )
  145. scope.onmessage?.({ data: { url: '/upload', body, headers: {} } } as never)
  146. await vi.waitFor(() => {
  147. expect(posted.at(-1)).toEqual({ kind: 'error', message: 'offline' })
  148. })
  149. const failedSource = new ReadableStream<Uint8Array>({
  150. start(controller) { controller.error('source failed') },
  151. })
  152. fileUploadWorker(
  153. scope,
  154. () => { throw new Error('unused') },
  155. async (_url, init) => {
  156. await (init.body as ReadableStream<Uint8Array>).getReader().read()
  157. return new Response()
  158. },
  159. )
  160. scope.onmessage?.({ data: { url: '/upload', body: failedSource, headers: {} } } as never)
  161. await vi.waitFor(() => {
  162. expect(posted.at(-1)).toEqual({ kind: 'error', message: 'source failed' })
  163. })
  164. })
  165. it('uses Worker globals when the emitted body supplies no test seams', async () => {
  166. const posted: unknown[] = []
  167. const scope = {
  168. onmessage: null as ((event: MessageEvent) => void) | null,
  169. postMessage: (message: unknown) => { posted.push(message) },
  170. }
  171. const xhr = {
  172. upload: { onprogress: null },
  173. status: 204,
  174. responseText: '',
  175. withCredentials: false,
  176. onload: null,
  177. onerror: null,
  178. open: vi.fn(),
  179. setRequestHeader: vi.fn(),
  180. send: vi.fn(),
  181. }
  182. vi.stubGlobal('self', scope)
  183. vi.stubGlobal('XMLHttpRequest', vi.fn(function () { return xhr }))
  184. fileUploadWorker()
  185. scope.onmessage?.({ data: { url: '/upload', body: new Blob(), headers: {} } } as MessageEvent)
  186. expect(xhr.send).toHaveBeenCalledOnce()
  187. const fetch = vi.fn(async (_url: string, init: RequestInit) => {
  188. await new Response(init.body).arrayBuffer()
  189. return new Response(null, { status: 204 })
  190. })
  191. vi.stubGlobal('fetch', fetch)
  192. fileUploadWorker()
  193. const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
  194. scope.onmessage?.({ data: { url: '/stream', body: stream, headers: {} } } as MessageEvent)
  195. await vi.waitFor(() => {
  196. expect(fetch).toHaveBeenCalledOnce()
  197. expect(posted.at(-1)).toEqual({ kind: 'complete', status: 204, body: '' })
  198. })
  199. })
  200. })
  201. describe('file upload service', () => {
  202. it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => {
  203. vi.stubGlobal('location', { origin: 'https://preview.test' })
  204. const fetch = vi.fn((_url: URL, _init?: RequestInit) =>
  205. Promise.resolve(new Response('accepted', { status: 202 })))
  206. ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
  207. const ctx = new Context()
  208. const fiber = ctx.plugin(FileUploadRuntime)
  209. await fiber
  210. const blob = new Blob(['opaque'])
  211. const signal = new AbortController().signal
  212. await expect((ctx.fileUpload as FileUploadRuntime).post({
  213. path: '/api/upload', body: blob, headers: { 'x-test': 'yes' }, signal,
  214. })).resolves.toEqual({ status: 202, body: 'accepted' })
  215. expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/api/upload'), {
  216. method: 'POST', headers: { 'x-test': 'yes' }, body: blob, signal,
  217. })
  218. const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
  219. await (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream })
  220. expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/stream'), {
  221. method: 'POST', body: stream, duplex: 'half',
  222. })
  223. await fiber.dispose()
  224. })
  225. it('mounts through the plugin entry and resolves non-browser URLs', async () => {
  226. vi.stubGlobal('location', { origin: 'null' })
  227. const fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })))
  228. ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
  229. const ctx = new Context()
  230. const fiber = ctx.plugin({ apply })
  231. await fiber
  232. const body = new Blob()
  233. await (ctx.fileUpload as FileUploadRuntime).post({ path: '/fallback', body })
  234. expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), {
  235. method: 'POST', body,
  236. })
  237. await fiber.dispose()
  238. })
  239. it('leaves the fixture on its generated Remote fallback', async () => {
  240. vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' })
  241. const ctx = new Context()
  242. const fiber = ctx.plugin(FileUploadRuntime)
  243. await fiber
  244. expect(ctx.fileUpload.available).toBe(false)
  245. await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }))
  246. .rejects.toThrow('background upload is unavailable in fixture mode')
  247. await fiber.dispose()
  248. })
  249. it('fails loud when a served browser has no Worker implementation', async () => {
  250. vi.stubGlobal('Worker', undefined)
  251. const ctx = new Context()
  252. const fiber = ctx.plugin(FileUploadRuntime)
  253. await fiber
  254. await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }))
  255. .rejects.toThrow('background upload requires Web Worker support')
  256. await fiber.dispose()
  257. })
  258. it('forwards progress and completion from a dedicated Worker and then terminates it', async () => {
  259. const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
  260. const revoked = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
  261. class FakeWorker {
  262. static last: FakeWorker | undefined
  263. onmessage: ((event: MessageEvent) => void) | null = null
  264. onerror: ((event: ErrorEvent) => void) | null = null
  265. readonly postMessage = vi.fn()
  266. readonly terminate = vi.fn()
  267. constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this }
  268. }
  269. vi.stubGlobal('Worker', FakeWorker)
  270. vi.stubGlobal('location', { origin: 'https://harness.test' })
  271. const ctx = new Context()
  272. const fiber = ctx.plugin(FileUploadRuntime)
  273. await fiber
  274. const progress = vi.fn()
  275. const blob = new Blob(['bytes'])
  276. const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/api/upload', body: blob, onProgress: progress })
  277. const worker = FakeWorker.last
  278. if (worker === undefined) throw new Error('worker missing')
  279. expect(created).toHaveBeenCalledOnce()
  280. expect(revoked).toHaveBeenCalledWith('blob:worker')
  281. expect(worker.postMessage).toHaveBeenCalledWith({
  282. url: 'https://harness.test/api/upload', body: blob, headers: {},
  283. })
  284. worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent)
  285. worker.onmessage?.({ data: { kind: 'progress', loaded: 6 } } as MessageEvent)
  286. worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent)
  287. worker.onmessage?.({ data: { kind: 'complete', status: 500, body: 'late' } } as MessageEvent)
  288. await expect(pending).resolves.toEqual({ status: 200, body: 'done' })
  289. expect(progress.mock.calls).toEqual([
  290. [{ loaded: 4, total: 5 }],
  291. [{ loaded: 6 }],
  292. ])
  293. expect(worker.terminate).toHaveBeenCalledOnce()
  294. await fiber.dispose()
  295. })
  296. it('transfers stream ownership to the dedicated Worker', async () => {
  297. vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
  298. vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
  299. class FakeWorker {
  300. static last: FakeWorker | undefined
  301. onmessage: ((event: MessageEvent) => void) | null = null
  302. onerror: ((event: ErrorEvent) => void) | null = null
  303. readonly postMessage = vi.fn()
  304. readonly terminate = vi.fn()
  305. constructor() { FakeWorker.last = this }
  306. }
  307. vi.stubGlobal('Worker', FakeWorker)
  308. const ctx = new Context()
  309. const fiber = ctx.plugin(FileUploadRuntime)
  310. await fiber
  311. const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
  312. const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream })
  313. const worker = FakeWorker.last
  314. if (worker === undefined) throw new Error('worker missing')
  315. expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: stream }), [stream])
  316. worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent)
  317. await expect(pending).resolves.toEqual({ status: 200, body: 'done' })
  318. await fiber.dispose()
  319. })
  320. it('rejects worker messages, worker errors, and caller cancellation', async () => {
  321. vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
  322. vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
  323. class FakeWorker {
  324. static all: FakeWorker[] = []
  325. onmessage: ((event: MessageEvent) => void) | null = null
  326. onerror: ((event: ErrorEvent) => void) | null = null
  327. readonly postMessage = vi.fn()
  328. readonly terminate = vi.fn()
  329. constructor() { FakeWorker.all.push(this) }
  330. }
  331. vi.stubGlobal('Worker', FakeWorker)
  332. const ctx = new Context()
  333. const fiber = ctx.plugin(FileUploadRuntime)
  334. await fiber
  335. const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
  336. FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent)
  337. await expect(reported).rejects.toThrow('network failed')
  338. const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
  339. FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent)
  340. await expect(errored).rejects.toThrow('worker crashed')
  341. const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
  342. FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent)
  343. await expect(unnamed).rejects.toThrow('background upload worker failed')
  344. const controller = new AbortController()
  345. const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: controller.signal })
  346. controller.abort()
  347. await expect(aborted).rejects.toMatchObject({ name: 'AbortError' })
  348. expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce()
  349. const already = new AbortController()
  350. already.abort()
  351. await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: already.signal }))
  352. .rejects.toMatchObject({ name: 'AbortError' })
  353. expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled()
  354. await fiber.dispose()
  355. })
  356. })
  357. describe('Session-addressed file upload', () => {
  358. const SESSION_ID = 's1' as SessionId
  359. async function scopedService(options: {
  360. readonly remote?: ReturnType<typeof vi.fn>
  361. } = {}) {
  362. const ctx = new Context()
  363. const remote = options.remote ?? vi.fn(() => Promise.resolve({
  364. ok: true,
  365. value: {
  366. receiptId: 'remote-receipt',
  367. file: { attachmentId: 'remote-file', name: 'file', bytes: 3 },
  368. },
  369. }))
  370. ctx.provide('remote', { fileUploads: { upload: remote } } as never)
  371. const fiber = ctx.plugin(FileUploadRuntime)
  372. await fiber
  373. return { ctx, fiber, remote, service: ctx.fileUpload }
  374. }
  375. it('assembles the scoped streaming request and parses progress and receipt fields', async () => {
  376. vi.stubGlobal('location', { origin: 'https://preview.test' })
  377. const progress = vi.fn()
  378. const fetch = vi.fn((_url: URL, init: RequestInit) => {
  379. expect(init.body).toBeInstanceOf(Blob)
  380. progress({ loaded: 2, total: 4 })
  381. return Promise.resolve(new Response(JSON.stringify({
  382. ok: true,
  383. value: {
  384. receiptId: 'receipt-1',
  385. file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
  386. },
  387. }), { status: 200 }))
  388. })
  389. ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
  390. const { fiber, service } = await scopedService()
  391. const signal = new AbortController().signal
  392. const file = new Blob(['data'])
  393. await expect(service.upload(SESSION_ID, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({
  394. ok: true,
  395. value: {
  396. receiptId: 'receipt-1',
  397. file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
  398. },
  399. })
  400. expect(fetch).toHaveBeenCalledWith(
  401. new URL('https://preview.test/api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf'),
  402. expect.objectContaining({
  403. method: 'POST',
  404. headers: { 'content-type': 'application/octet-stream' },
  405. body: file,
  406. signal,
  407. }),
  408. )
  409. await fiber.dispose()
  410. })
  411. it('uses the direct Remote fallback for exact bytes and fixture Blob bodies', async () => {
  412. vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' })
  413. const remote = vi.fn(() => Promise.resolve({
  414. ok: true,
  415. value: {
  416. receiptId: 'remote-receipt',
  417. file: { attachmentId: 'remote-file', name: 'bytes.bin', bytes: 3 },
  418. },
  419. }))
  420. const { fiber, service } = await scopedService({ remote })
  421. await expect(service.upload(SESSION_ID, Uint8Array.of(0, 0, 0), 'bytes.bin'))
  422. .resolves.toMatchObject({ ok: true })
  423. await expect(service.upload(SESSION_ID, new Blob([Uint8Array.of(1)])))
  424. .resolves.toMatchObject({ ok: true })
  425. expect(remote.mock.calls).toEqual([
  426. [SESSION_ID, { data: 'AAAA', name: 'bytes.bin' }, undefined],
  427. [SESSION_ID, { data: 'AQ==' }, undefined],
  428. ])
  429. await fiber.dispose()
  430. })
  431. it('rejects an unavailable stream and malformed background results', async () => {
  432. vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' })
  433. const fixture = await scopedService()
  434. const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
  435. await expect(fixture.service.upload(SESSION_ID, stream))
  436. .rejects.toThrow('stream file upload requires a background carrier')
  437. await fixture.fiber.dispose()
  438. vi.stubGlobal('location', { origin: 'https://preview.test' })
  439. ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
  440. fetch: () => Promise.resolve(new Response(null, { status: 413 })),
  441. }
  442. const rejected = await scopedService()
  443. await expect(rejected.service.upload(SESSION_ID, new Blob()))
  444. .rejects.toThrow('file upload transport failed with HTTP 413')
  445. await rejected.fiber.dispose()
  446. const bodies: unknown[] = [
  447. null,
  448. { ok: 'yes' },
  449. { ok: false, error: null },
  450. { ok: false, error: { code: 1, message: 'denied', details: {} } },
  451. { ok: false, error: { code: 'denied', message: 1, details: {} } },
  452. { ok: false, error: { code: 'denied', message: 'denied', details: null } },
  453. { ok: true, value: null },
  454. { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } },
  455. ]
  456. for (const body of bodies) {
  457. ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
  458. fetch: () => Promise.resolve(new Response(JSON.stringify(body), { status: 200 })),
  459. }
  460. const malformed = await scopedService()
  461. await expect(malformed.service.upload(SESSION_ID, new Blob()))
  462. .rejects.toThrow(/file upload transport returned an invalid/)
  463. await malformed.fiber.dispose()
  464. }
  465. ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
  466. fetch: () => Promise.resolve(new Response(JSON.stringify({
  467. ok: false,
  468. error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
  469. }), { status: 200 })),
  470. }
  471. const failed = await scopedService()
  472. await expect(failed.service.upload(SESSION_ID, new Blob())).resolves.toMatchObject({
  473. ok: false,
  474. error: {
  475. code: 'session/attachment-invalid',
  476. message: 'denied',
  477. details: { reason: 'NOPE' },
  478. },
  479. })
  480. await failed.fiber.dispose()
  481. })
  482. })