diff --git a/src/commands/executeStepZenRequest.ts b/src/commands/executeStepZenRequest.ts index c41027a..8706cc7 100644 --- a/src/commands/executeStepZenRequest.ts +++ b/src/commands/executeStepZenRequest.ts @@ -2,23 +2,15 @@ import * as vscode from "vscode"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; -import * as https from "https"; import { resolveStepZenProjectRoot } from "../utils/stepzenProject"; import { clearResultsPanel, openResultsPanel } from "../panels/resultsPanel"; import { summariseDiagnostics, publishDiagnostics } from "../utils/runtimeDiagnostics"; import { runtimeDiag } from "../extension"; -import { UI, TIMEOUTS, TEMP_FILE_PATTERNS, FILE_PATTERNS } from "../utils/constants"; +import { UI, TIMEOUTS, TEMP_FILE_PATTERNS } from "../utils/constants"; import { services } from "../services"; import { StepZenResponse, StepZenDiagnostic } from "../types"; -import { ValidationError, NetworkError, handleError } from "../errors"; +import { ValidationError, handleError } from "../errors"; -/* CLEANUP - DELETE WHEN SAFE -// Export utility functions for use in other files -export { - createTempGraphQLFile, - cleanupLater -}; -*/ /** * Creates a temporary GraphQL file with the provided query content @@ -93,33 +85,17 @@ function cleanupLater(filePath: string) { */ export async function executeStepZenRequest(options: { queryText?: string; - documentId?: string; + documentContent?: string; operationName?: string; varArgs?: string[]; }): Promise { - // Validate options object - if (!options || typeof options !== 'object') { - handleError(new ValidationError("Invalid request options provided", "INVALID_OPTIONS")); - return; - } - - const { queryText, documentId, operationName, varArgs = [] } = options; - - // Validate at least one of queryText or documentId is provided and valid - if (documentId === undefined && (!queryText || typeof queryText !== 'string')) { - handleError(new ValidationError("Invalid request: either documentId or queryText must be provided", "MISSING_QUERY")); - return; - } - - // Validate operationName if provided - if (operationName !== undefined && typeof operationName !== 'string') { - handleError(new ValidationError("Invalid operation name provided", "INVALID_OPERATION_NAME")); - return; - } + const { queryText, documentContent, operationName, varArgs = [] } = options; - // Validate varArgs is an array - if (!Array.isArray(varArgs)) { - handleError(new ValidationError("Invalid variable arguments: expected an array", "INVALID_VAR_ARGS")); + // Validate request options using the request service + try { + services.request.validateRequestOptions({ queryText, documentContent, operationName, varArgs }); + } catch (err) { + handleError(err); return; } @@ -136,90 +112,13 @@ export async function executeStepZenRequest(options: { const debugLevel = cfg.get("request.debugLevel", 1); // For persisted documents, we need to make an HTTP request directly - if (documentId) { + if (documentContent) { try { - // Get StepZen config to build the endpoint URL - const configPath = path.join(projectRoot, FILE_PATTERNS.CONFIG_FILE); - services.logger.debug(`Looking for config file at: ${configPath}`); - - // Verify config file exists - if (!fs.existsSync(configPath)) { - handleError(new ValidationError( - `StepZen configuration file not found at: ${configPath}`, - "CONFIG_NOT_FOUND" - )); - return; - } - - let endpoint: string; - let apiKey: string; + // Load endpoint configuration using the request service + const endpointConfig = await services.request.loadEndpointConfig(projectRoot); - try { - const configContent = fs.readFileSync(configPath, "utf8"); - - if (!configContent) { - handleError(new ValidationError("StepZen configuration file is empty", "EMPTY_CONFIG")); - return; - } - - const config = JSON.parse(configContent); - - if (!config || !config.endpoint) { - handleError(new ValidationError("Invalid StepZen configuration: missing endpoint", "MISSING_ENDPOINT")); - return; - } - - endpoint = config.endpoint; - apiKey = config.apiKey || ""; - } catch (err) { - handleError(new ValidationError( - "Failed to parse StepZen configuration file", - "CONFIG_PARSE_ERROR", - err - )); - return; - } - - // Parse endpoint to extract account and domain - const endpointParts = endpoint.split("/"); - if (endpointParts.length < 2) { - handleError(new ValidationError( - `Invalid StepZen endpoint format: ${endpoint}`, - "INVALID_ENDPOINT_FORMAT" - )); - return; - } - - // Construct the GraphQL endpoint URL - const graphqlUrl = `https://${endpoint}/graphql`; - - // Prepare variables from varArgs - let variables: Record = {}; - for (let i = 0; i < varArgs.length; i += 2) { - if (varArgs[i] === "--var" && i + 1 < varArgs.length) { - const [name, value] = varArgs[i + 1].split("="); - variables[name] = value; - } else if (varArgs[i] === "--var-file" && i + 1 < varArgs.length) { - try { - const fileContent = fs.readFileSync(varArgs[i + 1], "utf8"); - variables = JSON.parse(fileContent); - } catch (err) { - handleError(new ValidationError( - "Failed to read variables file", - "VAR_FILE_READ_ERROR", - err - )); - return; - } - } - } - - // Prepare the request body - const requestBody = { - documentId, - operationName, - variables - }; + // Parse variables using the request service + const { variables } = services.request.parseVariables(varArgs); // Show a progress notification await vscode.window.withProgress( @@ -229,53 +128,13 @@ export async function executeStepZenRequest(options: { cancellable: false }, async () => { - services.logger.info("Making HTTP request to StepZen API for persisted document"); - // Use Node.js https module to make the request - const result = await new Promise((resolve, reject) => { - const postData = JSON.stringify(requestBody); - - const options = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(postData), - 'Authorization': apiKey ? `Apikey ${apiKey}` : '', - 'stepzen-debug-level': debugLevel.toString(), - } - }; - - const req = https.request(graphqlUrl, options, (res) => { - let responseData = ''; - - res.on('data', (chunk) => { - responseData += chunk; - }); - - res.on('end', () => { - try { - const json = JSON.parse(responseData); - resolve(json); - } catch (err) { - reject(new ValidationError( - "Failed to parse StepZen response", - "RESPONSE_PARSE_ERROR", - err - )); - } - }); - }); - - req.on('error', (err) => { - reject(new NetworkError( - "Failed to connect to StepZen API", - "API_CONNECTION_ERROR", - err - )); - }); - - req.write(postData); - req.end(); - }); + // Execute the persisted document request using the request service + const result = await services.request.executePersistedDocumentRequest( + endpointConfig, + documentContent, + variables, + operationName + ); // Process results const rawDiags = (result.extensions?.stepzen?.diagnostics ?? []) as StepZenDiagnostic[]; @@ -355,37 +214,8 @@ export async function executeStepZenRequest(options: { }, async () => { try { - // Parse varArgs into variables object - const variables: Record = {}; - for (let i = 0; i < varArgs.length; i += 2) { - if (varArgs[i] === "--var" && i + 1 < varArgs.length) { - const [name, value] = varArgs[i + 1].split("="); - if (name && value !== undefined) { - variables[name] = value; - services.logger.debug(`Setting variable ${name}=${value}`); - } else { - services.logger.warn(`Invalid variable format: ${varArgs[i + 1]}`); - } - } else if (varArgs[i] === "--var-file" && i + 1 < varArgs.length) { - try { - const varFilePath = varArgs[i + 1]; - services.logger.debug(`Reading variables from file: ${varFilePath}`); - if (!fs.existsSync(varFilePath)) { - throw new ValidationError(`Variables file not found: ${varFilePath}`, "VAR_FILE_NOT_FOUND"); - } - const fileContent = fs.readFileSync(varFilePath, "utf8"); - const fileVars = JSON.parse(fileContent); - services.logger.debug(`Loaded ${Object.keys(fileVars).length} variables from file`); - Object.assign(variables, fileVars); - } catch (err) { - throw new ValidationError( - "Failed to read variables file", - "VAR_FILE_READ_ERROR", - err - ); - } - } - } + // Parse variables using the request service + const { variables } = services.request.parseVariables(varArgs); // Use the CLI service to execute the request services.logger.info(`Executing StepZen request${operationName ? ` for operation "${operationName}"` : ' (anonymous operation)'} with debug level ${debugLevel}`); diff --git a/src/commands/runRequest.ts b/src/commands/runRequest.ts index 926246f..52c2406 100644 --- a/src/commands/runRequest.ts +++ b/src/commands/runRequest.ts @@ -371,9 +371,9 @@ export async function runPersisted(documentId: string, operationName: string) { return; // user cancelled } - // Execute using document ID approach + // Execute using persisted document approach with the full document content await executeStepZenRequest({ - documentId, + documentContent: content, operationName, varArgs }); diff --git a/src/services/cli.ts b/src/services/cli.ts index 6178883..d1d7300 100644 --- a/src/services/cli.ts +++ b/src/services/cli.ts @@ -160,6 +160,90 @@ export class StepzenCliService { throw error; } } + + /** + * Get the API key from StepZen CLI + * + * @returns Promise resolving to the API key + * @throws CliError if the operation fails + */ + async getApiKey(): Promise { + try { + logger.debug('Retrieving API key from StepZen CLI'); + + const result = await this.spawnProcessWithOutput(['whoami', '--apikey']); + const apiKey = result.trim(); + + if (!apiKey) { + throw new CliError("Empty API key returned from StepZen CLI", "EMPTY_API_KEY"); + } + + logger.debug("Successfully retrieved API key from CLI"); + return apiKey; + } catch (err) { + throw new CliError( + "Failed to retrieve API key from StepZen CLI", + "API_KEY_RETRIEVAL_FAILED", + err + ); + } + } + + /** + * Get the account name from StepZen CLI + * + * @returns Promise resolving to the account name + * @throws CliError if the operation fails + */ + async getAccount(): Promise { + try { + logger.debug('Retrieving account from StepZen CLI'); + + const result = await this.spawnProcessWithOutput(['whoami', '--account']); + const account = result.trim(); + + if (!account) { + throw new CliError("Empty account returned from StepZen CLI", "EMPTY_ACCOUNT"); + } + + logger.debug("Successfully retrieved account from CLI"); + return account; + } catch (err) { + throw new CliError( + "Failed to retrieve account from StepZen CLI", + "ACCOUNT_RETRIEVAL_FAILED", + err + ); + } + } + + /** + * Get the domain from StepZen CLI + * + * @returns Promise resolving to the domain + * @throws CliError if the operation fails + */ + async getDomain(): Promise { + try { + logger.debug('Retrieving domain from StepZen CLI'); + + const result = await this.spawnProcessWithOutput(['whoami', '--domain']); + const domain = result.trim(); + + if (!domain) { + throw new CliError("Empty domain returned from StepZen CLI", "EMPTY_DOMAIN"); + } + + logger.debug("Successfully retrieved domain from CLI"); + return domain; + } catch (err) { + throw new CliError( + "Failed to retrieve domain from StepZen CLI", + "DOMAIN_RETRIEVAL_FAILED", + err + ); + } + } // TODO: CLEANUP // /** @@ -228,7 +312,7 @@ export class StepzenCliService { * @returns Promise resolving to the captured stdout * @throws CliError if the process fails */ - private async spawnProcessWithOutput( + public async spawnProcessWithOutput( args: string[] = [], options: cp.SpawnOptions = {} ): Promise { diff --git a/src/services/index.ts b/src/services/index.ts index 111e2b6..86d44d6 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -2,6 +2,7 @@ import { StepzenCliService } from './cli'; import { Logger, logger } from './logger'; import { ProjectResolver } from './projectResolver'; import { SchemaIndexService } from './SchemaIndexService'; +import { RequestService } from './request'; /** * Service registry for dependency injection of application services @@ -12,6 +13,7 @@ export interface ServiceRegistry { logger: Logger; projectResolver: ProjectResolver; schemaIndex: SchemaIndexService; + request: RequestService; } /** @@ -22,6 +24,7 @@ export const services: ServiceRegistry = { logger, projectResolver: new ProjectResolver(logger), schemaIndex: new SchemaIndexService(), + request: new RequestService(logger), }; /** diff --git a/src/services/request.ts b/src/services/request.ts new file mode 100644 index 0000000..6366c07 --- /dev/null +++ b/src/services/request.ts @@ -0,0 +1,330 @@ +/** + * Copyright IBM Corp. 2025 + * Assisted by CursorAI + */ + +import * as fs from "fs"; +import * as path from "path"; +import * as https from "https"; +import * as crypto from "crypto"; +import { StepZenResponse, StepZenConfig } from "../types"; +import { ValidationError, NetworkError } from "../errors"; +import { Logger } from "./logger"; +import { FILE_PATTERNS } from "../utils/constants"; + +/** + * Request options for StepZen GraphQL requests + */ +interface RequestOptions { + /** GraphQL query text for file-based requests */ + queryText?: string; + /** Document content for persisted document requests */ + documentContent?: string; + /** Name of the operation to execute */ + operationName?: string; + /** Variable arguments (--var, --var-file) */ + varArgs?: string[]; + /** Debug level for the request */ + debugLevel?: number; +} + +/** + * Parsed variables from varArgs + */ +interface ParsedVariables { + /** Variables as key-value pairs */ + variables: Record; +} + +/** + * StepZen configuration with endpoint details + */ +interface EndpointConfig { + /** GraphQL endpoint URL */ + graphqlUrl: string; + /** API key for authentication */ + apiKey: string; +} + +/** + * Service for handling StepZen GraphQL requests + * Handles HTTP requests to StepZen API and variable parsing + */ +export class RequestService { + constructor(private logger: Logger) {} + + /** + * Parse variable arguments into a variables object + * Supports both --var name=value and --var-file path formats + * + * @param varArgs Array of variable arguments + * @returns Parsed variables object + */ + public parseVariables(varArgs: string[]): ParsedVariables { + const variables: Record = {}; + + if (!Array.isArray(varArgs)) { + throw new ValidationError("Invalid variable arguments: expected an array", "INVALID_VAR_ARGS"); + } + + for (let i = 0; i < varArgs.length; i += 2) { + if (varArgs[i] === "--var" && i + 1 < varArgs.length) { + const varString = varArgs[i + 1]; + const equalIndex = varString.indexOf("="); + + if (equalIndex === -1) { + this.logger.warn(`Invalid variable format: ${varString} (missing =)`); + continue; + } + + const name = varString.substring(0, equalIndex); + const value = varString.substring(equalIndex + 1); + + if (name && value !== undefined) { + variables[name] = value; + this.logger.debug(`Setting variable ${name}=${value}`); + } else { + this.logger.warn(`Invalid variable format: ${varString}`); + } + } else if (varArgs[i] === "--var-file" && i + 1 < varArgs.length) { + const varFilePath = varArgs[i + 1]; + this.logger.debug(`Reading variables from file: ${varFilePath}`); + + if (!fs.existsSync(varFilePath)) { + throw new ValidationError(`Variables file not found: ${varFilePath}`, "VAR_FILE_NOT_FOUND"); + } + + try { + const fileContent = fs.readFileSync(varFilePath, "utf8"); + const fileVars = JSON.parse(fileContent); + this.logger.debug(`Loaded ${Object.keys(fileVars).length} variables from file`); + Object.assign(variables, fileVars); + } catch (err) { + throw new ValidationError( + `Failed to read variables file: ${varFilePath}`, + "VAR_FILE_READ_ERROR", + err + ); + } + } + } + + return { variables }; + } + + /** + * Get the API key from StepZen CLI + * + * @returns Promise resolving to the API key + */ + public async getApiKey(): Promise { + try { + this.logger.debug("Retrieving API key from StepZen CLI"); + + // Import services here to avoid circular dependency + const { services } = await import('./index.js'); + + // Use the CLI service's getApiKey method + const apiKey = await services.cli.getApiKey(); + + this.logger.debug("Successfully retrieved API key from CLI service"); + return apiKey; + } catch (err) { + throw new ValidationError( + "Failed to retrieve API key from StepZen CLI", + "API_KEY_RETRIEVAL_FAILED", + err + ); + } + } + + /** + * Load StepZen configuration and extract endpoint details + * + * @param projectRoot Path to the StepZen project root + * @returns Endpoint configuration + */ + public async loadEndpointConfig(projectRoot: string): Promise { + const configPath = path.join(projectRoot, FILE_PATTERNS.CONFIG_FILE); + this.logger.debug(`Looking for config file at: ${configPath}`); + + if (!fs.existsSync(configPath)) { + throw new ValidationError( + `StepZen configuration file not found at: ${configPath}`, + "CONFIG_NOT_FOUND" + ); + } + + let config: StepZenConfig; + try { + const configContent = fs.readFileSync(configPath, "utf8"); + + if (!configContent) { + throw new ValidationError("StepZen configuration file is empty", "EMPTY_CONFIG"); + } + + config = JSON.parse(configContent); + + if (!config || !config.endpoint) { + throw new ValidationError("Invalid StepZen configuration: missing endpoint", "MISSING_ENDPOINT"); + } + } catch (err) { + if (err instanceof ValidationError) { + throw err; + } + throw new ValidationError( + "Failed to parse StepZen configuration file", + "CONFIG_PARSE_ERROR", + err + ); + } + + // Parse endpoint to validate format + const endpointParts = config.endpoint.split("/"); + if (endpointParts.length < 2) { + throw new ValidationError( + `Invalid StepZen endpoint format: ${config.endpoint}`, + "INVALID_ENDPOINT_FORMAT" + ); + } + + // Import services here to avoid circular dependency + const { services } = await import('./index.js'); + + // Get account, domain, and API key from the CLI + const [account, domain, apiKey] = await Promise.all([ + services.cli.getAccount(), + services.cli.getDomain(), + this.getApiKey() + ]); + + // Construct the GraphQL endpoint URL using the correct pattern + const graphqlUrl = `https://${account}.${domain}/${config.endpoint}/graphql`; + + this.logger.debug(`Constructed GraphQL URL: ${graphqlUrl}`); + + return { graphqlUrl, apiKey }; + } + + /** + * Calculate SHA256 hash of document content for persisted documents + * + * @param documentContent The GraphQL document content + * @returns SHA256 hash in the format "sha256:hash" + */ + public calculateDocumentHash(documentContent: string): string { + if (!documentContent || typeof documentContent !== 'string') { + throw new ValidationError("Invalid document content for hash calculation", "INVALID_DOCUMENT_CONTENT"); + } + + const hash = crypto.createHash('sha256').update(documentContent, 'utf8').digest('hex'); + return `sha256:${hash}`; + } + + /** + * Execute a persisted document request via HTTP + * + * @param endpointConfig Endpoint configuration + * @param documentContent The GraphQL document content (used to calculate hash) + * @param variables Request variables + * @param operationName Optional operation name + * @param debugLevel Debug level for the request + * @returns Promise resolving to StepZen response + */ + public async executePersistedDocumentRequest( + endpointConfig: EndpointConfig, + documentContent: string, + variables: Record, + operationName?: string, + ): Promise { + this.logger.info("Making HTTP request to StepZen API for persisted document"); + + // Calculate the document hash as required by StepZen persisted documents + const documentId = this.calculateDocumentHash(documentContent); + this.logger.debug(`Calculated document hash: ${documentId}`); + + const requestBody = { + documentId, + operationName, + variables + }; + + return new Promise((resolve, reject) => { + const postData = JSON.stringify(requestBody); + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + 'Authorization': endpointConfig.apiKey ? `Apikey ${endpointConfig.apiKey}` : '' + } + }; + + // debug log the request details + this.logger.debug(`Request details: ${JSON.stringify(options)}`); + this.logger.debug(`Request body: ${postData}`); + this.logger.debug(`Request URL: ${endpointConfig.graphqlUrl}`); + + const req = https.request(endpointConfig.graphqlUrl, options, (res) => { + let responseData = ''; + + res.on('data', (chunk) => { + responseData += chunk; + }); + + res.on('end', () => { + try { + const json = JSON.parse(responseData); + resolve(json); + } catch (err) { + reject(new ValidationError( + "Failed to parse StepZen response", + "RESPONSE_PARSE_ERROR", + err + )); + } + }); + }); + + req.on('error', (err) => { + reject(new NetworkError( + "Failed to connect to StepZen API", + "API_CONNECTION_ERROR", + err + )); + }); + + req.write(postData); + req.end(); + }); + } + + /** + * Validate request options + * + * @param options Request options to validate + */ + public validateRequestOptions(options: RequestOptions): void { + if (!options || typeof options !== 'object') { + throw new ValidationError("Invalid request options provided", "INVALID_OPTIONS"); + } + + const { queryText, documentContent, operationName, varArgs } = options; + + // Validate at least one of queryText or documentContent is provided and valid + if (documentContent === undefined && (!queryText || typeof queryText !== 'string')) { + throw new ValidationError("Invalid request: either documentContent or queryText must be provided", "MISSING_QUERY"); + } + + // Validate operationName if provided + if (operationName !== undefined && typeof operationName !== 'string') { + throw new ValidationError("Invalid operation name provided", "INVALID_OPERATION_NAME"); + } + + // Validate varArgs is an array + if (varArgs !== undefined && !Array.isArray(varArgs)) { + throw new ValidationError("Invalid variable arguments: expected an array", "INVALID_VAR_ARGS"); + } + } +} \ No newline at end of file diff --git a/src/test/unit/services/cli.test.ts b/src/test/unit/services/cli.test.ts index daa06d4..23a7315 100644 --- a/src/test/unit/services/cli.test.ts +++ b/src/test/unit/services/cli.test.ts @@ -92,4 +92,43 @@ suite('StepzenCliService', () => { assert.ok(service, 'Service should be defined'); }); }); + + suite('getApiKey', () => { + test('should execute stepzen whoami --apikey', async () => { + // This would verify the CLI service properly: + // - Calls spawn with ['whoami', '--apikey'] + // - Returns the trimmed result + // - Handles empty responses with appropriate error + + // In real implementation this would have actual test logic + assert.ok(service, 'Service should be defined'); + assert.strictEqual(typeof service.getApiKey, 'function', 'getApiKey should be a function'); + }); + }); + + suite('getAccount', () => { + test('should execute stepzen whoami --account', async () => { + // This would verify the CLI service properly: + // - Calls spawn with ['whoami', '--account'] + // - Returns the trimmed result + // - Handles empty responses with appropriate error + + // In real implementation this would have actual test logic + assert.ok(service, 'Service should be defined'); + assert.strictEqual(typeof service.getAccount, 'function', 'getAccount should be a function'); + }); + }); + + suite('getDomain', () => { + test('should execute stepzen whoami --domain', async () => { + // This would verify the CLI service properly: + // - Calls spawn with ['whoami', '--domain'] + // - Returns the trimmed result + // - Handles empty responses with appropriate error + + // In real implementation this would have actual test logic + assert.ok(service, 'Service should be defined'); + assert.strictEqual(typeof service.getDomain, 'function', 'getDomain should be a function'); + }); + }); }); \ No newline at end of file diff --git a/src/test/unit/services/request.test.ts b/src/test/unit/services/request.test.ts new file mode 100644 index 0000000..4b24bf2 --- /dev/null +++ b/src/test/unit/services/request.test.ts @@ -0,0 +1,186 @@ +/** + * Copyright IBM Corp. 2025 + * Assisted by CursorAI + */ + +import * as assert from 'assert'; +import { RequestService } from '../../../services/request'; +import { Logger } from '../../../services/logger'; +import { ValidationError } from '../../../errors'; +import { createMock } from '../../helpers/test-utils'; + +suite('RequestService', () => { + let requestService: RequestService; + let mockLogger: Logger; + + setup(() => { + mockLogger = createMock({ + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {} + }); + requestService = new RequestService(mockLogger); + }); + + suite('parseVariables', () => { + test('should parse --var arguments correctly', () => { + const varArgs = ['--var', 'name=value', '--var', 'count=42']; + const result = requestService.parseVariables(varArgs); + + assert.deepStrictEqual(result.variables, { + name: 'value', + count: '42' + }); + }); + + test('should throw error for invalid varArgs type', () => { + assert.throws(() => { + requestService.parseVariables('invalid' as any); + }, ValidationError); + }); + + test('should handle malformed variable format gracefully', () => { + const varArgs = ['--var', 'invalidformat', '--var', 'valid=value']; + const result = requestService.parseVariables(varArgs); + + // Should only include the valid variable + assert.deepStrictEqual(result.variables, { + valid: 'value' + }); + }); + + test('should return empty variables for empty array', () => { + const result = requestService.parseVariables([]); + assert.deepStrictEqual(result.variables, {}); + }); + + test('should handle variables with equals signs in values', () => { + const varArgs = ['--var', 'url=https://example.com/path?param=value']; + const result = requestService.parseVariables(varArgs); + + assert.deepStrictEqual(result.variables, { + url: 'https://example.com/path?param=value' + }); + }); + }); + + suite('loadEndpointConfig', () => { + test('should have loadEndpointConfig method', () => { + // Just verify the method exists - file system tests would require complex mocking + assert.strictEqual(typeof requestService.loadEndpointConfig, 'function'); + }); + }); + + suite('getApiKey', () => { + test('should have getApiKey method', () => { + // Just verify the method exists - CLI tests would require complex mocking + assert.strictEqual(typeof requestService.getApiKey, 'function'); + }); + }); + + suite('validateRequestOptions', () => { + test('should validate valid options with queryText', () => { + const options = { + queryText: 'query { hello }', + operationName: 'GetHello', + varArgs: ['--var', 'name=value'] + }; + + // Should not throw + requestService.validateRequestOptions(options); + }); + + test('should validate valid options with documentContent', () => { + const options = { + documentContent: 'query GetHello { hello }', + operationName: 'GetHello', + varArgs: ['--var', 'name=value'] + }; + + // Should not throw + requestService.validateRequestOptions(options); + }); + + test('should throw error for invalid options object', () => { + assert.throws(() => { + requestService.validateRequestOptions(null as any); + }, ValidationError); + + assert.throws(() => { + requestService.validateRequestOptions('invalid' as any); + }, ValidationError); + }); + + test('should throw error when neither queryText nor documentContent provided', () => { + const options = { + operationName: 'GetHello' + }; + + assert.throws(() => { + requestService.validateRequestOptions(options); + }, ValidationError); + }); + + test('should throw error for invalid operationName type', () => { + const options = { + queryText: 'query { hello }', + operationName: 123 as any + }; + + assert.throws(() => { + requestService.validateRequestOptions(options); + }, ValidationError); + }); + + test('should throw error for invalid varArgs type', () => { + const options = { + queryText: 'query { hello }', + varArgs: 'invalid' as any + }; + + assert.throws(() => { + requestService.validateRequestOptions(options); + }, ValidationError); + }); + + test('should allow undefined optional fields', () => { + const options = { + queryText: 'query { hello }', + operationName: undefined, + varArgs: undefined + }; + + // Should not throw + requestService.validateRequestOptions(options); + }); + }); + + suite('calculateDocumentHash', () => { + test('should calculate SHA256 hash correctly', () => { + const documentContent = '{\n __typename\n}\n'; + const result = requestService.calculateDocumentHash(documentContent); + + // This should match the hash from the StepZen documentation example + assert.strictEqual(result, 'sha256:8d8f7365e9e86fa8e3313fcaf2131b801eafe9549de22373089cf27511858b39'); + }); + + test('should throw error for invalid document content', () => { + assert.throws(() => { + requestService.calculateDocumentHash(''); + }, ValidationError); + + assert.throws(() => { + requestService.calculateDocumentHash(null as any); + }, ValidationError); + }); + }); + + suite('executePersistedDocumentRequest', () => { + test('should handle successful HTTP request', async () => { + // This test would require mocking the https module + // For now, we'll just verify the method exists and has the correct signature + assert.strictEqual(typeof requestService.executePersistedDocumentRequest, 'function'); + }); + }); +}); \ No newline at end of file diff --git a/src/test/unit/services/service-registry.test.ts b/src/test/unit/services/service-registry.test.ts index c56b2cf..8343a12 100644 --- a/src/test/unit/services/service-registry.test.ts +++ b/src/test/unit/services/service-registry.test.ts @@ -5,6 +5,7 @@ import { StepzenCliService } from '../../../services/cli'; import { Logger } from '../../../services/logger'; import { ProjectResolver } from '../../../services/projectResolver'; import { SchemaIndexService } from '../../../services/SchemaIndexService'; +import { RequestService } from '../../../services/request'; suite('Service Registry', () => { let originalServices: ServiceRegistry; @@ -24,18 +25,22 @@ suite('Service Registry', () => { setMockServices(originalServices); }); - test('services should contain cli, logger, projectResolver, and schemaIndex by default', () => { + test('services should contain cli, logger, projectResolver, schemaIndex, and request by default', () => { assert.ok(services.cli instanceof StepzenCliService, 'CLI service should be an instance of StepzenCliService'); assert.ok(services.logger instanceof Logger, 'Logger should be an instance of Logger'); assert.ok(services.projectResolver instanceof ProjectResolver, 'ProjectResolver should be an instance of ProjectResolver'); assert.ok(services.schemaIndex instanceof SchemaIndexService, 'SchemaIndex service should be an instance of SchemaIndexService'); + assert.ok(services.request instanceof RequestService, 'Request service should be an instance of RequestService'); }); test('overrideServices should replace individual services', () => { // Create a mock CLI service const mockCli = createMock({ deploy: async () => { /* mock implementation */ }, - request: async () => 'mock response' + request: async () => 'mock response', + getApiKey: async () => 'mock-api-key', + getAccount: async () => 'mock-account', + getDomain: async () => 'mock.stepzen.net' }); // Store the original CLI service @@ -66,7 +71,10 @@ suite('Service Registry', () => { const mockServices: ServiceRegistry = { cli: createMock({ deploy: async () => { /* mock implementation */ }, - request: async () => 'mock response from complete mock' + request: async () => 'mock response from complete mock', + getApiKey: async () => 'mock-api-key', + getAccount: async () => 'mock-account', + getDomain: async () => 'mock.stepzen.net' }), logger: createMock({ info: () => { /* mock implementation */ }, @@ -90,6 +98,14 @@ suite('Service Registry', () => { getTypeDirectives: () => ({}), getTypeRelationships: () => [], computeHash: () => 'mock-hash' + }), + request: createMock({ + parseVariables: () => ({ variables: {} }), + getApiKey: async () => 'mock-api-key', + loadEndpointConfig: async () => ({ graphqlUrl: 'https://mock-account.mock.stepzen.net/mock-endpoint/graphql', apiKey: 'mock-key' }), + executePersistedDocumentRequest: async () => ({ data: {} }), + validateRequestOptions: () => { /* mock implementation */ }, + calculateDocumentHash: () => 'sha256:mockhash' }) }; @@ -101,12 +117,14 @@ suite('Service Registry', () => { assert.strictEqual(services.logger, mockServices.logger, 'Logger service should be replaced with mock'); assert.strictEqual(services.projectResolver, mockServices.projectResolver, 'ProjectResolver service should be replaced with mock'); assert.strictEqual(services.schemaIndex, mockServices.schemaIndex, 'SchemaIndex service should be replaced with mock'); + assert.strictEqual(services.request, mockServices.request, 'Request service should be replaced with mock'); // Verify that previous contains all original services assert.strictEqual(previous.cli, originalServices.cli, 'previous should contain original CLI service'); assert.strictEqual(previous.logger, originalServices.logger, 'previous should contain original logger service'); assert.strictEqual(previous.projectResolver, originalServices.projectResolver, 'previous should contain original ProjectResolver service'); assert.strictEqual(previous.schemaIndex, originalServices.schemaIndex, 'previous should contain original SchemaIndex service'); + assert.strictEqual(previous.request, originalServices.request, 'previous should contain original Request service'); // Reset to original services setMockServices(previous); @@ -116,6 +134,7 @@ suite('Service Registry', () => { assert.strictEqual(services.logger, originalServices.logger, 'Logger service should be restored'); assert.strictEqual(services.projectResolver, originalServices.projectResolver, 'ProjectResolver service should be restored'); assert.strictEqual(services.schemaIndex, originalServices.schemaIndex, 'SchemaIndex service should be restored'); + assert.strictEqual(services.request, originalServices.request, 'Request service should be restored'); }); test('mocked service should be usable in place of real service', async () => {