Browse Source

test(file-upload): cover isolated service paths

creatixchu 1 month ago
parent
commit
8a84c1edff

+ 29 - 1
packages/api/session-controller/tests/commands-upload-file.host.spec.ts

@@ -29,6 +29,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
   saveFileStream: ReturnType<typeof vi.fn>
   saveImages: ReturnType<typeof vi.fn>
   disposeAgent: () => void
+  uploadRoute: (request: Request) => Promise<Response>
 }> {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
@@ -68,7 +69,15 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
   const saveImages = vi.fn((): Promise<readonly ImageAttachmentRef[]> =>
     Promise.reject(new Error('fixture did not expect image persistence')))
   ctx.provide('attachments', { saveFile, saveFileStream, saveImages } as never)
-  ctx.provide('connection', { fetch: { register: () => async () => {} } } as never)
+  let uploadRoute: ((request: Request) => Promise<Response>) | undefined
+  ctx.provide('connection', {
+    fetch: {
+      register: (route: { readonly fetch: (request: Request) => Promise<Response> }) => {
+        uploadRoute = route.fetch
+        return () => {}
+      },
+    },
+  } as never)
   ctx.provide('llm', {
     listProviders: () => [{ id: 'fixture', name: 'Fixture' }],
     resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }),
@@ -83,6 +92,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
     serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
   } as unknown as ApiSessionAgentController
   const uploads = new FileUploads(ctx)
+  if (uploadRoute === undefined) throw new Error('file upload route was not registered')
   return {
     ctx,
     controller: new SessionCommandController(ctx, agents, '/workspace'),
@@ -93,6 +103,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
     saveFileStream,
     saveImages,
     disposeAgent,
+    uploadRoute,
   }
 }
 
@@ -106,6 +117,12 @@ function promptRequest(content: Parameters<SessionCommandController['prompt']>[0
 }
 
 describe('Session file uploads', () => {
+  it('registers an HTTP route bound to the upload service', async () => {
+    const { uploadRoute } = await uploadHarness()
+    await expect(uploadRoute(new Request('http://host/upload')))
+      .resolves.toMatchObject({ status: 405 })
+  })
+
   it('stages one verbatim upload and cites it from a later prompt as a file block', async () => {
     const { ctx, controller, uploads, agent, followup, saveFile } = await uploadHarness()
     const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal)
@@ -219,10 +236,21 @@ describe('Session file uploads', () => {
     disposeResolver()
     const replacement = vi.fn(async () => agent)
     const disposeReplacement = uploads.registerAgentResolver(replacement)
+    disposeResolver()
+    expect(() => { uploads.registerAgentResolver(replacement) }).toThrow('already registered')
     expect(disposeReplacement).toBeTypeOf('function')
     disposeReplacement()
   })
 
+  it('rejects a cold upload when no Agent resolver is registered', async () => {
+    const { uploads, disposeAgent } = await uploadHarness()
+    disposeAgent()
+    await expect(uploads.uploadStream({
+      sessionId: SESSION,
+      data: (async function* (): AsyncIterable<Uint8Array> {})(),
+    })).rejects.toMatchObject({ code: 'session/not-found' })
+  })
+
   it('rejects subagent uploads and access outside the receiving Agent scope', async () => {
     const child = await uploadHarness('subagent')
     await expect(child.uploads.upload(

+ 21 - 0
packages/api/session-controller/tests/controller.host.spec.ts

@@ -42,6 +42,16 @@ describe('SessionController facade', () => {
       list: () => Promise.resolve([header]),
       inspect,
     }) as never)
+    let uploadResolver: ((sessionId: SessionId) => Promise<Agent>) | undefined
+    ctx.provide('fileUploads', {
+      registerAgentResolver: (resolve: (sessionId: SessionId) => Promise<Agent>) => {
+        uploadResolver = resolve
+        return () => {}
+      },
+      resolve: () => undefined,
+      bindPrompt: () => () => {},
+      retirePrompt: () => {},
+    } as never)
     const controller = createSessionTestController(ctx, defaults)
     const status = vi.fn()
     const failure = vi.fn()
@@ -65,6 +75,17 @@ describe('SessionController facade', () => {
       ctx,
     } as Agent
     ctx.agents.register(agent)
+    const resolveUploadAgent = (id: SessionId): Promise<Agent> => {
+      if (uploadResolver === undefined) throw new Error('file upload resolver was not registered')
+      return uploadResolver(id)
+    }
+    await expect(resolveUploadAgent(sessionId)).resolves.toBe(agent)
+    const activationError = new RemoteError('session/not-found', 'missing upload session', { sessionId })
+    vi.spyOn(
+      (controller as unknown as { agents: ApiSessionAgentController }).agents,
+      'resolveAgent',
+    ).mockResolvedValueOnce({ error: activationError })
+    await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError)
     const consumeSelection = vi.spyOn(
       (controller as unknown as { agents: ApiSessionAgentController }).agents,
       'consumeSelection',

+ 1 - 1
packages/client/file-upload/src/index.ts

@@ -223,7 +223,7 @@ export class FileUploads extends TypertRemoteService {
   private observeSessionEvent(session: Session, event: SessionEvent): void {
     if (event.type !== 'user/message' || event.data.source.kind !== 'user'
       || !('rpcId' in event.data.source)) return
-    if (typeof event.data.source.rpcId === 'string') this.retire(session, event.data.source.rpcId)
+    this.retire(session, event.data.source.rpcId)
   }
 
   private retire(session: Session, requestId: string): void {

+ 29 - 0
packages/client/file-upload/tests/file-upload.client.spec.ts

@@ -483,10 +483,22 @@ describe('Agent-scoped file upload', () => {
     await fixture.fiber.dispose()
 
     vi.stubGlobal('location', { origin: 'https://preview.test' })
+    ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
+      fetch: () => Promise.resolve(new Response(null, { status: 413 })),
+    }
+    const rejected = await scopedService({ sessionId: 's1' })
+    await expect(rejected.service.upload(rejected.owner, new Blob()))
+      .rejects.toThrow('file upload transport failed with HTTP 413')
+    await rejected.fiber.dispose()
+
     const bodies: unknown[] = [
       null,
       { ok: 'yes' },
       { ok: false, error: null },
+      { ok: false, error: { code: 1, message: 'denied', details: {} } },
+      { ok: false, error: { code: 'denied', message: 1, details: {} } },
+      { ok: false, error: { code: 'denied', message: 'denied', details: null } },
+      { ok: true, value: null },
       { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } },
     ]
     for (const body of bodies) {
@@ -498,5 +510,22 @@ describe('Agent-scoped file upload', () => {
         .rejects.toThrow(/file upload transport returned an invalid/)
       await malformed.fiber.dispose()
     }
+
+    ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
+      fetch: () => Promise.resolve(new Response(JSON.stringify({
+        ok: false,
+        error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
+      }), { status: 200 })),
+    }
+    const failed = await scopedService({ sessionId: 's1' })
+    await expect(failed.service.upload(failed.owner, new Blob())).resolves.toMatchObject({
+      ok: false,
+      error: {
+        code: 'session/attachment-invalid',
+        message: 'denied',
+        details: { reason: 'NOPE' },
+      },
+    })
+    await failed.fiber.dispose()
   })
 })