|
| 1 | +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' |
| 2 | +import { tmpdir } from 'node:os' |
| 3 | +import { join } from 'node:path' |
| 4 | +import { Command } from 'commander' |
| 5 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 6 | +import { buildGeneratedCommands } from '../../runtime/build.js' |
| 7 | +import { attachProtocolCommands } from './index.js' |
| 8 | + |
| 9 | +const { mockRequest } = vi.hoisted(() => ({ |
| 10 | + mockRequest: vi.fn(), |
| 11 | +})) |
| 12 | + |
| 13 | +vi.mock('../../context.js', () => ({ |
| 14 | + clientFrom: () => ({ |
| 15 | + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, |
| 16 | + profile: { |
| 17 | + workspaceId: 'ws_local', |
| 18 | + output: 'json', |
| 19 | + name: 'default', |
| 20 | + apiKey: 'k', |
| 21 | + endpoint: 'https://sim.example', |
| 22 | + }, |
| 23 | + }), |
| 24 | +})) |
| 25 | + |
| 26 | +let dir: string |
| 27 | + |
| 28 | +beforeEach(() => { |
| 29 | + dir = mkdtempSync(join(tmpdir(), 'sim-kb-upload-')) |
| 30 | + mockRequest.mockReset() |
| 31 | +}) |
| 32 | + |
| 33 | +afterEach(() => { |
| 34 | + vi.restoreAllMocks() |
| 35 | + vi.unstubAllGlobals() |
| 36 | + rmSync(dir, { recursive: true, force: true }) |
| 37 | +}) |
| 38 | + |
| 39 | +function program(): Command { |
| 40 | + const root = new Command('sim').exitOverride() |
| 41 | + for (const group of buildGeneratedCommands()) root.addCommand(group) |
| 42 | + attachProtocolCommands(root) |
| 43 | + const override = (command: Command) => { |
| 44 | + command.exitOverride() |
| 45 | + command.commands.forEach(override) |
| 46 | + } |
| 47 | + override(root) |
| 48 | + return root |
| 49 | +} |
| 50 | + |
| 51 | +function uploadSession() { |
| 52 | + return { |
| 53 | + id: 'upload_1', |
| 54 | + knowledgeBaseId: 'kb_1', |
| 55 | + status: 'uploading', |
| 56 | + name: 'notes.doc', |
| 57 | + contentType: 'application/msword', |
| 58 | + size: 5, |
| 59 | + partSize: 10, |
| 60 | + partCount: 1, |
| 61 | + uploadToken: 'secret-token', |
| 62 | + expiresAt: '2026-08-04T20:00:00.000Z', |
| 63 | + error: null, |
| 64 | + document: null, |
| 65 | + } |
| 66 | +} |
| 67 | + |
| 68 | +describe('knowledge documents upload', () => { |
| 69 | + it('owns the multipart protocol while hiding its low-level operations', () => { |
| 70 | + const knowledge = program().commands.find((command) => command.name() === 'knowledge') |
| 71 | + expect(knowledge?.commands.map((command) => command.name())).not.toEqual( |
| 72 | + expect.arrayContaining(['uploads', 'parts', 'complete']) |
| 73 | + ) |
| 74 | + |
| 75 | + const documents = knowledge?.commands.find((command) => command.name() === 'documents') |
| 76 | + expect(documents?.commands.map((command) => command.name())).toContain('upload') |
| 77 | + }) |
| 78 | + |
| 79 | + it('uploads a local document and prints the created document without transfer secrets', async () => { |
| 80 | + const path = join(dir, 'notes.doc') |
| 81 | + writeFileSync(path, 'hello') |
| 82 | + const session = uploadSession() |
| 83 | + mockRequest |
| 84 | + .mockResolvedValueOnce({ data: session }) |
| 85 | + .mockResolvedValueOnce({ |
| 86 | + data: { |
| 87 | + parts: [ |
| 88 | + { |
| 89 | + partNumber: 1, |
| 90 | + url: 'https://storage.example/part', |
| 91 | + headers: { 'content-type': 'application/octet-stream' }, |
| 92 | + expiresAt: '2026-08-04T20:00:00.000Z', |
| 93 | + }, |
| 94 | + ], |
| 95 | + }, |
| 96 | + }) |
| 97 | + .mockResolvedValueOnce({ |
| 98 | + data: { |
| 99 | + ...session, |
| 100 | + status: 'completed', |
| 101 | + document: { |
| 102 | + id: 'doc_1', |
| 103 | + knowledgeBaseId: 'kb_1', |
| 104 | + filename: 'notes.doc', |
| 105 | + fileSize: 5, |
| 106 | + mimeType: 'application/msword', |
| 107 | + processingStatus: 'pending', |
| 108 | + chunkCount: 0, |
| 109 | + tokenCount: 0, |
| 110 | + characterCount: 0, |
| 111 | + enabled: true, |
| 112 | + createdAt: '2026-08-04T19:00:00.000Z', |
| 113 | + }, |
| 114 | + }, |
| 115 | + }) |
| 116 | + vi.stubGlobal( |
| 117 | + 'fetch', |
| 118 | + vi.fn().mockResolvedValue( |
| 119 | + new Response(null, { |
| 120 | + status: 200, |
| 121 | + headers: { etag: '"etag-1"' }, |
| 122 | + }) |
| 123 | + ) |
| 124 | + ) |
| 125 | + const logged: string[] = [] |
| 126 | + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) |
| 127 | + |
| 128 | + await program().parseAsync([ |
| 129 | + 'node', |
| 130 | + 'sim', |
| 131 | + 'knowledge', |
| 132 | + 'documents', |
| 133 | + 'upload', |
| 134 | + 'kb_1', |
| 135 | + path, |
| 136 | + '--tag', |
| 137 | + 'customer', |
| 138 | + 'priority', |
| 139 | + '--recipe', |
| 140 | + 'default', |
| 141 | + '--lang', |
| 142 | + 'en', |
| 143 | + ]) |
| 144 | + |
| 145 | + expect(mockRequest.mock.calls[0]).toEqual([ |
| 146 | + '/api/v2/knowledge/kb_1/documents/uploads', |
| 147 | + { |
| 148 | + method: 'POST', |
| 149 | + body: { |
| 150 | + workspaceId: 'ws_local', |
| 151 | + name: 'notes.doc', |
| 152 | + contentType: 'application/msword', |
| 153 | + size: 5, |
| 154 | + tag1: 'customer', |
| 155 | + tag2: 'priority', |
| 156 | + processingOptions: { recipe: 'default', lang: 'en' }, |
| 157 | + }, |
| 158 | + }, |
| 159 | + ]) |
| 160 | + expect(mockRequest.mock.calls[1][0]).toBe( |
| 161 | + '/api/v2/knowledge/kb_1/documents/uploads/upload_1/parts' |
| 162 | + ) |
| 163 | + expect(mockRequest.mock.calls[2][0]).toBe( |
| 164 | + '/api/v2/knowledge/kb_1/documents/uploads/upload_1/complete' |
| 165 | + ) |
| 166 | + expect(mockRequest.mock.calls[2][1].body).toEqual({ |
| 167 | + parts: [{ partNumber: 1, etag: 'etag-1' }], |
| 168 | + }) |
| 169 | + expect(JSON.parse(logged[0])).toEqual({ |
| 170 | + id: 'doc_1', |
| 171 | + knowledgeBaseId: 'kb_1', |
| 172 | + name: 'notes.doc', |
| 173 | + size: 5, |
| 174 | + status: 'pending', |
| 175 | + }) |
| 176 | + expect(logged[0]).not.toContain('secret-token') |
| 177 | + }) |
| 178 | + |
| 179 | + it('rejects more tags than the protocol supports before making a request', async () => { |
| 180 | + const path = join(dir, 'notes.txt') |
| 181 | + writeFileSync(path, 'hello') |
| 182 | + |
| 183 | + await expect( |
| 184 | + program().parseAsync([ |
| 185 | + 'node', |
| 186 | + 'sim', |
| 187 | + 'knowledge', |
| 188 | + 'documents', |
| 189 | + 'upload', |
| 190 | + 'kb_1', |
| 191 | + path, |
| 192 | + '--tag', |
| 193 | + '1', |
| 194 | + '2', |
| 195 | + '3', |
| 196 | + '4', |
| 197 | + '5', |
| 198 | + '6', |
| 199 | + '7', |
| 200 | + '8', |
| 201 | + ]) |
| 202 | + ).rejects.toThrow(/at most seven/) |
| 203 | + expect(mockRequest).not.toHaveBeenCalled() |
| 204 | + }) |
| 205 | +}) |
0 commit comments