diff --git a/package.json b/package.json index 4ca64d2..ade9ab4 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "types": "./build/bin/qasphere.d.ts", "scripts": { "test": "vitest run", + "test:api-manifest": "vitest run src/tests/api-manifest.spec.ts", + "test:e2e:api": "vitest run src/tests/api.e2e.spec.ts", "test:watch": "vitest", "build": "npm run clean && tsc && ts-add-js-extension --dir=./build && chmod +x build/bin/qasphere.js", "clean": "rm -rf ./build", diff --git a/src/api/publicApi.ts b/src/api/publicApi.ts new file mode 100644 index 0000000..3ed88a4 --- /dev/null +++ b/src/api/publicApi.ts @@ -0,0 +1,96 @@ +import { readFile } from 'node:fs/promises' +import { basename } from 'node:path' + +import { CLI_VERSION } from '../utils/version' +import { withBaseUrl, withHeaders, withHttpRetry } from './utils' + +export interface PublicApiRequest { + method: 'GET' | 'POST' | 'PATCH' + pathname: string + query?: URLSearchParams + jsonBody?: unknown + filePath?: string +} + +const createPublicApiFetcher = (baseUrl: string, apiKey: string) => + withHttpRetry( + withHeaders(withBaseUrl(fetch, baseUrl), { + Authorization: `ApiKey ${apiKey}`, + Accept: 'application/json', + 'User-Agent': `qas-cli/${CLI_VERSION}`, + }) + ) + +const buildErrorFromResponse = async (response: Response) => { + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) { + try { + const body = (await response.json()) as Record + const message = typeof body.message === 'string' ? body.message : response.statusText + throw new Error(message) + } catch (error) { + if (error instanceof Error && error.message !== 'Unexpected end of JSON input') { + throw error + } + } + } + + const text = await response.text() + throw new Error(text || response.statusText || `Request failed with status ${response.status}`) +} + +const parseJsonResponse = async (response: Response) => { + if (response.status === 204) { + return null + } + + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.includes('application/json')) { + const text = await response.text() + return text ? JSON.parse(text) : null + } + + const text = await response.text() + return text ? JSON.parse(text) : null +} + +export const executePublicApiRequest = async ( + baseUrl: string, + apiKey: string, + request: PublicApiRequest +) => { + const fetcher = createPublicApiFetcher(baseUrl, apiKey) + const query = request.query?.size ? `?${request.query.toString()}` : '' + const url = `${request.pathname}${query}` + + if (request.filePath) { + const fileBuffer = await readFile(request.filePath) + const formData = new FormData() + formData.append('file', new Blob([fileBuffer]), basename(request.filePath)) + const response = await fetcher(url, { + method: request.method, + body: formData, + }) + if (!response.ok) { + return buildErrorFromResponse(response) + } + return parseJsonResponse(response) + } + + const response = await fetcher(url, { + method: request.method, + headers: + request.jsonBody === undefined + ? undefined + : { + 'Content-Type': 'application/json', + }, + body: request.jsonBody === undefined ? undefined : JSON.stringify(request.jsonBody), + }) + + if (!response.ok) { + return buildErrorFromResponse(response) + } + + return parseJsonResponse(response) +} diff --git a/src/commands/api/executor.ts b/src/commands/api/executor.ts new file mode 100644 index 0000000..049d0a4 --- /dev/null +++ b/src/commands/api/executor.ts @@ -0,0 +1,129 @@ +import { Readable } from 'node:stream' + +import { executePublicApiRequest } from '../../api/publicApi' +import { loadEnvs } from '../../utils/env' +import { + ApiValidationError, + collectQueryInput, + buildPathname, + loadJsonBodyInput, + parseIntegerValue, + serializeQueryObject, + validateBodyMode, + validateWithSchema, +} from './helpers' +import { ApiEndpointSpec, ApiOptionSpec } from './types' + +export interface ExecuteApiCommandOptions { + stdin?: Readable + baseUrl?: string + apiKey?: string +} + +const normalizeOptionValue = (option: ApiOptionSpec, value: unknown) => { + if (option.type !== 'integer') { + return value + } + + if (option.array) { + const items = Array.isArray(value) ? value : [value] + return items.map((item, index) => parseIntegerValue(`--${option.name}[${index}]`, item)) + } + + return parseIntegerValue(`--${option.name}`, value) +} + +const collectValidatedQuery = (spec: ApiEndpointSpec, args: Record) => { + const query = collectQueryInput(args, spec.queryOptions, spec.supportsCustomFieldFilters) + const normalizedQuery = Object.fromEntries( + Object.entries(query).map(([key, value]) => { + const option = spec.queryOptions?.find((item) => item.name === key) + return [key, option ? normalizeOptionValue(option, value) : value] + }) + ) + const queryInput = spec.queryAdapter ? spec.queryAdapter(normalizedQuery) : normalizedQuery + return validateWithSchema>('Query', spec.querySchema, queryInput) +} + +const collectPathParams = (spec: ApiEndpointSpec, args: Record) => { + return Object.fromEntries( + spec.pathParams.map((param) => { + const rawValue = args[param.name] + if (param.type === 'integer') { + return [param.name, parseIntegerValue(`<${param.name}>`, rawValue)] + } + if (typeof rawValue !== 'string' || rawValue.length === 0) { + throw new ApiValidationError(`<${param.name}> is required.`) + } + return [param.name, rawValue] + }) + ) +} + +const collectBody = async ( + spec: ApiEndpointSpec, + args: Record, + stdin: Readable +) => { + if (spec.bodyMode === 'none') { + validateBodyMode(spec.bodyMode, args) + return undefined + } + + if (spec.bodyMode === 'file') { + validateBodyMode('none', args) + if (typeof args.file !== 'string' || args.file.length === 0) { + throw new ApiValidationError('--file is required for this command.') + } + return { filePath: args.file } + } + + validateBodyMode('json', args) + const rawBody = await loadJsonBodyInput(args, stdin) + const normalizedBody = spec.bodyAdapter ? spec.bodyAdapter(rawBody) : rawBody + const body = validateWithSchema('Request body', spec.bodySchema, normalizedBody) + return { jsonBody: body } +} + +export const buildApiRequestFromArgs = async ( + spec: ApiEndpointSpec, + args: Record, + stdin: Readable = process.stdin +) => { + const pathParams = collectPathParams(spec, args) + const query = collectValidatedQuery(spec, args) + const body = await collectBody(spec, args, stdin) + + return { + method: spec.method, + pathname: buildPathname(spec.pathTemplate, pathParams), + query: serializeQueryObject(query), + ...body, + } +} + +export const executeApiCommand = async ( + spec: ApiEndpointSpec, + args: Record, + options?: ExecuteApiCommandOptions +) => { + loadEnvs() + const request = await buildApiRequestFromArgs(spec, args, options?.stdin) + const baseUrl = options?.baseUrl ?? process.env.QAS_URL + const apiKey = options?.apiKey ?? process.env.QAS_TOKEN + + if (!baseUrl || !apiKey) { + throw new ApiValidationError('QAS_URL and QAS_TOKEN are required.') + } + + try { + new URL(baseUrl) + } catch { + throw new ApiValidationError( + 'QAS_URL must be a valid absolute URL, for example https://qas.eu1.qasphere.com' + ) + } + + const response = await executePublicApiRequest(baseUrl, apiKey, request) + return response +} diff --git a/src/commands/api/helpers.ts b/src/commands/api/helpers.ts new file mode 100644 index 0000000..18c814d --- /dev/null +++ b/src/commands/api/helpers.ts @@ -0,0 +1,251 @@ +import { createReadStream } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { basename } from 'node:path' +import { Readable } from 'node:stream' +import { ZodError, ZodTypeAny } from 'zod' + +import { ApiEndpointSpec, ApiOptionSpec } from './types' + +export interface JsonBodyInputArgs { + body?: string + bodyFile?: string + bodyStdin?: boolean +} + +export class ApiValidationError extends Error {} + +const BODY_SOURCE_FLAGS = ['body', 'bodyFile', 'bodyStdin'] as const + +export const validateBodyMode = ( + bodyMode: ApiEndpointSpec['bodyMode'], + args: JsonBodyInputArgs +) => { + const usedSources = BODY_SOURCE_FLAGS.filter((key) => { + if (key === 'bodyStdin') { + return args[key] === true + } + return typeof args[key] === 'string' + }) + + if (bodyMode === 'none') { + if (usedSources.length > 0) { + throw new ApiValidationError( + 'JSON body flags are not supported for this command: use only the documented positional args and query flags.' + ) + } + return + } + + if (bodyMode === 'json') { + if (usedSources.length === 0) { + throw new ApiValidationError( + 'Exactly one of --body, --body-file, or --body-stdin is required.' + ) + } + if (usedSources.length > 1) { + throw new ApiValidationError( + 'Only one of --body, --body-file, or --body-stdin may be provided.' + ) + } + } +} + +const formatJsonParseError = (source: string, error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + return `Invalid JSON from ${source}: ${message}` +} + +const readStreamText = async (stream: Readable) => { + const chunks: Buffer[] = [] + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + } + return Buffer.concat(chunks).toString('utf8') +} + +export const loadJsonBodyInput = async ( + args: JsonBodyInputArgs, + stdin: Readable = process.stdin +): Promise => { + if (typeof args.body === 'string') { + try { + return JSON.parse(args.body) + } catch (error) { + throw new ApiValidationError(formatJsonParseError('--body', error)) + } + } + + if (typeof args.bodyFile === 'string') { + const filePath = args.bodyFile + try { + const raw = await readFile(filePath, 'utf8') + return JSON.parse(raw) + } catch (error) { + throw new ApiValidationError(formatJsonParseError(`--body-file ${filePath}`, error)) + } + } + + if (args.bodyStdin) { + try { + const raw = await readStreamText(stdin) + return JSON.parse(raw) + } catch (error) { + throw new ApiValidationError(formatJsonParseError('--body-stdin', error)) + } + } + + throw new ApiValidationError('Missing JSON body source.') +} + +const formatZodIssuePath = (path: Array) => { + if (path.length === 0) { + return '$' + } + return path + .map((part) => (typeof part === 'number' ? `[${part}]` : part)) + .join('.') + .replace('.[', '[') +} + +export const formatZodError = (label: string, error: ZodError) => { + const lines = error.issues.map((issue) => `${formatZodIssuePath(issue.path)}: ${issue.message}`) + return `${label} validation failed:\n${lines.join('\n')}` +} + +export const validateWithSchema = ( + label: string, + schema: ZodTypeAny | undefined, + value: unknown +): T => { + if (!schema) { + return value as T + } + const result = schema.safeParse(value) + if (!result.success) { + throw new ApiValidationError(formatZodError(label, result.error)) + } + return result.data as T +} + +export const applyNullDefaults = ( + value: Record, + keys: string[] | undefined +): Record => { + if (!keys || keys.length === 0) { + return value + } + const next = { ...value } + for (const key of keys) { + if (!(key in next)) { + next[key] = null + } + } + return next +} + +export const parseCustomFieldFilters = (values: unknown): Record => { + const filters = Array.isArray(values) ? values : values === undefined ? [] : [values] + const parsed: Record = {} + + for (const rawValue of filters) { + if (typeof rawValue !== 'string') { + throw new ApiValidationError('Each --cf value must be in key=value format.') + } + const eqIndex = rawValue.indexOf('=') + if (eqIndex <= 0 || eqIndex === rawValue.length - 1) { + throw new ApiValidationError('--cf must be exactly key=value with non-empty key and value.') + } + const key = rawValue.slice(0, eqIndex) + const value = rawValue.slice(eqIndex + 1) + if (!parsed[key]) { + parsed[key] = [] + } + parsed[key].push(value) + } + + return parsed +} + +export const collectQueryInput = ( + args: Record, + queryOptions: ApiOptionSpec[] | undefined, + supportsCustomFieldFilters: boolean | undefined +) => { + const query: Record = {} + + for (const option of queryOptions ?? []) { + const value = args[option.name] + if (value !== undefined) { + query[option.name] = value + } + } + + if (supportsCustomFieldFilters && args.cf !== undefined) { + query.customFields = parseCustomFieldFilters(args.cf) + } + + return query +} + +export const serializeQueryObject = (query: Record) => { + const searchParams = new URLSearchParams() + + for (const [key, rawValue] of Object.entries(query)) { + if (rawValue === undefined || rawValue === null) { + continue + } + + if (key === 'customFields' && typeof rawValue === 'object' && rawValue) { + for (const [fieldName, values] of Object.entries(rawValue as Record)) { + const items = Array.isArray(values) ? values : [values] + for (const item of items) { + if (item !== undefined && item !== null) { + searchParams.append(`cf_${fieldName}`, String(item)) + } + } + } + continue + } + + if (Array.isArray(rawValue)) { + for (const item of rawValue) { + if (item !== undefined && item !== null) { + searchParams.append(key, String(item)) + } + } + continue + } + + if (rawValue instanceof Date) { + searchParams.set(key, rawValue.toISOString()) + continue + } + + searchParams.set(key, String(rawValue)) + } + + return searchParams +} + +export const buildPathname = (template: string, pathParams: Record) => + template.replace(/\{([^}]+)\}/g, (_, key: string) => encodeURIComponent(String(pathParams[key]))) + +export const parseIntegerValue = (label: string, value: unknown) => { + if (typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value)) { + return value + } + if (typeof value === 'string' && /^-?\d+$/.test(value)) { + return Number(value) + } + throw new ApiValidationError(`${label} must be a finite integer.`) +} + +export const buildUploadFile = async (filePath: string) => { + const fileBuffer = await readFile(filePath) + return { + formField: 'file', + fileName: basename(filePath), + blob: new Blob([fileBuffer]), + stream: createReadStream(filePath), + } +} diff --git a/src/commands/api/index.ts b/src/commands/api/index.ts new file mode 100644 index 0000000..5baef05 --- /dev/null +++ b/src/commands/api/index.ts @@ -0,0 +1,167 @@ +import { Arguments, Argv, CommandModule } from 'yargs' + +import { executeApiCommand } from './executor' +import { apiEndpointSpecs } from './manifest' +import { ApiEndpointSpec } from './types' + +type ApiCommandArgs = Record & { + body?: string + bodyFile?: string + bodyStdin?: boolean + file?: string + cf?: string[] +} + +interface ApiCommandTreeNode { + segment?: string + spec?: ApiEndpointSpec + children: Map +} + +const buildCommandTree = (specs: ApiEndpointSpec[]) => { + const root: ApiCommandTreeNode = { + segment: 'api', + children: new Map(), + } + + for (const spec of specs) { + let current = root + for (const segment of spec.commandPath) { + let next = current.children.get(segment) + if (!next) { + next = { + segment, + children: new Map(), + } + current.children.set(segment, next) + } + current = next + } + current.spec = spec + } + + return root +} + +class ApiEndpointCommandModule implements CommandModule { + constructor( + private readonly segment: string, + private readonly spec: ApiEndpointSpec + ) {} + + get command() { + const pathParams = this.spec.pathParams.map((param) => `<${param.name}>`).join(' ') + return [this.segment, pathParams].filter(Boolean).join(' ') + } + + get describe() { + return this.spec.describe + } + + builder = (argv: Argv) => { + for (const param of this.spec.pathParams) { + argv.positional(param.name, { + describe: param.describe, + type: param.type === 'integer' ? 'number' : 'string', + }) + } + + for (const queryOption of this.spec.queryOptions ?? []) { + argv.option(queryOption.name, { + describe: queryOption.describe, + type: queryOption.type === 'integer' ? 'number' : queryOption.type, + array: queryOption.array, + choices: queryOption.choices, + }) + } + + if (this.spec.supportsCustomFieldFilters) { + argv.option('cf', { + describe: 'Custom field filter in systemName=value format. Repeat to add more values.', + type: 'string', + array: true, + }) + } + + if (this.spec.bodyMode === 'json') { + argv + .option('body', { + describe: 'Inline JSON request body', + type: 'string', + }) + .option('body-file', { + describe: 'Load JSON request body from a file', + type: 'string', + }) + .option('body-stdin', { + describe: 'Load JSON request body from stdin', + type: 'boolean', + default: false, + }) + } + + if (this.spec.bodyMode === 'file') { + argv.option('file', { + describe: 'Path to the file to upload', + type: 'string', + }) + } + + return argv + } + + handler = async (args: Arguments) => { + const response = await executeApiCommand(this.spec, args) + process.stdout.write(`${JSON.stringify(response ?? null, null, 2)}\n`) + } +} + +class ApiCommandNodeModule implements CommandModule { + constructor( + private readonly node: ApiCommandTreeNode, + private readonly isRoot = false + ) {} + + get command() { + return this.node.segment ?? 'api' + } + + get describe() { + return this.isRoot + ? 'Call QA Sphere public API endpoints' + : `QA Sphere ${this.command} commands` + } + + builder = (argv: Argv) => { + for (const child of this.node.children.values()) { + if (child.spec && child.children.size === 0) { + argv.command(new ApiEndpointCommandModule(child.segment!, child.spec)) + continue + } + argv.command(new ApiCommandNodeModule(child)) + } + + if (this.isRoot) { + argv.epilogue( + [ + 'JSON body input:', + ' Use exactly one of --body, --body-file, or --body-stdin for commands that require JSON.', + 'Custom field filters:', + ' Repeat --cf systemName=value to add dynamic custom-field query filters.', + ].join('\n') + ) + } + + return argv.demandCommand(1) + } + + handler = async () => {} +} + +const apiCommandTree = buildCommandTree(apiEndpointSpecs) + +export class ApiCommandModule extends ApiCommandNodeModule { + constructor() { + super(apiCommandTree, true) + } +} diff --git a/src/commands/api/manifest.ts b/src/commands/api/manifest.ts new file mode 100644 index 0000000..f25e03a --- /dev/null +++ b/src/commands/api/manifest.ts @@ -0,0 +1,798 @@ +import { z } from 'zod' + +import { + BulkUpsertFoldersRequestSchema, + BulkUpsertFoldersResponseSchema, + CreateMilestonePublicRequestSchema, + CreatePlanRequestSchema, + CreateProjectRequestSchema, + CreateResultRequestSchema, + CreateResultsRequestSchema, + CreateRunRequestSchema, + CreateTCaseRequestSchema, + CreateTCaseResponseSchema, + CloneRunRequestSchema, + FullTCaseSchema, + GetCustomFieldsResponseSchema, + GetMilestonesRequestSchema, + GetPaginatedFolderResponseSchema, + GetPaginatedTCaseRequestSchema, + GetPaginatedTCaseResponseSchema, + GetPublicApiMilestonesResponseSchema, + GetPublicPaginatedFolderRequestSchema, + GetPublicAuditLogsRequestSchema, + GetPublicAuditLogsResponseSchema, + GetPublicProjectsResponseSchema, + GetPublicUsersListResponseSchema, + GetRequirementsRequestSchema, + GetRequirementsResponseSchema, + GetRunsResponseSchema, + GetRunTCasesResponseSchema, + GetSharedPreconditionsRequestSchema, + GetSharedStepsRequestSchema, + GetSharedStepsResponseSchema, + GetStatusesResponseSchema, + GetTagsRequestSchema, + GetTagsResponseSchema, + GetTCasesCountRequestSchema, + GetTCasesCountResponseSchema, + IDResponseSchema, + IDsResponseSchema, + ListRunTCasesRequestSchema, + ListRunsRequestSchema, + MessageResponseSchema, + PreconditionSchema, + PublicProjectSchema, + RunTCaseSchema, + StepSchema, + UploadFileResponseSchema, + UpdateStatusesRequestSchema, + UpdateTCaseRequestSchema, +} from './sharedSchemas' +import { ApiEndpointSpec, ApiOptionSpec } from './types' + +const priorityChoices = ['low', 'medium', 'high'] as const +const sortOrderChoices = ['asc', 'desc'] as const + +const pathParam = ( + name: string, + describe: string, + type: 'string' | 'integer' +): ApiEndpointSpec['pathParams'][number] => ({ + name, + describe, + type, +}) + +const option = ( + name: string, + describe: string, + type: ApiOptionSpec['type'], + config?: Partial +): ApiOptionSpec => ({ + name, + describe, + type, + ...config, +}) + +const asRecord = (value: unknown) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + return value as Record +} + +const withNulls = (value: unknown, keys: string[]) => { + const next = { ...asRecord(value) } + for (const key of keys) { + if (!(key in next)) { + next[key] = null + } + } + return next +} + +const normalizeResultLinks = (value: unknown) => { + const input = asRecord(value) + return withNulls(input, ['links', 'timeTaken']) +} + +const normalizeResultBatch = (value: unknown) => { + const input = withNulls(value, ['items']) + if (!Array.isArray(input.items)) { + return input + } + return { + ...input, + items: input.items.map((item) => normalizeResultLinks(item)), + } +} + +const normalizeCreateProjectBody = (value: unknown) => + withNulls(value, ['links', 'overviewTitle', 'overviewDescription']) + +const normalizeCreateRunBody = (value: unknown) => + withNulls(value, [ + 'description', + 'milestoneId', + 'configurationId', + 'assignmentId', + 'links', + 'integrationLink', + ]) + +const normalizeCloneRunBody = (value: unknown) => { + const input = asRecord(value) + if (!('description' in input)) { + input.description = '' + } + return input +} + +const normalizeCreatePlanBody = (value: unknown) => { + const input = asRecord(value) + if (!('description' in input)) { + input.description = '' + } + return input +} + +const normalizeTCaseSteps = (steps: unknown) => { + if (!Array.isArray(steps)) { + return steps + } + return steps.map((step) => { + const next = asRecord(step) + if (!('description' in next)) { + next.description = '' + } + if (!('expected' in next)) { + next.expected = '' + } + return next + }) +} + +const normalizeCreateTCaseBody = (value: unknown) => { + const input = withNulls(value, [ + 'pos', + 'files', + 'requirements', + 'links', + 'tags', + 'steps', + 'customFields', + 'parameterValues', + ]) + if (!('isDraft' in input)) { + input.isDraft = false + } + input.steps = normalizeTCaseSteps(input.steps) + return input +} + +const normalizeUpdateTCaseBody = (value: unknown) => { + const input = withNulls(value, ['requirements', 'links', 'tags', 'steps', 'files']) + if ('steps' in input) { + input.steps = normalizeTCaseSteps(input.steps) + } + const precondition = asRecord(input.precondition) + if ('id' in precondition && !('sharedPreconditionId' in precondition)) { + precondition.sharedPreconditionId = precondition.id + delete precondition.id + input.precondition = precondition + } + return input +} + +const normalizeNullableQuery = (keys: string[]) => (value: Record) => + withNulls(value, keys) + +const SharedPreconditionsListResponseSchema: z.ZodTypeAny = z.array( + PreconditionSchema as z.ZodTypeAny +) + +const projectParam = pathParam('project', 'Project code or ID', 'string') +const runParam = pathParam('run', 'Run ID', 'integer') +const tcaseParam = pathParam('tcase', 'Test case ID, sequence, or legacy ID', 'string') +const numericIdParam = (name: string, describe: string) => pathParam(name, describe, 'integer') + +const commonSortAndIncludeOptions = ( + sortFields: readonly string[], + includeChoices: readonly string[] +): ApiOptionSpec[] => [ + option('sortField', 'Sort field', 'string', { choices: sortFields }), + option('sortOrder', 'Sort direction', 'string', { choices: sortOrderChoices }), + option('include', 'Additional response fields to include', 'string', { + array: true, + choices: includeChoices, + }), +] + +const testCaseFilterOptions: ApiOptionSpec[] = [ + option('page', 'Page number', 'integer'), + option('limit', 'Maximum number of test cases to return', 'integer'), + option('sortField', 'Sort field', 'string', { + choices: [ + 'id', + 'seq', + 'folder_id', + 'author_id', + 'pos', + 'title', + 'priority', + 'created_at', + 'updated_at', + 'legacy_id', + ], + }), + option('sortOrder', 'Sort direction', 'string', { choices: sortOrderChoices }), + option('types', 'Filter by test case type', 'string', { + array: true, + choices: ['standalone', 'template', 'filled'], + }), + option('search', 'Filter by title', 'string'), + option('folders', 'Filter by folder ID', 'integer', { array: true }), + option('tags', 'Filter by tag ID', 'integer', { array: true }), + option('priorities', 'Filter by priority', 'string', { array: true, choices: priorityChoices }), + option('draft', 'Filter by draft status', 'boolean'), + option('templateTCaseIds', 'Filter by template test case ID', 'string', { array: true }), + option('requirementIds', 'Filter by requirement ID', 'string', { array: true }), + option('include', 'Additional response fields to include', 'string', { + array: true, + choices: [ + 'precondition', + 'steps', + 'tags', + 'project', + 'folder', + 'path', + 'requirements', + 'customFields', + 'parameterValues', + ], + }), +] + +const testCaseCountOptions: ApiOptionSpec[] = [ + option('folders', 'Filter by folder ID', 'integer', { array: true }), + option('recursive', 'Include child folders', 'boolean'), + option('tags', 'Filter by tag ID', 'integer', { array: true }), + option('priorities', 'Filter by priority', 'string', { array: true, choices: priorityChoices }), + option('draft', 'Filter by draft status', 'boolean'), +] + +export const apiEndpointSpecs: ApiEndpointSpec[] = [ + { + id: 'projects.list', + commandPath: ['projects', 'list'], + describe: 'List visible projects', + method: 'GET', + pathTemplate: '/api/public/v0/project', + pathParams: [], + bodyMode: 'none', + responseSchema: GetPublicProjectsResponseSchema, + requestSchemaLinks: { response: 'GetPublicProjectsResponseSchema' }, + }, + { + id: 'projects.get', + commandPath: ['projects', 'get'], + describe: 'Get one project by code or ID', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}', + pathParams: [projectParam], + bodyMode: 'none', + responseSchema: PublicProjectSchema, + requestSchemaLinks: { response: 'PublicProjectSchema' }, + }, + { + id: 'projects.create', + commandPath: ['projects', 'create'], + describe: 'Create a project', + method: 'POST', + pathTemplate: '/api/public/v0/project', + pathParams: [], + bodyMode: 'json', + bodySchema: CreateProjectRequestSchema, + bodyAdapter: normalizeCreateProjectBody, + responseSchema: IDResponseSchema, + requestSchemaLinks: { + body: 'CreateProjectRequestSchema', + response: 'IDResponseSchema', + }, + }, + { + id: 'folders.list', + commandPath: ['folders', 'list'], + describe: 'List project folders', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/tcase/folders', + pathParams: [projectParam], + bodyMode: 'none', + queryOptions: [ + option('page', 'Page number', 'integer'), + option('limit', 'Maximum number of folders to return', 'integer'), + option('sortField', 'Sort field', 'string', { + choices: ['id', 'project_id', 'title', 'pos', 'parent_id', 'created_at', 'updated_at'], + }), + option('sortOrder', 'Sort direction', 'string', { choices: sortOrderChoices }), + ], + querySchema: GetPublicPaginatedFolderRequestSchema, + responseSchema: GetPaginatedFolderResponseSchema, + requestSchemaLinks: { + query: 'GetPublicPaginatedFolderRequestSchema', + response: 'GetPaginatedFolderResponseSchema', + }, + }, + { + id: 'folders.upsert', + commandPath: ['folders', 'upsert'], + describe: 'Bulk upsert project folders', + method: 'POST', + pathTemplate: '/api/public/v0/project/{project}/tcase/folder/bulk', + pathParams: [projectParam], + bodyMode: 'json', + bodySchema: BulkUpsertFoldersRequestSchema, + bodyAdapter: (value) => withNulls(value, ['folders']), + responseSchema: BulkUpsertFoldersResponseSchema, + requestSchemaLinks: { + body: 'BulkUpsertFoldersRequestSchema', + response: 'BulkUpsertFoldersResponseSchema', + }, + }, + { + id: 'milestones.list', + commandPath: ['milestones', 'list'], + describe: 'List project milestones', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/milestone', + pathParams: [projectParam], + bodyMode: 'none', + queryOptions: [option('archived', 'Filter by archived status', 'boolean')], + querySchema: GetMilestonesRequestSchema, + queryAdapter: normalizeNullableQuery(['archived']), + responseSchema: GetPublicApiMilestonesResponseSchema, + requestSchemaLinks: { + query: 'GetMilestonesRequestSchema', + response: 'GetPublicApiMilestonesResponseSchema', + }, + }, + { + id: 'milestones.create', + commandPath: ['milestones', 'create'], + describe: 'Create a milestone', + method: 'POST', + pathTemplate: '/api/public/v0/project/{project}/milestone', + pathParams: [projectParam], + bodyMode: 'json', + bodySchema: CreateMilestonePublicRequestSchema, + responseSchema: IDResponseSchema, + requestSchemaLinks: { + body: 'CreateMilestonePublicRequestSchema', + response: 'IDResponseSchema', + }, + }, + { + id: 'plans.create', + commandPath: ['plans', 'create'], + describe: 'Create a test plan', + method: 'POST', + pathTemplate: '/api/public/v0/project/{project}/plan', + pathParams: [projectParam], + bodyMode: 'json', + bodySchema: CreatePlanRequestSchema, + bodyAdapter: normalizeCreatePlanBody, + responseSchema: IDResponseSchema, + requestSchemaLinks: { + body: 'CreatePlanRequestSchema', + response: 'IDResponseSchema', + }, + }, + { + id: 'requirements.list', + commandPath: ['requirements', 'list'], + describe: 'List project requirements', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/requirement', + pathParams: [projectParam], + bodyMode: 'none', + queryOptions: commonSortAndIncludeOptions(['created_at', 'text'], ['tcaseCount']), + querySchema: GetRequirementsRequestSchema, + responseSchema: GetRequirementsResponseSchema, + requestSchemaLinks: { + query: 'GetRequirementsRequestSchema', + response: 'GetRequirementsResponseSchema', + }, + }, + { + id: 'results.add', + commandPath: ['results', 'add'], + describe: 'Add a result to one run test case', + method: 'POST', + pathTemplate: '/api/public/v0/project/{project}/run/{run}/tcase/{tcase}/result', + pathParams: [projectParam, runParam, tcaseParam], + bodyMode: 'json', + bodySchema: CreateResultRequestSchema, + bodyAdapter: normalizeResultLinks, + responseSchema: IDResponseSchema, + requestSchemaLinks: { + body: 'CreateResultRequestSchema', + response: 'IDResponseSchema', + }, + }, + { + id: 'results.add-batch', + commandPath: ['results', 'add-batch'], + describe: 'Add multiple results to a run', + method: 'POST', + pathTemplate: '/api/public/v0/project/{project}/run/{run}/result/batch', + pathParams: [projectParam, runParam], + bodyMode: 'json', + bodySchema: CreateResultsRequestSchema, + bodyAdapter: normalizeResultBatch, + responseSchema: IDsResponseSchema, + requestSchemaLinks: { + body: 'CreateResultsRequestSchema', + response: 'IDsResponseSchema', + }, + }, + { + id: 'runs.list', + commandPath: ['runs', 'list'], + describe: 'List project runs', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/run', + pathParams: [projectParam], + bodyMode: 'none', + queryOptions: [ + option('closed', 'Filter by closed status', 'boolean'), + option('milestoneIds', 'Filter by milestone ID', 'integer', { array: true }), + option('limit', 'Maximum number of runs to return', 'integer'), + ], + querySchema: ListRunsRequestSchema, + queryAdapter: normalizeNullableQuery(['limit', 'closed', 'milestoneIds']), + responseSchema: GetRunsResponseSchema, + requestSchemaLinks: { + query: 'ListRunsRequestSchema', + response: 'GetRunsResponseSchema', + }, + }, + { + id: 'runs.create', + commandPath: ['runs', 'create'], + describe: 'Create a run', + method: 'POST', + pathTemplate: '/api/public/v0/project/{project}/run', + pathParams: [projectParam], + bodyMode: 'json', + bodySchema: CreateRunRequestSchema, + bodyAdapter: normalizeCreateRunBody, + responseSchema: IDResponseSchema, + requestSchemaLinks: { + body: 'CreateRunRequestSchema', + response: 'IDResponseSchema', + }, + }, + { + id: 'runs.clone', + commandPath: ['runs', 'clone'], + describe: 'Clone an existing run', + method: 'POST', + pathTemplate: '/api/public/v0/project/{project}/run/clone', + pathParams: [projectParam], + bodyMode: 'json', + bodySchema: CloneRunRequestSchema, + bodyAdapter: normalizeCloneRunBody, + responseSchema: IDResponseSchema, + requestSchemaLinks: { + body: 'CloneRunRequestSchema', + response: 'IDResponseSchema', + }, + }, + { + id: 'runs.close', + commandPath: ['runs', 'close'], + describe: 'Close a run', + method: 'POST', + pathTemplate: '/api/public/v0/project/{project}/run/{run}/close', + pathParams: [projectParam, runParam], + bodyMode: 'none', + responseSchema: MessageResponseSchema.optional(), + requestSchemaLinks: { response: 'MessageResponseSchema' }, + }, + { + id: 'runs.list-tcases', + commandPath: ['runs', 'list-tcases'], + describe: 'List run test cases', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/run/{run}/tcase', + pathParams: [projectParam, runParam], + bodyMode: 'none', + queryOptions: [ + option('search', 'Filter by title', 'string'), + option('tags', 'Filter by tag ID', 'integer', { array: true }), + option('priorities', 'Filter by priority', 'string', { + array: true, + choices: priorityChoices, + }), + option('include', 'Additional response fields to include', 'string', { + array: true, + choices: ['folder'], + }), + ], + supportsCustomFieldFilters: true, + querySchema: ListRunTCasesRequestSchema, + queryAdapter: normalizeNullableQuery([ + 'tags', + 'tagsFilterOp', + 'priorities', + 'search', + 'customFields', + 'include', + ]), + responseSchema: GetRunTCasesResponseSchema, + requestSchemaLinks: { + query: 'ListRunTCasesRequestSchema', + response: 'GetRunTCasesResponseSchema', + }, + }, + { + id: 'runs.get-tcase', + commandPath: ['runs', 'get-tcase'], + describe: 'Get one run test case', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/run/{run}/tcase/{tcase}', + pathParams: [projectParam, runParam, tcaseParam], + bodyMode: 'none', + responseSchema: RunTCaseSchema, + requestSchemaLinks: { response: 'RunTCaseSchema' }, + }, + { + id: 'settings.statuses.get', + commandPath: ['settings', 'statuses', 'get'], + describe: 'Get result status configuration', + method: 'GET', + pathTemplate: '/api/public/v0/settings/preferences/status', + pathParams: [], + bodyMode: 'none', + responseSchema: GetStatusesResponseSchema, + requestSchemaLinks: { response: 'GetStatusesResponseSchema' }, + }, + { + id: 'settings.statuses.update', + commandPath: ['settings', 'statuses', 'update'], + describe: 'Update custom statuses', + method: 'POST', + pathTemplate: '/api/public/v0/settings/preferences/status', + pathParams: [], + bodyMode: 'json', + bodySchema: UpdateStatusesRequestSchema, + responseSchema: MessageResponseSchema, + requestSchemaLinks: { + body: 'UpdateStatusesRequestSchema', + response: 'MessageResponseSchema', + }, + }, + { + id: 'shared-preconditions.list', + commandPath: ['shared-preconditions', 'list'], + describe: 'List shared preconditions', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/shared-precondition', + pathParams: [projectParam], + bodyMode: 'none', + queryOptions: commonSortAndIncludeOptions(['created_at', 'title'], ['tcaseCount']), + querySchema: GetSharedPreconditionsRequestSchema, + responseSchema: SharedPreconditionsListResponseSchema, + requestSchemaLinks: { + query: 'GetSharedPreconditionsRequestSchema', + response: 'PreconditionSchema[]', + }, + }, + { + id: 'shared-preconditions.get', + commandPath: ['shared-preconditions', 'get'], + describe: 'Get one shared precondition', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/shared-precondition/{id}', + pathParams: [projectParam, numericIdParam('id', 'Shared precondition ID')], + bodyMode: 'none', + responseSchema: PreconditionSchema, + requestSchemaLinks: { response: 'PreconditionSchema' }, + }, + { + id: 'shared-steps.list', + commandPath: ['shared-steps', 'list'], + describe: 'List shared steps', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/shared-step', + pathParams: [projectParam], + bodyMode: 'none', + queryOptions: commonSortAndIncludeOptions(['created_at', 'title'], ['tcaseCount']), + querySchema: GetSharedStepsRequestSchema, + responseSchema: GetSharedStepsResponseSchema, + requestSchemaLinks: { + query: 'GetSharedStepsRequestSchema', + response: 'GetSharedStepsResponseSchema', + }, + }, + { + id: 'shared-steps.get', + commandPath: ['shared-steps', 'get'], + describe: 'Get one shared step', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/shared-step/{id}', + pathParams: [projectParam, numericIdParam('id', 'Shared step ID')], + bodyMode: 'none', + responseSchema: StepSchema, + requestSchemaLinks: { response: 'StepSchema' }, + }, + { + id: 'tags.list', + commandPath: ['tags', 'list'], + describe: 'List project tags', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/tag', + pathParams: [projectParam], + bodyMode: 'none', + queryOptions: commonSortAndIncludeOptions(['created_at', 'title'], ['tcaseCount']), + querySchema: GetTagsRequestSchema, + responseSchema: GetTagsResponseSchema, + requestSchemaLinks: { + query: 'GetTagsRequestSchema', + response: 'GetTagsResponseSchema', + }, + }, + { + id: 'testcases.list', + commandPath: ['testcases', 'list'], + describe: 'List project test cases', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/tcase', + pathParams: [projectParam], + bodyMode: 'none', + queryOptions: testCaseFilterOptions, + supportsCustomFieldFilters: true, + querySchema: GetPaginatedTCaseRequestSchema, + queryAdapter: normalizeNullableQuery(['createdAfter', 'createdBefore']), + responseSchema: GetPaginatedTCaseResponseSchema, + requestSchemaLinks: { + query: 'GetPaginatedTCaseRequestSchema', + response: 'GetPaginatedTCaseResponseSchema', + }, + }, + { + id: 'testcases.get', + commandPath: ['testcases', 'get'], + describe: 'Get one test case', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/tcase/{tcase}', + pathParams: [projectParam, tcaseParam], + bodyMode: 'none', + responseSchema: FullTCaseSchema, + requestSchemaLinks: { response: 'FullTCaseSchema' }, + }, + { + id: 'testcases.count', + commandPath: ['testcases', 'count'], + describe: 'Count project test cases', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/tcase/count', + pathParams: [projectParam], + bodyMode: 'none', + queryOptions: testCaseCountOptions, + supportsCustomFieldFilters: true, + querySchema: GetTCasesCountRequestSchema, + queryAdapter: normalizeNullableQuery([ + 'search', + 'folders', + 'tags', + 'tagsFilterOp', + 'priorities', + 'draft', + 'types', + 'templateTCaseIds', + 'preconditionIds', + 'stepIds', + 'requirementIds', + 'authorIds', + 'customFields', + 'createdAfter', + 'createdBefore', + 'recursive', + ]), + responseSchema: GetTCasesCountResponseSchema, + requestSchemaLinks: { + query: 'GetTCasesCountRequestSchema', + response: 'GetTCasesCountResponseSchema', + }, + }, + { + id: 'testcases.create', + commandPath: ['testcases', 'create'], + describe: 'Create a test case', + method: 'POST', + pathTemplate: '/api/public/v0/project/{project}/tcase', + pathParams: [projectParam], + bodyMode: 'json', + bodySchema: CreateTCaseRequestSchema, + bodyAdapter: normalizeCreateTCaseBody, + responseSchema: CreateTCaseResponseSchema, + requestSchemaLinks: { + body: 'CreateTCaseRequestSchema', + response: 'CreateTCaseResponseSchema', + }, + }, + { + id: 'testcases.update', + commandPath: ['testcases', 'update'], + describe: 'Update a test case', + method: 'PATCH', + pathTemplate: '/api/public/v0/project/{project}/tcase/{tcase}', + pathParams: [projectParam, tcaseParam], + bodyMode: 'json', + bodySchema: UpdateTCaseRequestSchema, + bodyAdapter: normalizeUpdateTCaseBody, + responseSchema: MessageResponseSchema, + requestSchemaLinks: { + body: 'UpdateTCaseRequestSchema', + response: 'MessageResponseSchema', + }, + }, + { + id: 'custom-fields.list', + commandPath: ['custom-fields', 'list'], + describe: 'List project custom fields', + method: 'GET', + pathTemplate: '/api/public/v0/project/{project}/custom-field', + pathParams: [projectParam], + bodyMode: 'none', + responseSchema: GetCustomFieldsResponseSchema, + requestSchemaLinks: { response: 'GetCustomFieldsResponseSchema' }, + }, + { + id: 'files.upload', + commandPath: ['files', 'upload'], + describe: 'Upload one file', + method: 'POST', + pathTemplate: '/api/public/v0/file', + pathParams: [], + bodyMode: 'file', + responseSchema: UploadFileResponseSchema, + requestSchemaLinks: { response: 'UploadFileResponseSchema' }, + }, + { + id: 'users.list', + commandPath: ['users', 'list'], + describe: 'List users', + method: 'GET', + pathTemplate: '/api/public/v0/users', + pathParams: [], + bodyMode: 'none', + responseSchema: GetPublicUsersListResponseSchema, + requestSchemaLinks: { response: 'GetPublicUsersListResponseSchema' }, + }, + { + id: 'audit-logs.list', + commandPath: ['audit-logs', 'list'], + describe: 'List audit logs', + method: 'GET', + pathTemplate: '/api/public/v0/audit-logs', + pathParams: [], + bodyMode: 'none', + queryOptions: [ + option('after', 'Pagination cursor', 'integer'), + option('count', 'Number of events to return', 'integer'), + ], + querySchema: GetPublicAuditLogsRequestSchema, + queryAdapter: normalizeNullableQuery(['after', 'count']), + responseSchema: GetPublicAuditLogsResponseSchema, + requestSchemaLinks: { + query: 'GetPublicAuditLogsRequestSchema', + response: 'GetPublicAuditLogsResponseSchema', + }, + }, +] + +export const apiEndpointSpecById = new Map(apiEndpointSpecs.map((spec) => [spec.id, spec])) diff --git a/src/commands/api/sharedSchemas.ts b/src/commands/api/sharedSchemas.ts new file mode 100644 index 0000000..7dcec2e --- /dev/null +++ b/src/commands/api/sharedSchemas.ts @@ -0,0 +1 @@ +export * from './sharedSchemasSubset' diff --git a/src/commands/api/sharedSchemasSubset.ts b/src/commands/api/sharedSchemasSubset.ts new file mode 100644 index 0000000..6cc6fe7 --- /dev/null +++ b/src/commands/api/sharedSchemasSubset.ts @@ -0,0 +1,676 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import { z } from 'zod' + +const sortOrderSchema = z.enum(['asc', 'desc']) +const prioritySchema = z.enum(['low', 'medium', 'high']) +const statusSchema = z.enum([ + 'passed', + 'failed', + 'skipped', + 'open', + 'blocked', + 'custom1', + 'custom2', + 'custom3', + 'custom4', +]) + +const idStringSchema = z.string().min(1) +const dateLikeSchema = z.union([z.string(), z.date()]) + +const LinkWithTextSchema = z.object({ + url: z.string().min(1), + text: z.string().min(1), +}) + +const ResultLinkMetaSchema = z.object({ + id: z.string().optional(), +}) + +const CreateResultLinkRequestSchema = z.object({ + integrationId: z.string().min(1), + url: z.string().min(1), + text: z.string().min(1), + meta: ResultLinkMetaSchema.optional(), +}) + +const PaginationFilterSchema = z.object({ + page: z.number().int().positive().optional(), + limit: z.number().int().positive().optional(), +}) + +const preconditionRequestSchema = z.object({ + sharedPreconditionId: z.number().int().positive().optional(), + text: z.string().optional(), +}) + +const tcaseCustomFieldSchema = z.object({ + value: z.string().optional(), + isDefault: z.boolean().optional(), +}) + +const tcaseStepRequestSchema = z.object({ + sharedStepId: z.number().int().positive().optional(), + description: z.string(), + expected: z.string(), +}) + +const createQueryPlanRequestSchema = z.object({ + tcaseIds: z.string().array().nullable().optional(), + folderIds: z.number().int().array().nullable().optional(), + tagIds: z.number().int().array().nullable().optional(), + priorities: prioritySchema.array().nullable().optional(), +}) + +export const IDResponseSchema = z.object({ + id: z.union([z.number().int(), z.string().min(1)]), +}) + +export const IDsResponseSchema = z.object({ + ids: z.union([z.number().int().array(), z.number().int().array().array()]), +}) + +export const MessageResponseSchema = z.object({ + message: z.string().min(1), +}) + +export const CreateProjectRequestSchema = z.object({ + code: z.string().min(2).max(5), + title: z.string().min(1).max(255), + links: LinkWithTextSchema.array().nullable(), + overviewTitle: z.string().max(255).nullable(), + overviewDescription: z.string().nullable(), + skipDefaultFolder: z.boolean().optional(), + skipDefaultConfigurations: z.boolean().optional(), + skipDefaultAIRules: z.boolean().optional(), +}) + +export const PublicProjectSchema = z.object({ + id: idStringSchema, + code: z.string().min(1), + title: z.string().min(1), + description: z.string(), + overviewTitle: z.string(), + overviewDescription: z.string(), + links: LinkWithTextSchema.array().nullable(), + createdAt: dateLikeSchema, + updatedAt: dateLikeSchema, + archivedAt: dateLikeSchema.nullable(), +}) + +export const GetPublicProjectsResponseSchema = z.object({ + projects: PublicProjectSchema.array().nullable(), +}) + +export const GetPublicPaginatedFolderRequestSchema = PaginationFilterSchema.merge( + z.object({ + sortField: z + .enum(['id', 'project_id', 'title', 'pos', 'parent_id', 'created_at', 'updated_at']) + .optional(), + sortOrder: sortOrderSchema.optional(), + }) +) + +const FolderSchema = z.object({ + id: z.number().int(), + parentId: z.number().int(), + title: z.string().min(1), + comment: z.string(), + projectId: z.string().min(1), + pos: z.number().int(), +}) + +export const GetPaginatedFolderResponseSchema = z.object({ + total: z.number().int(), + page: z.number().int(), + limit: z.number().int(), + data: FolderSchema.array().nullable(), +}) + +const PathWithCommentSchema = z.object({ + path: z.string().min(1).max(255).array(), + comment: z.string().nullable(), +}) + +export const BulkUpsertFoldersRequestSchema = z.object({ + folders: PathWithCommentSchema.array().nullable(), +}) + +export const BulkUpsertFoldersResponseSchema = z.object({ + ids: z.number().int().array().array().nullable(), +}) + +export const GetMilestonesRequestSchema = z.object({ + archived: z.boolean().optional().nullable(), +}) + +export const GetPublicApiMilestonesResponseSchema = z.object({ + milestones: z + .object({ + id: z.number().int(), + title: z.string().min(1), + createdAt: dateLikeSchema, + updatedAt: dateLikeSchema, + archivedAt: dateLikeSchema.nullable(), + archivedBy: z.number().int().nullable().optional(), + }) + .array() + .nullable(), +}) + +export const CreateMilestonePublicRequestSchema = z.object({ + title: z.string().min(1).max(255), +}) + +const CreatePlanRunQueryPlanSchema = z.object({ + tcaseIds: z.string().array(), +}) + +const CreatePlanRunSchema = z.object({ + title: z.string().min(1).max(255), + assignmentId: z.number().int().positive().optional(), + configurationId: z.string().min(1).optional(), + queryPlans: CreatePlanRunQueryPlanSchema.array().length(1), +}) + +export const CreatePlanRequestSchema = z.object({ + title: z.string().min(1).max(255), + description: z.string(), + milestoneId: z.number().int().positive().optional(), + integrationLink: z.string().url().optional(), + runs: CreatePlanRunSchema.array().min(1), +}) + +const sortAndIncludeSchema = ( + sortFields: [string, ...string[]], + includeValues: [string, ...string[]] +) => + z.object({ + sortField: z.enum(sortFields).optional(), + sortOrder: sortOrderSchema.optional(), + include: z.enum(includeValues).array().optional(), + }) + +export const GetRequirementsRequestSchema = sortAndIncludeSchema( + ['created_at', 'text'], + ['tcaseCount'] +) + +export const GetRequirementsResponseSchema = z.object({ + requirements: z + .object({ + id: z.string().min(1), + text: z.string().min(1), + url: z.string(), + tcaseCount: z.number().int().optional(), + integrationLink: z.any().nullable().optional(), + }) + .array() + .nullable(), +}) + +export const CreateResultRequestSchema = z.object({ + status: statusSchema, + comment: z.string(), + links: CreateResultLinkRequestSchema.array().nullable(), + timeTaken: z.number().nullable(), +}) + +export const CreateResultsRequestSchema = z.object({ + items: z + .object({ + tcaseId: z.string().min(1), + status: statusSchema, + comment: z.string(), + links: CreateResultLinkRequestSchema.array().nullable(), + timeTaken: z.number().nullable(), + }) + .array() + .nullable(), +}) + +export const ListRunsRequestSchema = z.object({ + limit: z.number().int().nullable(), + closed: z.boolean().nullable(), + milestoneIds: z.number().int().array().nullable(), + configurationIds: z.string().array().nullable().optional(), +}) + +export const GetRunsResponseSchema = z.object({ + runs: z.array(z.any()).nullable(), + closed: z.number().int(), + open: z.number().int(), +}) + +export const CreateRunRequestSchema = z.object({ + type: z.enum(['static', 'static_struct', 'live']), + title: z.string().min(1).max(255), + description: z.string(), + milestoneId: z.number().int().nullable(), + configurationId: z.string().nullable(), + assignmentId: z.number().int().nullable(), + links: z.string().array().nullable(), + integrationLink: z.string().url().nullable(), + queryPlans: createQueryPlanRequestSchema.array().min(1), +}) + +export const CloneRunRequestSchema = z.object({ + runId: z.number().int().positive(), + title: z.string().min(1).max(255), + description: z.string(), + milestoneId: z.number().int().optional(), + configurationId: z.string().optional(), + assignmentId: z.number().int().optional(), + links: z.string().array().optional(), + integrationLink: z.string().url().or(z.literal('')).optional(), +}) + +export const ListRunTCasesRequestSchema = z.object({ + tags: z.number().int().array().nullable(), + tagsFilterOp: z.enum(['and', 'or', 'not']).nullable(), + priorities: prioritySchema.array().nullable(), + search: z.string().nullable(), + customFields: z.record(z.string().min(1), z.string().array().min(1)).nullable(), + include: z.enum(['folder']).array().nullable(), +}) + +const RunFolderSchema = z.object({ + id: z.number().int(), + parentId: z.number().int(), + title: z.string().min(1), + comment: z.string().optional(), + pos: z.number().int().optional(), +}) + +const RunTCaseOverviewSchema = z.object({ + id: z.string().min(1), + version: z.number().int(), + legacyId: z.string().optional(), + type: z.enum(['standalone', 'filled']).optional(), + folderId: z.number().int(), + pos: z.number().int().optional(), + seq: z.number().int().optional(), + title: z.string().min(1), + priority: z.string(), + status: z.string(), + isAutomated: z.boolean().optional(), + isEmpty: z.boolean().optional(), + templateTCaseId: z.string().nullable().optional(), +}) + +const RunTCaseOverviewWithFolderSchema = RunTCaseOverviewSchema.extend({ + folder: RunFolderSchema.optional(), +}) + +export const GetRunTCasesResponseSchema = z.object({ + tcases: RunTCaseOverviewWithFolderSchema.array().nullable(), +}) + +export const StepSchema = z.lazy(() => + z.object({ + id: z.number().int(), + version: z.number().int(), + type: z.string(), + title: z.string().optional(), + description: z.string().optional(), + expected: z.string().optional(), + subSteps: StepSchema.array().optional(), + isLatest: z.boolean(), + deletedAt: dateLikeSchema.optional(), + tcaseCount: z.number().int().optional(), + }) +) + +export const PreconditionSchema = z.object({ + projectId: z.string().min(1), + id: z.number().int(), + version: z.number().int(), + title: z.string().optional(), + type: z.enum(['standalone', 'shared']), + text: z.string(), + isLatest: z.boolean(), + createdAt: dateLikeSchema, + updatedAt: dateLikeSchema, + deletedAt: dateLikeSchema.optional(), + tcaseCount: z.number().int().optional(), +}) + +export const RunTCaseSchema = RunTCaseOverviewSchema.extend({ + comment: z.string(), + precondition: PreconditionSchema.nullable().optional(), + authorId: z.number().int().optional(), + requirements: z.array(z.any()).nullable().optional(), + links: LinkWithTextSchema.array().nullable().optional(), + files: z.array(z.any()).nullable().optional(), + tags: z.array(z.any()).nullable().optional(), + steps: StepSchema.array().nullable().optional(), + customFields: z.record(z.string(), z.any()).nullable().optional(), + results: z.array(z.any()).nullable().optional(), + createdAt: dateLikeSchema.optional(), + isLatestVersion: z.boolean().optional(), +}) + +const StatusSchema = z.object({ + id: z.enum([ + 'open', + 'passed', + 'blocked', + 'failed', + 'skipped', + 'custom1', + 'custom2', + 'custom3', + 'custom4', + ]), + name: z.string().min(1), + color: z.enum(['blue', 'gray', 'red', 'orange', 'yellow', 'green', 'teal', 'purple', 'pink']), + isDefault: z.boolean(), + isActive: z.boolean(), + inUse: z.boolean().optional(), +}) + +export const GetStatusesResponseSchema = z.object({ + statuses: StatusSchema.array(), +}) + +export const UpdateStatusesRequestSchema = z.object({ + statuses: z + .object({ + id: z.enum([ + 'open', + 'passed', + 'blocked', + 'failed', + 'skipped', + 'custom1', + 'custom2', + 'custom3', + 'custom4', + ]), + name: z.string().min(1).max(16), + color: z.enum(['blue', 'gray', 'red', 'orange', 'yellow', 'green', 'teal', 'purple', 'pink']), + isActive: z.boolean(), + }) + .array() + .min(1), +}) + +export const GetSharedPreconditionsRequestSchema = sortAndIncludeSchema( + ['created_at', 'title'], + ['tcaseCount'] +) + +export const GetSharedStepsRequestSchema = sortAndIncludeSchema( + ['created_at', 'title'], + ['tcaseCount'] +) + +export const GetSharedStepsResponseSchema = z.object({ + sharedSteps: StepSchema.array().nullable(), +}) + +export const GetTagsRequestSchema = sortAndIncludeSchema(['created_at', 'title'], ['tcaseCount']) + +export const GetTagsResponseSchema = z.object({ + tags: z + .object({ + id: z.number().int(), + title: z.string().min(1), + tcaseCount: z.number().int().optional(), + }) + .array() + .nullable(), +}) + +export const GetPaginatedTCaseRequestSchema = PaginationFilterSchema.merge( + z.object({ + sortField: z + .enum([ + 'id', + 'seq', + 'folder_id', + 'author_id', + 'pos', + 'title', + 'priority', + 'created_at', + 'updated_at', + 'legacy_id', + ]) + .optional(), + sortOrder: sortOrderSchema.optional(), + include: z + .enum([ + 'precondition', + 'steps', + 'tags', + 'project', + 'folder', + 'path', + 'requirements', + 'customFields', + 'parameterValues', + ]) + .array() + .optional(), + search: z.string().optional(), + folders: z.number().int().array().optional(), + tags: z.number().int().array().optional(), + tagsFilterOp: z.enum(['and', 'or', 'not']).optional(), + priorities: prioritySchema.array().optional(), + draft: z.boolean().optional(), + types: z.enum(['standalone', 'template', 'filled']).array().optional(), + templateTCaseIds: z.string().array().optional(), + preconditionIds: z.number().int().array().optional(), + stepIds: z.number().int().array().optional(), + requirementIds: z.string().array().optional(), + authorIds: z.number().int().array().optional(), + customFields: z.record(z.string().min(1), z.string().array().min(1)).optional(), + createdAfter: dateLikeSchema.nullable().optional(), + createdBefore: dateLikeSchema.nullable().optional(), + }) +) + +export const FullTCaseSchema = z.object({ + id: z.string().min(1), + version: z.number().int(), + legacyId: z.string().optional(), + seq: z.number().int().optional(), + type: z.enum(['standalone', 'template', 'filled']).optional(), + folderId: z.number().int().optional(), + pos: z.number().int().optional(), + title: z.string().min(1), + priority: prioritySchema.optional(), + comment: z.string().optional(), + authorId: z.number().int().optional(), + files: z.array(z.any()).nullable().optional(), + links: LinkWithTextSchema.array().nullable().optional(), + isDraft: z.boolean().optional(), + isLatestVersion: z.boolean().optional(), + isEmpty: z.boolean().optional(), + numFilledTCases: z.number().int().optional(), + templateTCaseId: z.string().optional().nullable(), + createdAt: dateLikeSchema.optional(), + updatedAt: dateLikeSchema.optional(), + precondition: PreconditionSchema.optional(), + requirements: z.array(z.any()).optional(), + tags: z.array(z.any()).optional(), + steps: StepSchema.array().optional(), + customFields: z.record(z.string(), z.any()).optional(), + parameterValues: z.array(z.any()).optional(), + project: z.any().optional(), + folder: FolderSchema.optional(), + path: FolderSchema.array().optional(), +}) + +export const GetPaginatedTCaseResponseSchema = z.object({ + total: z.number().int(), + page: z.number().int(), + limit: z.number().int(), + data: FullTCaseSchema.array().nullable(), +}) + +export const GetTCasesCountRequestSchema = z.object({ + search: z.string().optional().nullable(), + folders: z.number().int().array().optional().nullable(), + tags: z.number().int().array().optional().nullable(), + tagsFilterOp: z.enum(['and', 'or', 'not']).optional().nullable(), + priorities: prioritySchema.array().optional().nullable(), + draft: z.boolean().optional().nullable(), + types: z.enum(['standalone', 'template', 'filled']).array().optional().nullable(), + templateTCaseIds: z.string().array().optional().nullable(), + preconditionIds: z.number().int().array().optional().nullable(), + stepIds: z.number().int().array().optional().nullable(), + requirementIds: z.string().array().optional().nullable(), + authorIds: z.number().int().array().optional().nullable(), + customFields: z.record(z.string().min(1), z.string().array().min(1)).optional().nullable(), + createdAfter: dateLikeSchema.nullable().optional(), + createdBefore: dateLikeSchema.nullable().optional(), + recursive: z.boolean().nullable(), +}) + +export const GetTCasesCountResponseSchema = z.object({ + count: z.number().int(), +}) + +export const CreateTCaseRequestSchema = z.object({ + type: z.enum(['standalone', 'template']), + folderId: z.number().int(), + pos: z.number().int().nullable(), + title: z.string().min(1).max(511), + priority: prioritySchema, + comment: z.string().optional(), + precondition: preconditionRequestSchema.optional(), + files: z.array(z.any()).nullable(), + requirements: z + .object({ + text: z.string().min(1), + url: z.string(), + }) + .array() + .nullable(), + links: LinkWithTextSchema.array().nullable(), + tags: z.string().array().nullable(), + steps: tcaseStepRequestSchema.array().nullable(), + customFields: z.record(z.string().min(1), tcaseCustomFieldSchema).nullable(), + parameterValues: z + .object({ + values: z.record(z.string(), z.string()), + }) + .array() + .nullable(), + filledTCaseTitleSuffixParams: z.string().min(1).array().optional(), + isDraft: z.boolean(), +}) + +export const CreateTCaseResponseSchema = z.object({ + id: z.string().min(1), + seq: z.number().int(), +}) + +export const UpdateTCaseRequestSchema = z.object({ + id: z.string().min(1).optional(), + title: z.string().min(1).max(511).optional(), + priority: prioritySchema.optional(), + comment: z.string().optional(), + precondition: preconditionRequestSchema.optional(), + requirements: z + .object({ + text: z.string().min(1), + url: z.string(), + }) + .array() + .nullable() + .optional(), + links: LinkWithTextSchema.array().nullable().optional(), + tags: z.string().array().nullable().optional(), + steps: tcaseStepRequestSchema.array().nullable().optional(), + files: z.array(z.any()).nullable().optional(), + customFields: z.record(z.string().min(1), tcaseCustomFieldSchema).optional(), + parameterValues: z + .object({ + tcaseId: z.string().min(1), + values: z.record(z.string(), z.string()), + priority: prioritySchema.optional(), + }) + .array() + .optional(), + filledTCaseTitleSuffixParams: z.string().min(1).array().optional(), + isDraft: z.boolean().optional(), +}) + +export const GetCustomFieldsResponseSchema = z.object({ + customFields: z + .object({ + id: z.string().min(1), + type: z.enum(['text', 'dropdown', 'checkbox', 'richtext']), + systemName: z.string().min(1), + name: z.string().min(1), + required: z.boolean(), + enabled: z.boolean(), + options: z + .object({ + id: z.string().min(1), + value: z.string().min(1), + }) + .array() + .nullable(), + defaultValue: z.string(), + pos: z.number().int(), + allowAllProjects: z.boolean(), + allowedProjectIds: z.string().array().nullable(), + createdAt: dateLikeSchema, + updatedAt: dateLikeSchema, + }) + .array() + .nullable(), +}) + +export const UploadFileResponseSchema = z.object({ + id: z.string().min(1), + url: z.string().min(1), +}) + +export const GetPublicUsersListResponseSchema = z.object({ + users: z + .object({ + email: z.string().email(), + name: z.string().min(1), + role: z.enum(['owner', 'admin', 'user', 'test-runner', 'viewer']), + authorizationTypes: z.string().array(), + totpEnabled: z.boolean(), + createdAt: dateLikeSchema, + lastActivity: dateLikeSchema.nullable(), + }) + .array() + .nullable(), +}) + +export const GetPublicAuditLogsRequestSchema = z.object({ + after: z.number().int().gte(0).nullable(), + count: z.number().int().gte(1).lte(1000).nullable(), +}) + +export const GetPublicAuditLogsResponseSchema = z.object({ + after: z.number().int(), + count: z.number().int(), + events: z + .object({ + id: z.number().int(), + user: z + .object({ + id: z.number().int(), + name: z.string().min(1), + email: z.string().email(), + }) + .nullable(), + action: z.string(), + ip: z.string(), + userAgent: z.string(), + createdAt: dateLikeSchema, + meta: z.record(z.string(), z.any()).optional(), + }) + .array() + .nullable(), +}) diff --git a/src/commands/api/types.ts b/src/commands/api/types.ts new file mode 100644 index 0000000..533c1d6 --- /dev/null +++ b/src/commands/api/types.ts @@ -0,0 +1,53 @@ +import { ZodTypeAny } from 'zod' + +export type ApiCommandPath = [string, ...string[]] + +export type ApiHttpMethod = 'GET' | 'POST' | 'PATCH' + +export type ApiBodyMode = 'none' | 'json' | 'file' + +export type ApiValueType = 'string' | 'integer' | 'boolean' + +export interface ApiPathParamSpec { + name: string + describe: string + type: Extract +} + +export interface ApiOptionSpec { + name: string + describe: string + type: ApiValueType + array?: boolean + choices?: readonly string[] +} + +export interface ApiRequestEnvelope { + query?: Record + body?: unknown +} + +export interface ApiEndpointSpec { + id: string + commandPath: ApiCommandPath + describe: string + method: ApiHttpMethod + pathTemplate: string + pathParams: ApiPathParamSpec[] + queryOptions?: ApiOptionSpec[] + supportsCustomFieldFilters?: boolean + bodyMode: ApiBodyMode + querySchema?: ZodTypeAny + bodySchema?: ZodTypeAny + responseSchema?: ZodTypeAny + queryNullDefaults?: string[] + bodyNullDefaults?: string[] + queryAdapter?: (value: Record) => Record + bodyAdapter?: (value: unknown) => unknown + requestSchemaLinks?: { + query?: string + body?: string + response?: string + } + examples?: string[] +} diff --git a/src/commands/main.ts b/src/commands/main.ts index 81c3de0..49d6701 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -1,4 +1,5 @@ import yargs from 'yargs' +import { ApiCommandModule } from './api' import { ResultUploadCommandModule } from './resultUpload' import { qasEnvs, qasEnvFile } from '../utils/env' import { CLI_VERSION } from '../utils/version' @@ -11,6 +12,7 @@ export const run = (args: string | string[]) => Required variables: ${qasEnvs.join(', ')} These should be either exported as env vars or defined in a ${qasEnvFile} file.` ) + .command(new ApiCommandModule()) .command(new ResultUploadCommandModule('junit-upload')) .command(new ResultUploadCommandModule('playwright-json-upload')) .command(new ResultUploadCommandModule('allure-upload')) @@ -40,7 +42,7 @@ Required variables: ${qasEnvs.join(', ')} msg.startsWith('Not enough non-option arguments') ) { yi.showHelp() - process.exit(0) + process.exit(1) } } else if (err && err.message) { console.error(err.message) diff --git a/src/tests/api-command.spec.ts b/src/tests/api-command.spec.ts new file mode 100644 index 0000000..0f121c8 --- /dev/null +++ b/src/tests/api-command.spec.ts @@ -0,0 +1,192 @@ +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' + +import { executeApiCommand } from '../commands/api/executor' +import { apiEndpointSpecById } from '../commands/api/manifest' +import { run } from '../commands/main' + +const baseURL = 'https://qas.eu1.qasphere.com' +process.env.QAS_URL = baseURL +process.env.QAS_TOKEN = 'QAS_TOKEN' + +let stdout = '' + +const server = setupServer( + http.get(`${baseURL}/api/public/v0/project`, () => + HttpResponse.json({ + projects: [ + { + id: '1', + code: 'DEMO', + title: 'Demo Project', + description: '', + overviewTitle: '', + overviewDescription: '', + links: [], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + archivedAt: null, + }, + ], + }) + ), + http.post(`${baseURL}/api/public/v0/project`, async ({ request }) => { + expect(request.headers.get('Authorization')).toBe('ApiKey QAS_TOKEN') + expect(await request.json()).toEqual({ + code: 'DEMO', + title: 'Demo Project', + links: null, + overviewTitle: null, + overviewDescription: null, + }) + return HttpResponse.json({ id: 10 }, { status: 201 }) + }), + http.patch(`${baseURL}/api/public/v0/project/DEMO/tcase/123`, async ({ request }) => { + expect(request.headers.get('Authorization')).toBe('ApiKey QAS_TOKEN') + expect(await request.json()).toEqual({ + title: 'Updated', + requirements: null, + links: null, + tags: null, + steps: [{ sharedStepId: 9, description: '', expected: '' }], + files: null, + precondition: { sharedPreconditionId: 42 }, + }) + return HttpResponse.json({ message: 'Test case updated' }) + }), + http.post(`${baseURL}/api/public/v0/file`, async ({ request }) => { + expect(request.headers.get('Authorization')).toBe('ApiKey QAS_TOKEN') + const form = await request.formData() + const file = form.get('file') + expect(file).toBeInstanceOf(File) + expect((file as File).name).toBe('upload.txt') + return HttpResponse.json({ id: 'file-1', url: `${baseURL}/api/file/file-1` }) + }) +) + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'error' }) +}) + +afterAll(() => { + server.close() +}) + +beforeEach(() => { + stdout = '' + vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout += chunk.toString() + return true + }) as typeof process.stdout.write) +}) + +afterEach(() => { + server.resetHandlers() + vi.restoreAllMocks() +}) + +describe('qasphere api command', () => { + test('prints pretty JSON for GET commands', async () => { + await run(['api', 'projects', 'list']) + + expect(stdout).toBe( + `${JSON.stringify( + { + projects: [ + { + id: '1', + code: 'DEMO', + title: 'Demo Project', + description: '', + overviewTitle: '', + overviewDescription: '', + links: [], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + archivedAt: null, + }, + ], + }, + null, + 2 + )}\n` + ) + }) + + test('sends POST JSON from --body-file', async () => { + await run([ + 'api', + 'projects', + 'create', + '--body-file', + './src/tests/fixtures/api/create-project.json', + ]) + + expect(stdout).toBe(`${JSON.stringify({ id: 10 }, null, 2)}\n`) + }) + + test('sends PATCH JSON from inline body', async () => { + await run([ + 'api', + 'testcases', + 'update', + 'DEMO', + '123', + '--body', + JSON.stringify({ + title: 'Updated', + precondition: { id: 42 }, + steps: [{ sharedStepId: 9 }], + }), + ]) + + expect(stdout).toBe(`${JSON.stringify({ message: 'Test case updated' }, null, 2)}\n`) + }) + + test('uploads multipart files', async () => { + await run(['api', 'files', 'upload', '--file', './src/tests/fixtures/api/upload.txt']) + + expect(stdout).toBe( + `${JSON.stringify({ id: 'file-1', url: `${baseURL}/api/file/file-1` }, null, 2)}\n` + ) + }) + + test('surfaces API failure responses', async () => { + server.use( + http.post(`${baseURL}/api/public/v0/project`, () => + HttpResponse.json({ message: 'duplicate project code' }, { status: 409 }) + ) + ) + + const spec = apiEndpointSpecById.get('projects.create')! + await expect( + executeApiCommand( + spec, + { + body: JSON.stringify({ code: 'DEMO', title: 'Demo Project' }), + }, + { baseUrl: baseURL, apiKey: 'QAS_TOKEN' } + ) + ).rejects.toThrow('duplicate project code') + }) + + test('validates representative response shapes through the manifest schema', async () => { + const spec = apiEndpointSpecById.get('projects.list')! + const response = await executeApiCommand(spec, {}, { baseUrl: baseURL, apiKey: 'QAS_TOKEN' }) + + expect(spec.responseSchema?.safeParse(response).success).toBe(true) + }) + + test('rejects unknown flags through yargs strict mode', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('EXIT') + }) as never) + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(() => run(['api', 'projects', 'list', '--bogus'])).toThrow('EXIT') + + expect(stderrSpy).toHaveBeenCalled() + expect(exitSpy).toHaveBeenCalledWith(1) + }) +}) diff --git a/src/tests/api-helpers.spec.ts b/src/tests/api-helpers.spec.ts new file mode 100644 index 0000000..08d3f98 --- /dev/null +++ b/src/tests/api-helpers.spec.ts @@ -0,0 +1,124 @@ +import { Readable } from 'node:stream' + +import { describe, expect, test } from 'vitest' + +import { apiEndpointSpecById } from '../commands/api/manifest' +import { buildApiRequestFromArgs } from '../commands/api/executor' +import { loadJsonBodyInput } from '../commands/api/helpers' + +describe('API helper validation', () => { + test('loads JSON body from inline text', async () => { + await expect(loadJsonBodyInput({ body: '{"title":"Demo"}' })).resolves.toEqual({ + title: 'Demo', + }) + }) + + test('loads JSON body from file', async () => { + await expect( + loadJsonBodyInput({ bodyFile: './src/tests/fixtures/api/create-project.json' }) + ).resolves.toEqual({ + code: 'DEMO', + title: 'Demo Project', + }) + }) + + test('loads JSON body from stdin', async () => { + await expect( + loadJsonBodyInput( + { bodyStdin: true }, + Readable.from(['{"code":"STDIN","title":"From stdin"}']) + ) + ).resolves.toEqual({ + code: 'STDIN', + title: 'From stdin', + }) + }) + + test('reports invalid inline JSON with source context', async () => { + await expect(loadJsonBodyInput({ body: '{"code":' })).rejects.toThrow( + 'Invalid JSON from --body' + ) + }) + + test('reports invalid file JSON with source context', async () => { + await expect( + loadJsonBodyInput({ bodyFile: './src/tests/fixtures/api/invalid-body.txt' }) + ).rejects.toThrow('Invalid JSON from --body-file ./src/tests/fixtures/api/invalid-body.txt') + }) + + test('reports invalid stdin JSON with source context', async () => { + await expect( + loadJsonBodyInput({ bodyStdin: true }, Readable.from(['{"code":'])) + ).rejects.toThrow('Invalid JSON from --body-stdin') + }) + + test('rejects non-integer numeric query flags', async () => { + const spec = apiEndpointSpecById.get('audit-logs.list')! + await expect( + buildApiRequestFromArgs(spec, { + after: 0, + count: 1.5, + }) + ).rejects.toThrow('--count must be a finite integer.') + }) + + test('rejects malformed custom field filters', async () => { + const spec = apiEndpointSpecById.get('testcases.list')! + await expect( + buildApiRequestFromArgs(spec, { + project: 'DEMO', + cf: ['automation'], + }) + ).rejects.toThrow('--cf must be exactly key=value with non-empty key and value.') + }) + + test('rejects JSON body flags on file upload commands', async () => { + const spec = apiEndpointSpecById.get('files.upload')! + await expect( + buildApiRequestFromArgs(spec, { + file: './src/tests/fixtures/api/upload.txt', + body: '{"ignored":true}', + }) + ).rejects.toThrow('JSON body flags are not supported for this command') + }) + + test('maps repeated query flags and --cf filters into URL params', async () => { + const spec = apiEndpointSpecById.get('testcases.list')! + const request = await buildApiRequestFromArgs(spec, { + project: 'DEMO', + tags: [12, 13], + priorities: ['high', 'medium'], + cf: ['automation=Automated', 'automation=Planned', 'owner=QA'], + }) + + expect(request.pathname).toBe('/api/public/v0/project/DEMO/tcase') + expect(request.query?.getAll('tags')).toEqual(['12', '13']) + expect(request.query?.getAll('priorities')).toEqual(['high', 'medium']) + expect(request.query?.getAll('cf_automation')).toEqual(['Automated', 'Planned']) + expect(request.query?.getAll('cf_owner')).toEqual(['QA']) + }) + + test('normalizes update test case request before schema validation', async () => { + const spec = apiEndpointSpecById.get('testcases.update')! + const request = await buildApiRequestFromArgs(spec, { + project: 'DEMO', + tcase: '123', + body: JSON.stringify({ + title: 'Updated', + precondition: { id: 42 }, + steps: [{ sharedStepId: 10 }], + }), + }) + + expect(request.pathname).toBe('/api/public/v0/project/DEMO/tcase/123') + expect(request.jsonBody).toEqual({ + title: 'Updated', + requirements: null, + links: null, + tags: null, + steps: [{ sharedStepId: 10, description: '', expected: '' }], + files: null, + precondition: { sharedPreconditionId: 42 }, + }) + }) +}) diff --git a/src/tests/api-manifest.spec.ts b/src/tests/api-manifest.spec.ts new file mode 100644 index 0000000..4cfe9d3 --- /dev/null +++ b/src/tests/api-manifest.spec.ts @@ -0,0 +1,234 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { describe, expect, test } from 'vitest' + +import { apiEndpointSpecs } from '../commands/api/manifest' + +const docsRoot = join(process.cwd(), '..', 'qasphere-docs', 'docs', 'api', 'endpoints') + +describe('API endpoint manifest', () => { + test('exposes all planned CLI commands', () => { + expect(apiEndpointSpecs.map((spec) => spec.commandPath.join(' '))).toEqual([ + 'projects list', + 'projects get', + 'projects create', + 'folders list', + 'folders upsert', + 'milestones list', + 'milestones create', + 'plans create', + 'requirements list', + 'results add', + 'results add-batch', + 'runs list', + 'runs create', + 'runs clone', + 'runs close', + 'runs list-tcases', + 'runs get-tcase', + 'settings statuses get', + 'settings statuses update', + 'shared-preconditions list', + 'shared-preconditions get', + 'shared-steps list', + 'shared-steps get', + 'tags list', + 'testcases list', + 'testcases get', + 'testcases count', + 'testcases create', + 'testcases update', + 'custom-fields list', + 'files upload', + 'users list', + 'audit-logs list', + ]) + }) + + test('references the intended schemas or explicit local adapters', () => { + expect( + Object.fromEntries( + apiEndpointSpecs.map((spec) => [ + spec.id, + { + query: spec.requestSchemaLinks?.query ?? null, + body: spec.requestSchemaLinks?.body ?? null, + response: spec.requestSchemaLinks?.response ?? null, + }, + ]) + ) + ).toEqual({ + 'projects.list': { query: null, body: null, response: 'GetPublicProjectsResponseSchema' }, + 'projects.get': { query: null, body: null, response: 'PublicProjectSchema' }, + 'projects.create': { + query: null, + body: 'CreateProjectRequestSchema', + response: 'IDResponseSchema', + }, + 'folders.list': { + query: 'GetPublicPaginatedFolderRequestSchema', + body: null, + response: 'GetPaginatedFolderResponseSchema', + }, + 'folders.upsert': { + query: null, + body: 'BulkUpsertFoldersRequestSchema', + response: 'BulkUpsertFoldersResponseSchema', + }, + 'milestones.list': { + query: 'GetMilestonesRequestSchema', + body: null, + response: 'GetPublicApiMilestonesResponseSchema', + }, + 'milestones.create': { + query: null, + body: 'CreateMilestonePublicRequestSchema', + response: 'IDResponseSchema', + }, + 'plans.create': { + query: null, + body: 'CreatePlanRequestSchema', + response: 'IDResponseSchema', + }, + 'requirements.list': { + query: 'GetRequirementsRequestSchema', + body: null, + response: 'GetRequirementsResponseSchema', + }, + 'results.add': { + query: null, + body: 'CreateResultRequestSchema', + response: 'IDResponseSchema', + }, + 'results.add-batch': { + query: null, + body: 'CreateResultsRequestSchema', + response: 'IDsResponseSchema', + }, + 'runs.list': { + query: 'ListRunsRequestSchema', + body: null, + response: 'GetRunsResponseSchema', + }, + 'runs.create': { + query: null, + body: 'CreateRunRequestSchema', + response: 'IDResponseSchema', + }, + 'runs.clone': { + query: null, + body: 'CloneRunRequestSchema', + response: 'IDResponseSchema', + }, + 'runs.close': { query: null, body: null, response: 'MessageResponseSchema' }, + 'runs.list-tcases': { + query: 'ListRunTCasesRequestSchema', + body: null, + response: 'GetRunTCasesResponseSchema', + }, + 'runs.get-tcase': { query: null, body: null, response: 'RunTCaseSchema' }, + 'settings.statuses.get': { + query: null, + body: null, + response: 'GetStatusesResponseSchema', + }, + 'settings.statuses.update': { + query: null, + body: 'UpdateStatusesRequestSchema', + response: 'MessageResponseSchema', + }, + 'shared-preconditions.list': { + query: 'GetSharedPreconditionsRequestSchema', + body: null, + response: 'PreconditionSchema[]', + }, + 'shared-preconditions.get': { + query: null, + body: null, + response: 'PreconditionSchema', + }, + 'shared-steps.list': { + query: 'GetSharedStepsRequestSchema', + body: null, + response: 'GetSharedStepsResponseSchema', + }, + 'shared-steps.get': { query: null, body: null, response: 'StepSchema' }, + 'tags.list': { query: 'GetTagsRequestSchema', body: null, response: 'GetTagsResponseSchema' }, + 'testcases.list': { + query: 'GetPaginatedTCaseRequestSchema', + body: null, + response: 'GetPaginatedTCaseResponseSchema', + }, + 'testcases.get': { query: null, body: null, response: 'FullTCaseSchema' }, + 'testcases.count': { + query: 'GetTCasesCountRequestSchema', + body: null, + response: 'GetTCasesCountResponseSchema', + }, + 'testcases.create': { + query: null, + body: 'CreateTCaseRequestSchema', + response: 'CreateTCaseResponseSchema', + }, + 'testcases.update': { + query: null, + body: 'UpdateTCaseRequestSchema', + response: 'MessageResponseSchema', + }, + 'custom-fields.list': { + query: null, + body: null, + response: 'GetCustomFieldsResponseSchema', + }, + 'files.upload': { query: null, body: null, response: 'UploadFileResponseSchema' }, + 'users.list': { query: null, body: null, response: 'GetPublicUsersListResponseSchema' }, + 'audit-logs.list': { + query: 'GetPublicAuditLogsRequestSchema', + body: null, + response: 'GetPublicAuditLogsResponseSchema', + }, + }) + }) + + test('matches documented public routes when the docs repository is available', () => { + if (!existsSync(docsRoot)) { + return + } + + const routeRegex = + //g + const docRoutes = new Set() + for (const fileName of [ + 'audit_logs.mdx', + 'folders.mdx', + 'milestone.mdx', + 'plan.mdx', + 'projects.mdx', + 'requirements.mdx', + 'result.mdx', + 'run.mdx', + 'settings.mdx', + 'shared_preconditions.mdx', + 'shared_steps.mdx', + 'tag.mdx', + 'tcases.mdx', + 'tcases_custom_fields.mdx', + 'upload_file.mdx', + 'users.mdx', + ]) { + const fileText = readFileSync(join(docsRoot, fileName), 'utf8') + for (const match of fileText.matchAll(routeRegex)) { + docRoutes.add(`${match[1]} ${match[2].replace(/\{[^}]+\}/g, '{}')}`) + } + } + + const manifestRoutes = new Set( + apiEndpointSpecs.map( + (spec) => `${spec.method} ${spec.pathTemplate.replace(/\{[^}]+\}/g, '{}')}` + ) + ) + + expect(manifestRoutes).toEqual(docRoutes) + }) +}) diff --git a/src/tests/api.e2e.spec.ts b/src/tests/api.e2e.spec.ts new file mode 100644 index 0000000..54b22e1 --- /dev/null +++ b/src/tests/api.e2e.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest' + +import { executeApiCommand } from '../commands/api/executor' +import { apiEndpointSpecById } from '../commands/api/manifest' + +const enabled = + process.env.QAS_API_E2E === '1' && + typeof process.env.QAS_URL === 'string' && + typeof process.env.QAS_TOKEN === 'string' && + (() => { + try { + new URL(process.env.QAS_URL!) + return true + } catch { + return false + } + })() + +describe.runIf(enabled)('public API real-instance smoke tests', () => { + test('lists projects from the configured instance', async () => { + const spec = apiEndpointSpecById.get('projects.list')! + const response = await executeApiCommand(spec, {}) + + expect(spec.responseSchema?.safeParse(response).success).toBe(true) + }) +}) diff --git a/src/tests/fixtures/api/create-project.json b/src/tests/fixtures/api/create-project.json new file mode 100644 index 0000000..02febef --- /dev/null +++ b/src/tests/fixtures/api/create-project.json @@ -0,0 +1,4 @@ +{ + "code": "DEMO", + "title": "Demo Project" +} diff --git a/src/tests/fixtures/api/invalid-body.txt b/src/tests/fixtures/api/invalid-body.txt new file mode 100644 index 0000000..de15844 --- /dev/null +++ b/src/tests/fixtures/api/invalid-body.txt @@ -0,0 +1 @@ +{"code": diff --git a/src/tests/fixtures/api/upload.txt b/src/tests/fixtures/api/upload.txt new file mode 100644 index 0000000..d570919 --- /dev/null +++ b/src/tests/fixtures/api/upload.txt @@ -0,0 +1 @@ +upload me