file-upload-http.host.spec.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. import { runInNewContext } from 'node:vm'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import type { Mock } from 'vitest'
  6. import type { SessionCommandController } from '../src/commands.ts'
  7. import {
  8. handleSessionFileUploadHttp, registerSessionFileUploadHttp,
  9. } from '../src/file-upload-http.ts'
  10. function request(input: {
  11. method?: string
  12. sessionId?: string
  13. name?: string
  14. contentType?: string
  15. body?: Uint8Array
  16. } = {}): Request {
  17. const query = new URLSearchParams()
  18. if (input.sessionId !== undefined) query.set('sessionId', input.sessionId)
  19. if (input.name !== undefined) query.set('name', input.name)
  20. const suffix = query.size === 0 ? '' : `?${query.toString()}`
  21. return new Request(`http://host/api/session/uploadFileBinary${suffix}`, {
  22. method: input.method ?? 'POST',
  23. headers: input.contentType === undefined ? {} : { 'content-type': input.contentType },
  24. ...(input.body === undefined ? {} : { body: new Blob([Uint8Array.from(input.body).buffer]) }),
  25. })
  26. }
  27. function commands(result: unknown): SessionCommandController & {
  28. uploadFileStream: Mock<SessionCommandController['uploadFileStream']>
  29. uploadedChunks: Uint8Array[]
  30. } {
  31. const uploadedChunks: Uint8Array[] = []
  32. const uploadFileStream = vi.fn<SessionCommandController['uploadFileStream']>(async (input) => {
  33. for await (const chunk of input.data) uploadedChunks.push(chunk)
  34. return await result as Awaited<ReturnType<SessionCommandController['uploadFileStream']>>
  35. })
  36. return {
  37. uploadedChunks,
  38. uploadFileStream,
  39. } as unknown as SessionCommandController & {
  40. uploadFileStream: Mock<SessionCommandController['uploadFileStream']>
  41. uploadedChunks: Uint8Array[]
  42. }
  43. }
  44. describe('background file upload Fetch route', () => {
  45. it('registers one authenticated POST route on Connection', async () => {
  46. const ctx = new Context()
  47. const register = vi.fn((_route: unknown) => vi.fn(async () => {}))
  48. ctx.provide('connection', { fetch: { register } } as never)
  49. registerSessionFileUploadHttp(ctx, commands(Promise.resolve({})))
  50. await vi.waitFor(() => { expect(register).toHaveBeenCalledOnce() })
  51. const route = register.mock.calls[0]?.[0] as {
  52. path: string
  53. methods: string[]
  54. requestBody: string
  55. fetch(request: Request): Promise<Response>
  56. }
  57. expect(route.path).toBe('/api/session/uploadFileBinary')
  58. expect(route.methods).toEqual(['POST'])
  59. expect(route.requestBody).toBe('streaming')
  60. expect((await route.fetch(request({
  61. sessionId: 's1', contentType: 'application/octet-stream',
  62. }))).status).toBe(200)
  63. await ctx.fiber.dispose()
  64. })
  65. it('rejects the wrong method, media type, and missing Session id without storing', async () => {
  66. const controller = commands(Promise.resolve({}))
  67. const wrongMethod = await handleSessionFileUploadHttp(controller, request({ method: 'GET' }))
  68. expect(wrongMethod.status).toBe(405)
  69. expect(wrongMethod.headers.get('allow')).toBe('POST')
  70. const wrongType = await handleSessionFileUploadHttp(controller, request({ contentType: 'application/json' }))
  71. expect(wrongType.status).toBe(415)
  72. expect(await wrongType.text()).toBe('content type must be application/octet-stream')
  73. const missingSession = await handleSessionFileUploadHttp(
  74. controller,
  75. request({ contentType: 'application/octet-stream' }),
  76. )
  77. expect(missingSession.status).toBe(400)
  78. expect(await missingSession.text()).toBe('sessionId is required')
  79. expect(controller.uploadFileStream).not.toHaveBeenCalled()
  80. })
  81. it('stores the request bytes and returns the staged receipt', async () => {
  82. const value = {
  83. receiptId: 'receipt-1',
  84. file: { attachmentId: 'file-1', name: 'large & final.bin', bytes: 4 },
  85. }
  86. const controller = commands(Promise.resolve(value))
  87. const response = await handleSessionFileUploadHttp(controller, request({
  88. sessionId: 's1',
  89. name: 'large & final.bin',
  90. contentType: 'application/octet-stream; charset=binary',
  91. body: Uint8Array.of(1, 2, 3, 4),
  92. }))
  93. expect(controller.uploadFileStream).toHaveBeenCalledOnce()
  94. const upload = controller.uploadFileStream.mock.calls[0]?.[0]
  95. expect(upload).toMatchObject({ sessionId: 's1', name: 'large & final.bin' })
  96. expect(upload?.signal).toBeInstanceOf(AbortSignal)
  97. expect(controller.uploadedChunks).toEqual([Uint8Array.of(1, 2, 3, 4)])
  98. expect(response.status).toBe(200)
  99. expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8')
  100. expect(response.headers.get('cache-control')).toBe('no-store')
  101. expect(await response.json()).toEqual({ ok: true, value })
  102. })
  103. it('returns business and internal storage failures and keeps an absent name absent', async () => {
  104. const business = commands(Promise.reject(new RemoteError(
  105. 'session/attachment-invalid', 'denied', { reason: 'NOPE' },
  106. )))
  107. const businessResponse = await handleSessionFileUploadHttp(business, request({
  108. sessionId: 's1', contentType: 'application/octet-stream',
  109. }))
  110. expect(business.uploadFileStream).toHaveBeenCalledOnce()
  111. const upload = business.uploadFileStream.mock.calls[0]?.[0]
  112. expect(upload).toMatchObject({ sessionId: 's1' })
  113. expect(upload?.signal).toBeInstanceOf(AbortSignal)
  114. expect(business.uploadedChunks).toEqual([])
  115. expect(await businessResponse.json()).toEqual({
  116. ok: false,
  117. error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
  118. })
  119. const internal = commands(Promise.reject(new Error('disk offline')))
  120. expect(await (await handleSessionFileUploadHttp(internal, request({
  121. sessionId: 's1', contentType: 'application/octet-stream',
  122. }))).json()).toEqual({
  123. ok: false, error: { code: 'gateway/internal', message: 'disk offline', details: {} },
  124. })
  125. const foreignError = runInNewContext('new Error("disk exception")') as unknown as Error
  126. const exception = commands(Promise.reject(foreignError))
  127. expect(await (await handleSessionFileUploadHttp(exception, request({
  128. sessionId: 's1', contentType: 'application/octet-stream',
  129. }))).json()).toEqual({
  130. ok: false, error: { code: 'gateway/internal', message: 'Error: disk exception', details: {} },
  131. })
  132. })
  133. })