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

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