diff --git a/app/shared/src/hub/hubClientPayloadBodies.test.ts b/app/shared/src/hub/hubClientPayloadBodies.test.ts new file mode 100644 index 000000000..9fbacea1b --- /dev/null +++ b/app/shared/src/hub/hubClientPayloadBodies.test.ts @@ -0,0 +1,636 @@ +// real_tested=true +import { describe, expect, it } from 'vitest'; + +import type { + HubExecutionTarget, + HubExecutionTargetListResponse, + HubOidcAuthorizeRequest, +} from './hubClientDomainTypes'; +import { + buildAttachmentDownloadUrl, + buildAttachmentFormData, + buildForwardMessageBody, + buildFriendRequestBody, + buildMarkReadBody, + buildMemberIdsBody, + buildOidcAuthorizeBody, + buildOptionalJsonBody, + buildPatchSettingsBody, + buildProbeAttachmentBody, + buildReactionBody, + buildRefreshBody, + buildRemarkBody, + buildSessionIdBody, + buildStreamTaskEventBody, + buildTaskAckBody, + buildTaskDoneBody, + buildTaskFailBody, + buildTaskStreamBody, + buildTransferOwnerBody, + buildTriggerAgentTaskBody, + normalizeExecutionTargetsResponse, + withPublicCatalogParams, +} from './hubClientPayloadBodies'; + +const executionTarget: HubExecutionTarget = { + id: 'target-1', + name: 'Workstation Alpha', + type: 'local_edge', + status: 'online', +}; + +// ── normalizeExecutionTargetsResponse ────────────────────────────── + +describe('normalizeExecutionTargetsResponse', () => { + it('wraps a plain array into a list response with a hasMore:false page', () => { + const targets = [executionTarget]; + const result = normalizeExecutionTargetsResponse(targets); + expect(result.items).toBe(targets); + expect(result.page).toEqual({ hasMore: false }); + }); + + it('wraps an empty array into an empty items list', () => { + const result = normalizeExecutionTargetsResponse([]); + expect(result.items).toEqual([]); + expect(result.page).toEqual({ hasMore: false }); + }); + + it('passes through an existing list response untouched (same references)', () => { + const items = [executionTarget]; + const page = { hasMore: true, nextCursor: 'cursor-9' }; + const result = normalizeExecutionTargetsResponse({ items, page }); + expect(result.items).toBe(items); + expect(result.page).toBe(page); + }); + + it('replaces a missing or null items field with an empty array', () => { + const missingItems = normalizeExecutionTargetsResponse( + {} as unknown as HubExecutionTargetListResponse, + ); + expect(missingItems.items).toEqual([]); + + const nullItems = normalizeExecutionTargetsResponse({ + items: null, + page: { hasMore: true }, + } as unknown as HubExecutionTargetListResponse); + expect(nullItems.items).toEqual([]); + expect(nullItems.page).toEqual({ hasMore: true }); + }); + + it('falls back to a hasMore:false page when page is missing or null', () => { + const missingPage = normalizeExecutionTargetsResponse({ + items: [executionTarget], + } as unknown as HubExecutionTargetListResponse); + expect(missingPage.page).toEqual({ hasMore: false }); + + const nullPage = normalizeExecutionTargetsResponse({ + items: [executionTarget], + page: null, + } as unknown as HubExecutionTargetListResponse); + expect(nullPage.page).toEqual({ hasMore: false }); + }); + + it('preserves a non-array items payload only when it is a real array', () => { + const stringItems = normalizeExecutionTargetsResponse({ + items: 'not-an-array', + } as unknown as HubExecutionTargetListResponse); + expect(stringItems.items).toEqual([]); + }); +}); + +// ── buildOidcAuthorizeBody ───────────────────────────────────────── + +describe('buildOidcAuthorizeBody', () => { + it('injects the S256 code_challenge_method default', () => { + const body: HubOidcAuthorizeRequest = { code_challenge: 'challenge-abc' }; + const result = buildOidcAuthorizeBody(body); + expect(result.code_challenge_method).toBe('S256'); + expect(result.code_challenge).toBe('challenge-abc'); + }); + + it('lets an explicit code_challenge_method override the default', () => { + const result = buildOidcAuthorizeBody({ + code_challenge: 'challenge-abc', + code_challenge_method: 'plain', + }); + expect(result.code_challenge_method).toBe('plain'); + }); + + it('preserves optional device and redirect fields', () => { + const result = buildOidcAuthorizeBody({ + code_challenge: 'challenge-abc', + device_type: 'desktop', + device_id: 'dev-1', + redirect_uri: 'https://app.example.com/callback', + }); + expect(result).toEqual({ + code_challenge_method: 'S256', + code_challenge: 'challenge-abc', + device_type: 'desktop', + device_id: 'dev-1', + redirect_uri: 'https://app.example.com/callback', + }); + }); + + it('handles a minimal body with an empty code_challenge', () => { + const result = buildOidcAuthorizeBody({ code_challenge: '' }); + expect(result).toEqual({ code_challenge_method: 'S256', code_challenge: '' }); + }); + + it('handles an empty body object', () => { + const result = buildOidcAuthorizeBody({} as HubOidcAuthorizeRequest); + expect(result).toEqual({ code_challenge_method: 'S256' }); + }); +}); + +// ── buildRefreshBody ─────────────────────────────────────────────── + +describe('buildRefreshBody', () => { + it('wraps a refresh token under refresh_token', () => { + expect(buildRefreshBody('refresh-token-1')).toEqual({ refresh_token: 'refresh-token-1' }); + }); + + it('handles an empty refresh token', () => { + expect(buildRefreshBody('')).toEqual({ refresh_token: '' }); + }); +}); + +// ── buildFriendRequestBody ───────────────────────────────────────── + +describe('buildFriendRequestBody', () => { + it('includes the message when provided', () => { + expect(buildFriendRequestBody('friend-1', 'Hello!')).toEqual({ + friend_id: 'friend-1', + message: 'Hello!', + }); + }); + + it('omits the message key when the message is undefined', () => { + const result = buildFriendRequestBody('friend-1'); + expect(result).toEqual({ friend_id: 'friend-1' }); + expect('message' in result).toBe(false); + }); + + it('omits the message key when an explicit undefined is passed', () => { + const result = buildFriendRequestBody('friend-1', undefined); + expect(result).toEqual({ friend_id: 'friend-1' }); + expect('message' in result).toBe(false); + }); + + it('keeps the message key for an empty-string message', () => { + const result = buildFriendRequestBody('friend-1', ''); + expect(result).toEqual({ friend_id: 'friend-1', message: '' }); + expect('message' in result).toBe(true); + }); +}); + +// ── buildRemarkBody ──────────────────────────────────────────────── + +describe('buildRemarkBody', () => { + it('wraps a remark under remark', () => { + expect(buildRemarkBody('Best friend')).toEqual({ remark: 'Best friend' }); + }); + + it('handles an empty remark', () => { + expect(buildRemarkBody('')).toEqual({ remark: '' }); + }); +}); + +// ── buildMemberIdsBody ───────────────────────────────────────────── + +describe('buildMemberIdsBody', () => { + it('wraps member ids and keeps the array reference', () => { + const memberIds = ['user-1', 'user-2', 'user-3']; + const result = buildMemberIdsBody(memberIds); + expect(result).toEqual({ member_ids: memberIds }); + expect(result.member_ids).toBe(memberIds); + }); + + it('handles an empty member id list', () => { + expect(buildMemberIdsBody([])).toEqual({ member_ids: [] }); + }); +}); + +// ── buildTransferOwnerBody ───────────────────────────────────────── + +describe('buildTransferOwnerBody', () => { + it('wraps the new owner id under new_owner_id', () => { + expect(buildTransferOwnerBody('user-9')).toEqual({ new_owner_id: 'user-9' }); + }); + + it('handles an empty owner id', () => { + expect(buildTransferOwnerBody('')).toEqual({ new_owner_id: '' }); + }); +}); + +// ── buildMarkReadBody ────────────────────────────────────────────── + +describe('buildMarkReadBody', () => { + it('wraps a positive sequence number', () => { + expect(buildMarkReadBody(42)).toEqual({ last_read_seq: 42 }); + }); + + it('handles boundary values: zero, negative, and MAX_SAFE_INTEGER', () => { + expect(buildMarkReadBody(0)).toEqual({ last_read_seq: 0 }); + expect(buildMarkReadBody(-7)).toEqual({ last_read_seq: -7 }); + expect(buildMarkReadBody(Number.MAX_SAFE_INTEGER)).toEqual({ + last_read_seq: Number.MAX_SAFE_INTEGER, + }); + }); +}); + +// ── buildSessionIdBody ───────────────────────────────────────────── + +describe('buildSessionIdBody', () => { + it('wraps a session id under session_id', () => { + expect(buildSessionIdBody('session-7')).toEqual({ session_id: 'session-7' }); + }); + + it('handles an empty session id', () => { + expect(buildSessionIdBody('')).toEqual({ session_id: '' }); + }); +}); + +// ── buildForwardMessageBody ──────────────────────────────────────── + +describe('buildForwardMessageBody', () => { + it('wraps target session ids and keeps the array reference', () => { + const targetSessionIds = ['session-1', 'session-2']; + const result = buildForwardMessageBody(targetSessionIds); + expect(result).toEqual({ target_session_ids: targetSessionIds }); + expect(result.target_session_ids).toBe(targetSessionIds); + }); + + it('handles an empty target session list', () => { + expect(buildForwardMessageBody([])).toEqual({ target_session_ids: [] }); + }); +}); + +// ── buildTaskAckBody ─────────────────────────────────────────────── + +describe('buildTaskAckBody', () => { + it('wraps a run id under run_id', () => { + expect(buildTaskAckBody('run-1')).toEqual({ run_id: 'run-1' }); + }); + + it('returns undefined when runId is undefined', () => { + expect(buildTaskAckBody()).toBeUndefined(); + expect(buildTaskAckBody(undefined)).toBeUndefined(); + }); + + it('returns undefined for a falsy empty-string runId', () => { + expect(buildTaskAckBody('')).toBeUndefined(); + }); +}); + +// ── buildTaskStreamBody ──────────────────────────────────────────── + +describe('buildTaskStreamBody', () => { + it('builds a content-only body without a run_id key', () => { + const result = buildTaskStreamBody('streaming…'); + expect(result).toEqual({ content: 'streaming…' }); + expect('run_id' in result).toBe(false); + }); + + it('includes run_id when a truthy runId is provided', () => { + expect(buildTaskStreamBody('streaming…', 'run-2')).toEqual({ + content: 'streaming…', + run_id: 'run-2', + }); + }); + + it('keeps empty content and drops falsy runIds', () => { + const emptyContent = buildTaskStreamBody(''); + expect(emptyContent).toEqual({ content: '' }); + + const falsyRunId = buildTaskStreamBody('x', ''); + expect(falsyRunId).toEqual({ content: 'x' }); + expect('run_id' in falsyRunId).toBe(false); + }); +}); + +// ── buildTaskDoneBody ────────────────────────────────────────────── + +describe('buildTaskDoneBody', () => { + it('defaults final_content to an empty string with no run_id', () => { + const result = buildTaskDoneBody(); + expect(result).toEqual({ final_content: '' }); + expect('run_id' in result).toBe(false); + }); + + it('passes through a provided final_content and run_id', () => { + expect(buildTaskDoneBody('All done.', 'run-3')).toEqual({ + final_content: 'All done.', + run_id: 'run-3', + }); + }); + + it('preserves an empty-string final_content', () => { + expect(buildTaskDoneBody('')).toEqual({ final_content: '' }); + }); + + it('coalesces a null final_content to an empty string', () => { + const result = buildTaskDoneBody(null as unknown as string); + expect(result).toEqual({ final_content: '' }); + }); + + it('drops a falsy runId while keeping the final content', () => { + const result = buildTaskDoneBody('Done.', ''); + expect(result).toEqual({ final_content: 'Done.' }); + expect('run_id' in result).toBe(false); + }); +}); + +// ── buildTaskFailBody ────────────────────────────────────────────── + +describe('buildTaskFailBody', () => { + it('builds an error-only body without a run_id key', () => { + const result = buildTaskFailBody('boom'); + expect(result).toEqual({ error: 'boom' }); + expect('run_id' in result).toBe(false); + }); + + it('includes run_id when provided', () => { + expect(buildTaskFailBody('boom', 'run-4')).toEqual({ error: 'boom', run_id: 'run-4' }); + }); + + it('keeps empty errors and drops falsy runIds', () => { + expect(buildTaskFailBody('')).toEqual({ error: '' }); + + const falsyRunId = buildTaskFailBody('boom', ''); + expect(falsyRunId).toEqual({ error: 'boom' }); + expect('run_id' in falsyRunId).toBe(false); + }); +}); + +// ── buildStreamTaskEventBody ─────────────────────────────────────── + +describe('buildStreamTaskEventBody', () => { + it('builds a minimal body with only event_type and payload', () => { + const payload = { pct: 50 }; + const result = buildStreamTaskEventBody('progress', payload); + expect(result).toEqual({ event_type: 'progress', payload }); + expect('run_id' in result).toBe(false); + expect('client_msg_id' in result).toBe(false); + }); + + it('includes run_id when options.runId is truthy', () => { + expect(buildStreamTaskEventBody('progress', { pct: 50 }, { runId: 'run-5' })).toEqual({ + event_type: 'progress', + payload: { pct: 50 }, + run_id: 'run-5', + }); + }); + + it('includes client_msg_id when options.clientMsgId is truthy', () => { + expect(buildStreamTaskEventBody('progress', { pct: 50 }, { clientMsgId: 'cm-1' })).toEqual({ + event_type: 'progress', + payload: { pct: 50 }, + client_msg_id: 'cm-1', + }); + }); + + it('includes both ids when both options are provided', () => { + expect( + buildStreamTaskEventBody('progress', { pct: 50 }, { runId: 'run-6', clientMsgId: 'cm-2' }), + ).toEqual({ + event_type: 'progress', + payload: { pct: 50 }, + run_id: 'run-6', + client_msg_id: 'cm-2', + }); + }); + + it('drops falsy option values from the resulting body', () => { + const result = buildStreamTaskEventBody('progress', { pct: 50 }, { + runId: '', + clientMsgId: '', + }); + expect(result).toEqual({ event_type: 'progress', payload: { pct: 50 } }); + expect('run_id' in result).toBe(false); + expect('client_msg_id' in result).toBe(false); + }); + + it('passes payload through untouched for null, undefined, and primitives', () => { + expect(buildStreamTaskEventBody('progress', null)).toEqual({ + event_type: 'progress', + payload: null, + }); + expect(buildStreamTaskEventBody('progress', undefined)).toEqual({ + event_type: 'progress', + payload: undefined, + }); + expect(buildStreamTaskEventBody('progress', 0)).toEqual({ event_type: 'progress', payload: 0 }); + expect(buildStreamTaskEventBody('progress', ['a', 'b'])).toEqual({ + event_type: 'progress', + payload: ['a', 'b'], + }); + }); +}); + +// ── buildTriggerAgentTaskBody ────────────────────────────────────── + +describe('buildTriggerAgentTaskBody', () => { + it('builds a body with only trigger_message_id when no options are given', () => { + expect(buildTriggerAgentTaskBody('msg-1')).toEqual({ trigger_message_id: 'msg-1' }); + }); + + it('spreads all provided options alongside trigger_message_id', () => { + const options = { + agent_instance_id: 'inst-1', + agent_type: 'orchestrator', + custom_agent_id: 'custom-1', + model_params: '{"temperature":0.2}', + target_id: 'edge-1', + }; + expect(buildTriggerAgentTaskBody('msg-1', options)).toEqual({ + trigger_message_id: 'msg-1', + ...options, + }); + }); + + it('preserves falsy option values (unconditional spread)', () => { + const result = buildTriggerAgentTaskBody('msg-1', { target_id: '' }); + expect(result).toEqual({ trigger_message_id: 'msg-1', target_id: '' }); + expect('target_id' in result).toBe(true); + }); +}); + +// ── buildAttachmentFormData ──────────────────────────────────────── + +describe('buildAttachmentFormData', () => { + it('appends file, hash, and original_name entries', () => { + const file = new File(['hello'], 'report.txt', { type: 'text/plain' }); + const formData = buildAttachmentFormData(file, 'sha256-abc'); + expect(formData.get('file')).toBe(file); + expect(formData.get('hash')).toBe('sha256-abc'); + expect(formData.get('original_name')).toBe('report.txt'); + }); + + it('appends exactly one file entry', () => { + const file = new File(['hello'], 'report.txt'); + const formData = buildAttachmentFormData(file, 'sha256-abc'); + expect(formData.getAll('file')).toHaveLength(1); + }); + + it('handles a file with an empty name', () => { + const file = new File([], ''); + const formData = buildAttachmentFormData(file, 'hash-1'); + expect(formData.get('original_name')).toBe(''); + }); + + it('preserves unicode file names', () => { + const file = new File(['data'], '报告.txt'); + const formData = buildAttachmentFormData(file, 'hash-1'); + expect(formData.get('original_name')).toBe('报告.txt'); + }); +}); + +// ── buildAttachmentDownloadUrl ───────────────────────────────────── + +describe('buildAttachmentDownloadUrl', () => { + it('joins the base url with the attachment path', () => { + expect(buildAttachmentDownloadUrl('https://hub.example.com', 'att-123')).toBe( + 'https://hub.example.com/client/attachments/att-123', + ); + }); + + it('URL-encodes reserved characters in the attachment id', () => { + expect(buildAttachmentDownloadUrl('https://hub.example.com', 'a/b c')).toBe( + 'https://hub.example.com/client/attachments/a%2Fb%20c', + ); + }); + + it('URL-encodes unicode ids and tolerates an empty attachment id', () => { + expect(buildAttachmentDownloadUrl('https://hub.example.com', '文件')).toBe( + 'https://hub.example.com/client/attachments/%E6%96%87%E4%BB%B6', + ); + expect(buildAttachmentDownloadUrl('https://hub.example.com', '')).toBe( + 'https://hub.example.com/client/attachments/', + ); + }); + + it('handles empty and trailing-slash base urls', () => { + expect(buildAttachmentDownloadUrl('', 'att-1')).toBe('/client/attachments/att-1'); + expect(buildAttachmentDownloadUrl('https://hub.example.com/', 'att-1')).toBe( + 'https://hub.example.com//client/attachments/att-1', + ); + }); +}); + +// ── withPublicCatalogParams ──────────────────────────────────────── + +describe('withPublicCatalogParams', () => { + it('returns only is_public when no params are provided', () => { + expect(withPublicCatalogParams()).toEqual({ is_public: 'true' }); + expect(withPublicCatalogParams(undefined)).toEqual({ is_public: 'true' }); + }); + + it('merges provided params with the is_public marker', () => { + expect(withPublicCatalogParams({ page: '2', category: 'agents' })).toEqual({ + is_public: 'true', + page: '2', + category: 'agents', + }); + }); + + it('lets params override the is_public marker', () => { + expect(withPublicCatalogParams({ is_public: 'false' })).toEqual({ is_public: 'false' }); + }); + + it('handles an empty params object', () => { + expect(withPublicCatalogParams({})).toEqual({ is_public: 'true' }); + }); +}); + +// ── buildReactionBody ────────────────────────────────────────────── + +describe('buildReactionBody', () => { + it('merges the session id with the reaction emoji', () => { + expect(buildReactionBody('session-1', { emoji: '👍' })).toEqual({ + session_id: 'session-1', + emoji: '👍', + }); + }); + + it('spreads extra reaction fields into the body', () => { + const reactionWithExtras = { emoji: '👍', custom_key: 'extra' }; + expect(buildReactionBody('session-1', reactionWithExtras)).toEqual({ + session_id: 'session-1', + emoji: '👍', + custom_key: 'extra', + }); + }); + + it('handles an empty emoji', () => { + expect(buildReactionBody('session-1', { emoji: '' })).toEqual({ + session_id: 'session-1', + emoji: '', + }); + }); +}); + +// ── buildPatchSettingsBody ───────────────────────────────────────── + +describe('buildPatchSettingsBody', () => { + it('wraps settings values and keeps the object reference', () => { + const values: Record = { theme: 'dark', locale: 'zh-CN' }; + const result = buildPatchSettingsBody(values); + expect(result).toEqual({ values }); + expect(result.values).toBe(values); + }); + + it('handles an empty settings object', () => { + expect(buildPatchSettingsBody({})).toEqual({ values: {} }); + }); +}); + +// ── buildProbeAttachmentBody ─────────────────────────────────────── + +describe('buildProbeAttachmentBody', () => { + it('wraps a hash under hash', () => { + expect(buildProbeAttachmentBody('sha256-xyz')).toEqual({ hash: 'sha256-xyz' }); + }); + + it('handles an empty hash', () => { + expect(buildProbeAttachmentBody('')).toEqual({ hash: '' }); + }); +}); + +// ── buildOptionalJsonBody ────────────────────────────────────────── + +describe('buildOptionalJsonBody', () => { + it('returns an empty object (no body key) for undefined payloads', () => { + const result = buildOptionalJsonBody(undefined); + expect(result).toEqual({}); + expect('body' in result).toBe(false); + }); + + it('serializes null to the JSON literal "null"', () => { + expect(buildOptionalJsonBody(null)).toEqual({ body: 'null' }); + }); + + it('serializes strings with JSON quoting', () => { + expect(buildOptionalJsonBody('hello')).toEqual({ body: '"hello"' }); + expect(buildOptionalJsonBody('')).toEqual({ body: '""' }); + }); + + it('serializes objects and drops undefined-valued fields', () => { + expect(buildOptionalJsonBody({ a: 1, b: undefined })).toEqual({ body: '{"a":1}' }); + }); + + it('serializes nested structures deterministically', () => { + expect(buildOptionalJsonBody({ nested: { flag: true }, list: [1, 2] })).toEqual({ + body: '{"nested":{"flag":true},"list":[1,2]}', + }); + }); + + it('serializes falsy primitives 0 and false', () => { + expect(buildOptionalJsonBody(0)).toEqual({ body: '0' }); + expect(buildOptionalJsonBody(false)).toEqual({ body: 'false' }); + }); + + it('serializes arrays', () => { + expect(buildOptionalJsonBody([1, 'two', null])).toEqual({ body: '[1,"two",null]' }); + }); +}); diff --git a/app/shared/src/hub/hubClientPayloadRequestsTeams.test.ts b/app/shared/src/hub/hubClientPayloadRequestsTeams.test.ts new file mode 100644 index 000000000..a6f992281 --- /dev/null +++ b/app/shared/src/hub/hubClientPayloadRequestsTeams.test.ts @@ -0,0 +1,394 @@ +// real_tested=true +import { describe, expect, it } from 'vitest'; +import { + buildAddAgentTeamMemberRequest, + buildCreateAgentProfileRequest, + buildCreateAgentTeamRequest, + buildCreateDocumentRequest, + buildDecideTeamApprovalRequest, + buildDeleteAgentProfileRequest, + buildDeleteAgentTeamRequest, + buildDeleteDocumentRequest, + buildPostTeamRouteDecisionRequest, + buildRemoveAgentTeamMemberRequest, + buildResolveTeamConflictRequest, + buildStartTeamRunRequest, + buildUpdateAgentProfileRequest, + buildUpdateAgentTeamRequest, + buildUpdateDocumentRequest, +} from './hubClientPayloadRequestsTeams'; + +// ── buildCreateAgentTeamRequest ───────────────────────────────────── + +describe('buildCreateAgentTeamRequest', () => { + it('builds a POST request against the agent-teams collection with a JSON body', () => { + const request = buildCreateAgentTeamRequest({ name: 'Atlas', description: 'primary team' }); + + expect(request.path).toBe('/web/agent-teams'); + expect(request.init).toEqual({ + method: 'POST', + body: '{"name":"Atlas","description":"primary team"}', + }); + }); + + it('preserves nested objects, arrays, and unicode without extra escaping', () => { + const request = buildCreateAgentTeamRequest({ + name: 'héllo wörld', + members: [{ id: 'm-1', role: 'leader' }], + tags: ['a/b', 'c&d'], + }); + + expect(request.init.body).toBe( + '{"name":"héllo wörld","members":[{"id":"m-1","role":"leader"}],"tags":["a/b","c&d"]}', + ); + }); + + it('keeps the body key with an undefined value when data is undefined', () => { + const request = buildCreateAgentTeamRequest(undefined); + + expect(request.init.method).toBe('POST'); + expect('body' in request.init).toBe(true); + expect(request.init.body).toBeUndefined(); + }); + + it('serializes null, strings, numbers, and booleans via JSON.stringify semantics', () => { + expect(buildCreateAgentTeamRequest(null).init.body).toBe('null'); + expect(buildCreateAgentTeamRequest('draft').init.body).toBe('"draft"'); + expect(buildCreateAgentTeamRequest('').init.body).toBe('""'); + expect(buildCreateAgentTeamRequest(0).init.body).toBe('0'); + expect(buildCreateAgentTeamRequest(-1.5).init.body).toBe('-1.5'); + expect(buildCreateAgentTeamRequest(true).init.body).toBe('true'); + expect(buildCreateAgentTeamRequest([1, 'two']).init.body).toBe('[1,"two"]'); + }); + + it('returns a fresh request and init object on every call', () => { + const first = buildCreateAgentTeamRequest({ name: 'a' }); + const second = buildCreateAgentTeamRequest({ name: 'b' }); + + expect(first).not.toBe(second); + expect(first.init).not.toBe(second.init); + expect(first.init.body).not.toBe(second.init.body); + + first.init.method = 'PUT'; + expect(second.init.method).toBe('POST'); + }); +}); + +// ── buildUpdateAgentTeamRequest ───────────────────────────────────── + +describe('buildUpdateAgentTeamRequest', () => { + it('builds a PUT request scoped to the team id with a JSON body', () => { + const request = buildUpdateAgentTeamRequest('team-1', { name: 'Renamed' }); + + expect(request.path).toBe('/web/agent-teams/team-1'); + expect(request.init).toEqual({ method: 'PUT', body: '{"name":"Renamed"}' }); + }); + + it('percent-encodes team ids with spaces, slashes, and unicode', () => { + const request = buildUpdateAgentTeamRequest('team 1/2 é', {}); + + expect(request.path).toBe('/web/agent-teams/team%201%2F2%20%C3%A9'); + }); + + it('accepts an empty team id, producing a trailing slash', () => { + const request = buildUpdateAgentTeamRequest('', {}); + + expect(request.path).toBe('/web/agent-teams/'); + }); + + it('keeps the body key with an undefined value for undefined data', () => { + const request = buildUpdateAgentTeamRequest('team-1', undefined); + + expect(request.init.method).toBe('PUT'); + expect('body' in request.init).toBe(true); + expect(request.init.body).toBeUndefined(); + }); +}); + +// ── buildAddAgentTeamMemberRequest ────────────────────────────────── + +describe('buildAddAgentTeamMemberRequest', () => { + it('builds a POST request against the team members collection', () => { + const request = buildAddAgentTeamMemberRequest('team-1', { agent_id: 'agent-9' }); + + expect(request.path).toBe('/web/agent-teams/team-1/members'); + expect(request.init).toEqual({ method: 'POST', body: '{"agent_id":"agent-9"}' }); + }); + + it('percent-encodes the team id in the path', () => { + expect(buildAddAgentTeamMemberRequest('a b/c', {}).path).toBe( + '/web/agent-teams/a%20b%2Fc/members', + ); + }); + + it('serializes an empty object body as "{}"', () => { + expect(buildAddAgentTeamMemberRequest('team-1', {}).init.body).toBe('{}'); + }); +}); + +// ── buildStartTeamRunRequest ──────────────────────────────────────── + +describe('buildStartTeamRunRequest', () => { + it('builds a POST request against the team runs collection', () => { + const request = buildStartTeamRunRequest('team-1', { prompt: 'go' }); + + expect(request.path).toBe('/web/agent-teams/team-1/runs'); + expect(request.init).toEqual({ method: 'POST', body: '{"prompt":"go"}' }); + }); + + it('percent-encodes the team id in the path', () => { + expect(buildStartTeamRunRequest('team x?', {}).path).toBe('/web/agent-teams/team%20x%3F/runs'); + }); + + it('keeps the body key with an undefined value when data is undefined', () => { + const request = buildStartTeamRunRequest('team-1', undefined); + + expect('body' in request.init).toBe(true); + expect(request.init.body).toBeUndefined(); + }); +}); + +// ── buildDecideTeamApprovalRequest ────────────────────────────────── + +describe('buildDecideTeamApprovalRequest', () => { + it('builds a POST request to the approval decide endpoint', () => { + const request = buildDecideTeamApprovalRequest('team-1', 'run-2', 'ap-3', { + decision: 'approve', + }); + + expect(request.path).toBe('/web/agent-teams/team-1/runs/run-2/approvals/ap-3/decide'); + expect(request.init).toEqual({ method: 'POST', body: '{"decision":"approve"}' }); + }); + + it('percent-encodes team, run, and approval ids independently', () => { + const request = buildDecideTeamApprovalRequest('t 1', 'r/2', 'a?3', true); + + expect(request.path).toBe( + '/web/agent-teams/t%201/runs/r%2F2/approvals/a%3F3/decide', + ); + }); + + it('serializes a null decision as the string "null"', () => { + const request = buildDecideTeamApprovalRequest('team-1', 'run-2', 'ap-3', null); + + expect(request.init.body).toBe('null'); + }); + + it('keeps the body key with an undefined value for an undefined decision', () => { + const request = buildDecideTeamApprovalRequest('team-1', 'run-2', 'ap-3', undefined); + + expect('body' in request.init).toBe(true); + expect(request.init.body).toBeUndefined(); + }); +}); + +// ── buildResolveTeamConflictRequest ───────────────────────────────── + +describe('buildResolveTeamConflictRequest', () => { + it('builds a POST request to the conflict resolve endpoint', () => { + const request = buildResolveTeamConflictRequest('team-1', 'run-2', 'cf-3', { + choice: 'keep-a', + }); + + expect(request.path).toBe('/web/agent-teams/team-1/runs/run-2/conflicts/cf-3/resolve'); + expect(request.init).toEqual({ method: 'POST', body: '{"choice":"keep-a"}' }); + }); + + it('percent-encodes team, run, and conflict ids independently', () => { + const request = buildResolveTeamConflictRequest('t 1', 'r#2', 'c&3', 'x'); + + expect(request.path).toBe( + '/web/agent-teams/t%201/runs/r%232/conflicts/c%263/resolve', + ); + }); + + it('serializes a null resolution as the string "null"', () => { + const request = buildResolveTeamConflictRequest('team-1', 'run-2', 'cf-3', null); + + expect(request.init.body).toBe('null'); + }); +}); + +// ── buildCreateAgentProfileRequest ────────────────────────────────── + +describe('buildCreateAgentProfileRequest', () => { + it('builds a POST request against the agent-profiles collection', () => { + const request = buildCreateAgentProfileRequest({ runtime_id: 'rt-1', name: 'Profile A' }); + + expect(request.path).toBe('/web/agent-profiles'); + expect(request.init).toEqual({ + method: 'POST', + body: '{"runtime_id":"rt-1","name":"Profile A"}', + }); + }); + + it('keeps the body key with an undefined value for undefined data', () => { + const request = buildCreateAgentProfileRequest(undefined); + + expect('body' in request.init).toBe(true); + expect(request.init.body).toBeUndefined(); + }); +}); + +// ── buildUpdateAgentProfileRequest ────────────────────────────────── + +describe('buildUpdateAgentProfileRequest', () => { + it('builds a PATCH request scoped to the profile id', () => { + const request = buildUpdateAgentProfileRequest('prof-1', { name: 'Updated' }); + + expect(request.path).toBe('/web/agent-profiles/prof-1'); + expect(request.init).toEqual({ method: 'PATCH', body: '{"name":"Updated"}' }); + }); + + it('percent-encodes profile ids in the path', () => { + expect(buildUpdateAgentProfileRequest('p 1/2', {}).path).toBe( + '/web/agent-profiles/p%201%2F2', + ); + }); + + it('serializes a null payload as the string "null"', () => { + expect(buildUpdateAgentProfileRequest('prof-1', null).init.body).toBe('null'); + }); +}); + +// ── buildCreateDocumentRequest ────────────────────────────────────── + +describe('buildCreateDocumentRequest', () => { + it('builds a POST request against the documents collection', () => { + const request = buildCreateDocumentRequest({ title: 'Notes', content: 'hello' }); + + expect(request.path).toBe('/web/documents'); + expect(request.init).toEqual({ + method: 'POST', + body: '{"title":"Notes","content":"hello"}', + }); + }); + + it('serializes an empty object body as "{}"', () => { + expect(buildCreateDocumentRequest({}).init.body).toBe('{}'); + }); +}); + +// ── buildUpdateDocumentRequest ────────────────────────────────────── + +describe('buildUpdateDocumentRequest', () => { + it('builds a PATCH request scoped to the document id', () => { + const request = buildUpdateDocumentRequest('doc-1', { title: 'Renamed' }); + + expect(request.path).toBe('/web/documents/doc-1'); + expect(request.init).toEqual({ method: 'PATCH', body: '{"title":"Renamed"}' }); + }); + + it('percent-encodes document ids in the path', () => { + expect(buildUpdateDocumentRequest('d 1/2', {}).path).toBe('/web/documents/d%201%2F2'); + }); + + it('keeps the body key with an undefined value for undefined data', () => { + const request = buildUpdateDocumentRequest('doc-1', undefined); + + expect('body' in request.init).toBe(true); + expect(request.init.body).toBeUndefined(); + }); +}); + +// ── buildRemoveAgentTeamMemberRequest ─────────────────────────────── + +describe('buildRemoveAgentTeamMemberRequest', () => { + it('builds a bodyless DELETE request against the member resource', () => { + const request = buildRemoveAgentTeamMemberRequest('team-1', 'm-9'); + + expect(request.path).toBe('/web/agent-teams/team-1/members/m-9'); + expect(request.init).toEqual({ method: 'DELETE' }); + expect('body' in request.init).toBe(false); + }); + + it('percent-encodes team and member ids independently', () => { + const request = buildRemoveAgentTeamMemberRequest('t 1', 'm/9'); + + expect(request.path).toBe('/web/agent-teams/t%201/members/m%2F9'); + }); + + it('accepts an empty member id, producing a trailing slash', () => { + expect(buildRemoveAgentTeamMemberRequest('team-1', '').path).toBe( + '/web/agent-teams/team-1/members/', + ); + }); +}); + +// ── buildPostTeamRouteDecisionRequest ─────────────────────────────── + +describe('buildPostTeamRouteDecisionRequest', () => { + it('builds a POST request to the route-decisions endpoint', () => { + const request = buildPostTeamRouteDecisionRequest('team-1', 'run-2', { + route: 'continue', + }); + + expect(request.path).toBe('/web/agent-teams/team-1/runs/run-2/route-decisions'); + expect(request.init).toEqual({ method: 'POST', body: '{"route":"continue"}' }); + }); + + it('percent-encodes team and run ids in the path', () => { + const request = buildPostTeamRouteDecisionRequest('t 1', 'r/2', 'x'); + + expect(request.path).toBe('/web/agent-teams/t%201/runs/r%2F2/route-decisions'); + }); + + it('keeps the body key with an undefined value for an undefined decision', () => { + const request = buildPostTeamRouteDecisionRequest('team-1', 'run-2', undefined); + + expect('body' in request.init).toBe(true); + expect(request.init.body).toBeUndefined(); + }); +}); + +// ── buildDeleteAgentTeamRequest ───────────────────────────────────── + +describe('buildDeleteAgentTeamRequest', () => { + it('builds a bodyless DELETE request scoped to the team id', () => { + const request = buildDeleteAgentTeamRequest('team-1'); + + expect(request.path).toBe('/web/agent-teams/team-1'); + expect(request.init).toEqual({ method: 'DELETE' }); + expect('body' in request.init).toBe(false); + }); + + it('percent-encodes the team id in the path', () => { + expect(buildDeleteAgentTeamRequest('t 1/2').path).toBe('/web/agent-teams/t%201%2F2'); + }); + + it('accepts an empty team id, producing a trailing slash', () => { + expect(buildDeleteAgentTeamRequest('').path).toBe('/web/agent-teams/'); + }); +}); + +// ── buildDeleteAgentProfileRequest ────────────────────────────────── + +describe('buildDeleteAgentProfileRequest', () => { + it('builds a bodyless DELETE request scoped to the profile id', () => { + const request = buildDeleteAgentProfileRequest('prof-1'); + + expect(request.path).toBe('/web/agent-profiles/prof-1'); + expect(request.init).toEqual({ method: 'DELETE' }); + expect('body' in request.init).toBe(false); + }); + + it('percent-encodes the profile id in the path', () => { + expect(buildDeleteAgentProfileRequest('p 1/2').path).toBe('/web/agent-profiles/p%201%2F2'); + }); +}); + +// ── buildDeleteDocumentRequest ────────────────────────────────────── + +describe('buildDeleteDocumentRequest', () => { + it('builds a bodyless DELETE request scoped to the document id', () => { + const request = buildDeleteDocumentRequest('doc-1'); + + expect(request.path).toBe('/web/documents/doc-1'); + expect(request.init).toEqual({ method: 'DELETE' }); + expect('body' in request.init).toBe(false); + }); + + it('percent-encodes the document id in the path', () => { + expect(buildDeleteDocumentRequest('d 1/2').path).toBe('/web/documents/d%201%2F2'); + }); +}); diff --git a/app/shared/src/hub/hubClientPayloadRequestsWorkspace.test.ts b/app/shared/src/hub/hubClientPayloadRequestsWorkspace.test.ts new file mode 100644 index 000000000..bf35ea7e6 --- /dev/null +++ b/app/shared/src/hub/hubClientPayloadRequestsWorkspace.test.ts @@ -0,0 +1,235 @@ +// real_tested=true +import { describe, expect, it } from 'vitest'; +import { + buildAckRelayCommandRequest, + buildCreateCustomAgentRequest, + buildCreateExecutionTargetRequest, + buildCreateRelayCommandRequest, + buildCreateWorkspaceProjectRequest, + buildCreateWorkspaceProjectThreadRequest, + buildDeleteCustomAgentRequest, + buildDeleteExecutionTargetRequest, + buildPatchSettingsRequest, + buildPingExecutionTargetRequest, + buildProbeAttachmentRequest, + buildSendWorkspaceProjectThreadMessageRequest, + buildUpdateCustomAgentRequest, + buildUpdateExecutionTargetRequest, + buildUpdateWorkspaceProjectRequest, + buildUploadAttachmentRequest, +} from './hubClientPayloadRequestsWorkspace'; + +describe('hubClientPayloadRequestsWorkspace', () => { + it('builds a PATCH settings request with wrapped values', () => { + const values = { theme: 'dark', locale: 'zh-CN' }; + expect(buildPatchSettingsRequest(values)).toEqual({ + path: '/client/settings', + init: { method: 'PATCH', body: JSON.stringify({ values }) }, + }); + }); + + it('builds a PATCH settings request for empty values', () => { + expect(buildPatchSettingsRequest({})).toEqual({ + path: '/client/settings', + init: { method: 'PATCH', body: '{"values":{}}' }, + }); + }); + + it('builds a POST probe-attachment request', () => { + expect(buildProbeAttachmentRequest('sha256:abc')).toEqual({ + path: '/client/attachments/probe', + init: { method: 'POST', body: '{"hash":"sha256:abc"}' }, + }); + }); + + it('builds a probe-attachment request for an empty hash', () => { + expect(buildProbeAttachmentRequest('')).toEqual({ + path: '/client/attachments/probe', + init: { method: 'POST', body: '{"hash":""}' }, + }); + }); + + it('builds an upload request as path + FormData with no init key', () => { + const file = new File(['data'], 'notes.txt', { type: 'text/plain' }); + const request = buildUploadAttachmentRequest(file, 'sha256:def'); + + expect(request.path).toBe('/client/attachments'); + expect(Object.keys(request).sort()).toEqual(['formData', 'path']); + expect('init' in request).toBe(false); + expect(request.formData.get('file')).toBe(file); + expect(request.formData.get('hash')).toBe('sha256:def'); + expect(request.formData.get('original_name')).toBe('notes.txt'); + }); + + it('preserves empty and unicode file names in original_name', () => { + const emptyNamedFile = new File([], ''); + const unicodeNamedFile = new File(['x'], '报告 (最终).pdf'); + + expect( + buildUploadAttachmentRequest(emptyNamedFile, 'h1').formData.get('original_name'), + ).toBe(''); + expect( + buildUploadAttachmentRequest(unicodeNamedFile, 'h2').formData.get('original_name'), + ).toBe('报告 (最终).pdf'); + }); + + it('builds a POST create-execution-target request', () => { + const body = { name: 'edge-a', target_type: 'docker' }; + expect(buildCreateExecutionTargetRequest(body)).toEqual({ + path: '/web/execution-targets', + init: { method: 'POST', body: JSON.stringify(body) }, + }); + }); + + it('stringifies scalar and null bodies', () => { + expect(buildCreateExecutionTargetRequest(null).init.body).toBe('null'); + expect(buildCreateExecutionTargetRequest(0).init.body).toBe('0'); + expect(buildCreateExecutionTargetRequest('hello').init.body).toBe('"hello"'); + }); + + it('keeps an undefined body key with undefined value (JSON.stringify quirk)', () => { + const request = buildCreateExecutionTargetRequest(undefined); + expect(request.path).toBe('/web/execution-targets'); + expect(request.init.method).toBe('POST'); + expect(request.init.body).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(request.init, 'body')).toBe(true); + }); + + it('drops undefined nested fields from JSON bodies', () => { + expect(buildCreateExecutionTargetRequest({ a: 1, b: undefined }).init.body).toBe( + '{"a":1}', + ); + }); + + it('builds a PATCH update-execution-target request with encoded id', () => { + const body = { name: 'renamed' }; + expect(buildUpdateExecutionTargetRequest('et/1', body)).toEqual({ + path: '/web/execution-targets/et%2F1', + init: { method: 'PATCH', body: JSON.stringify(body) }, + }); + }); + + it('builds a POST ping request with only a method init', () => { + const request = buildPingExecutionTargetRequest('et/1'); + expect(request).toEqual({ + path: '/web/execution-targets/et%2F1/ping', + init: { method: 'POST' }, + }); + expect(Object.keys(request.init)).toEqual(['method']); + }); + + it('builds a POST create-relay-command request', () => { + const body = { command: 'echo', args: ['hi'] }; + expect(buildCreateRelayCommandRequest(body)).toEqual({ + path: '/web/relay/commands', + init: { method: 'POST', body: JSON.stringify(body) }, + }); + }); + + it('builds a POST create-custom-agent request', () => { + const body = { name: 'Agent X', description: 'does "things"' }; + expect(buildCreateCustomAgentRequest(body)).toEqual({ + path: '/web/custom-agents', + init: { method: 'POST', body: JSON.stringify(body) }, + }); + }); + + it('builds a PUT update-custom-agent request with encoded id', () => { + const body = { name: 'Agent Y' }; + expect(buildUpdateCustomAgentRequest('agent/1', body)).toEqual({ + path: '/web/custom-agents/agent%2F1', + init: { method: 'PUT', body: JSON.stringify(body) }, + }); + }); + + it('builds a POST create-workspace-project request', () => { + const data = { name: 'My Project' }; + expect(buildCreateWorkspaceProjectRequest(data)).toEqual({ + path: '/web/projects', + init: { method: 'POST', body: JSON.stringify(data) }, + }); + }); + + it('passes undefined workspace-project data through as an undefined body', () => { + const request = buildCreateWorkspaceProjectRequest(undefined); + expect(request.path).toBe('/web/projects'); + expect(request.init.body).toBeUndefined(); + }); + + it('builds a PATCH update-workspace-project request with encoded id', () => { + const data = { name: 'Renamed Project' }; + expect(buildUpdateWorkspaceProjectRequest('proj/7', data)).toEqual({ + path: '/web/projects/proj%2F7', + init: { method: 'PATCH', body: JSON.stringify(data) }, + }); + }); + + it('builds a POST create-thread request with encoded project id', () => { + const data = { title: 'First thread' }; + expect(buildCreateWorkspaceProjectThreadRequest('proj/7', data)).toEqual({ + path: '/web/projects/proj%2F7/threads', + init: { method: 'POST', body: JSON.stringify(data) }, + }); + }); + + it('builds a POST send-message request with encoded project and thread ids', () => { + const data = { content: 'hi', role: 'user' }; + expect(buildSendWorkspaceProjectThreadMessageRequest('proj/7', 'thr/3', data)).toEqual({ + path: '/web/projects/proj%2F7/threads/thr%2F3/messages', + init: { method: 'POST', body: JSON.stringify(data) }, + }); + }); + + it('encodes unicode ids in workspace thread paths', () => { + expect( + buildSendWorkspaceProjectThreadMessageRequest('项目 A', '线程 1', {}), + ).toEqual({ + path: '/web/projects/%E9%A1%B9%E7%9B%AE%20A/threads/%E7%BA%BF%E7%A8%8B%201/messages', + init: { method: 'POST', body: '{}' }, + }); + }); + + it('builds a DELETE execution-target request with only a method init', () => { + const request = buildDeleteExecutionTargetRequest('et/9'); + expect(request).toEqual({ + path: '/web/execution-targets/et%2F9', + init: { method: 'DELETE' }, + }); + expect(Object.keys(request.init)).toEqual(['method']); + }); + + it('builds a POST ack-relay-command request with encoded id', () => { + const request = buildAckRelayCommandRequest('cmd/5'); + expect(request).toEqual({ + path: '/web/relay/commands/cmd%2F5/ack', + init: { method: 'POST' }, + }); + expect(Object.keys(request.init)).toEqual(['method']); + }); + + it('builds a DELETE custom-agent request with only a method init', () => { + const request = buildDeleteCustomAgentRequest('agent/9'); + expect(request).toEqual({ + path: '/web/custom-agents/agent%2F9', + init: { method: 'DELETE' }, + }); + expect(Object.keys(request.init)).toEqual(['method']); + }); + + it('accepts empty string ids without throwing', () => { + expect(buildPingExecutionTargetRequest('')).toEqual({ + path: '/web/execution-targets//ping', + init: { method: 'POST' }, + }); + expect(buildDeleteCustomAgentRequest('')).toEqual({ + path: '/web/custom-agents/', + init: { method: 'DELETE' }, + }); + }); + + it('escapes special characters in JSON bodies', () => { + expect(buildUpdateWorkspaceProjectRequest('p1', { name: 'a"b\\c\n' }).init.body).toBe( + '{"name":"a\\"b\\\\c\\n"}', + ); + }); +});