diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 78c300e..e657030 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -221,18 +221,19 @@ mod tests { use axum::{Json, Router, routing::post}; use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ - DescriptorId, ExecutionConfig, ExecutionMode, GraphqlOperationType, GraphqlTarget, - GrpcTarget, HttpMethod, MembershipRole, Protocol, RestTarget, SecretKind, SoapBindingStyle, - SoapOperationMetadata, SoapTarget, SoapVersion, Target, ToolDescription, TransportBehavior, - WorkspaceId, + AsyncJobHandle, AsyncJobId, DescriptorId, ExecutionConfig, ExecutionMode, + GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, JobStatus, MembershipRole, + OperationId, Protocol, RestTarget, SecretKind, SoapBindingStyle, SoapOperationMetadata, + SoapTarget, SoapVersion, Target, ToolDescription, TransportBehavior, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; - use crank_registry::PostgresRegistry; + use crank_registry::{CreateAsyncJobRequest, PostgresRegistry}; use crank_runtime::SecretCrypto; use crank_schema::{Schema, SchemaKind}; use serde_json::{Value, json}; use serial_test::serial; use sqlx::{Connection, Executor, PgConnection}; + use time::OffsetDateTime; use tokio::net::TcpListener; use crate::{ @@ -1613,6 +1614,69 @@ mod tests { assert_eq!(result["id"], "lead_123"); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn returns_structured_context_for_pending_async_job_result() { + let registry = test_registry().await; + let storage_root = test_storage_root("async_job_pending_result"); + let upstream_base_url = spawn_upstream_server().await; + let job_id = AsyncJobId::new("job_pending_result".to_owned()); + let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; + let client = authorized_client(&base_url).await; + let created = client + .post(format!("{base_url}/operations")) + .json(&test_rest_async_job_operation_payload( + &upstream_base_url, + "crm_async_job_pending_result", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); + let now = OffsetDateTime::now_utc(); + registry + .create_async_job(CreateAsyncJobRequest { + job: &AsyncJobHandle { + id: job_id.clone(), + workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), + agent_id: None, + operation_id, + status: JobStatus::Running, + progress: json!({ "pct": 25 }), + result: None, + error: None, + expires_at: Some(now + time::Duration::minutes(5)), + created_at: now, + updated_at: now, + finished_at: None, + }, + }) + .await + .unwrap(); + + let response = client + .get(format!("{base_url}/async-jobs/{}/result", job_id.as_str())) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::CONFLICT); + assert_eq!(body["error"]["code"], "conflict"); + assert_eq!(body["error"]["message"], "async job result is not ready"); + assert_eq!( + body["error"]["context"], + json!({ + "job_id": job_id.as_str(), + "status": "running" + }) + ); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn uploads_wsdl_and_tests_soap_operation() { @@ -1684,6 +1748,52 @@ mod tests { assert_eq!(test_run["response_preview"]["id"], "lead_123"); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn returns_structured_context_when_wsdl_is_missing() { + let registry = test_registry().await; + let storage_root = test_storage_root("soap_missing_wsdl"); + let endpoint = spawn_soap_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_soap_operation_payload( + &endpoint, + "crm_create_lead_soap_missing_wsdl", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let response = client + .get(format!( + "{base_url}/operations/{operation_id}/soap/services" + )) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::NOT_FOUND); + assert_eq!(body["error"]["code"], "not_found"); + assert_eq!(body["error"]["message"], "wsdl was not uploaded"); + assert_eq!( + body["error"]["context"], + json!({ + "operation_id": operation_id, + "version": 1, + "descriptor_kind": "wsdl_upload" + }) + ); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_auth_profiles_and_yaml_upsert() { diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index d99cbf8..23a9369 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -90,21 +90,21 @@ impl ApiError { } } - fn validation_with_context(message: impl Into, context: Value) -> Self { + pub(crate) fn validation_with_context(message: impl Into, context: Value) -> Self { Self::Validation { message: message.into(), context: Some(context), } } - fn not_found_with_context(message: impl Into, context: Value) -> Self { + pub(crate) fn not_found_with_context(message: impl Into, context: Value) -> Self { Self::NotFound { message: message.into(), context: Some(context), } } - fn conflict_with_context(message: impl Into, context: Value) -> Self { + pub(crate) fn conflict_with_context(message: impl Into, context: Value) -> Self { Self::Conflict { message: message.into(), context: Some(context), diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index ed26753..3958a1c 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -1611,7 +1611,10 @@ impl AdminService { ) -> Result { self.ensure_workspace_exists(workspace_id).await?; let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| { - ApiError::not_found(format!("async job {} was not found", job_id.as_str())) + ApiError::not_found_with_context( + format!("async job {} was not found", job_id.as_str()), + json!({ "job_id": job_id.as_str() }), + ) })?; ensure_async_job_workspace(&job, workspace_id)?; @@ -1626,14 +1629,20 @@ impl AdminService { ) -> Result { self.ensure_workspace_exists(workspace_id).await?; let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| { - ApiError::not_found(format!("async job {} was not found", job_id.as_str())) + ApiError::not_found_with_context( + format!("async job {} was not found", job_id.as_str()), + json!({ "job_id": job_id.as_str() }), + ) })?; ensure_async_job_workspace(&job, workspace_id)?; self.registry .cancel_async_job(job_id, &OffsetDateTime::now_utc()) .await?; let updated = self.registry.get_async_job(job_id).await?.ok_or_else(|| { - ApiError::not_found(format!("async job {} was not found", job_id.as_str())) + ApiError::not_found_with_context( + format!("async job {} was not found", job_id.as_str()), + json!({ "job_id": job_id.as_str() }), + ) })?; Ok(async_job_detail_view(updated)) @@ -1647,15 +1656,44 @@ impl AdminService { ) -> Result { self.ensure_workspace_exists(workspace_id).await?; let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| { - ApiError::not_found(format!("async job {} was not found", job_id.as_str())) + ApiError::not_found_with_context( + format!("async job {} was not found", job_id.as_str()), + json!({ "job_id": job_id.as_str() }), + ) })?; ensure_async_job_workspace(&job, workspace_id)?; match job.status { JobStatus::Completed => Ok(job.result.unwrap_or(Value::Null)), - JobStatus::Failed => Err(ApiError::conflict("async job failed")), - JobStatus::Cancelled => Err(ApiError::conflict("async job was cancelled")), - _ => Err(ApiError::conflict("async job result is not ready")), + JobStatus::Failed => Err(ApiError::conflict_with_context( + "async job failed", + json!({ + "job_id": job_id.as_str(), + "status": "failed", + "error": job.error, + }), + )), + JobStatus::Cancelled => Err(ApiError::conflict_with_context( + "async job was cancelled", + json!({ + "job_id": job_id.as_str(), + "status": "cancelled", + }), + )), + _ => Err(ApiError::conflict_with_context( + "async job result is not ready", + json!({ + "job_id": job_id.as_str(), + "status": match job.status { + JobStatus::Created => "created", + JobStatus::Running => "running", + JobStatus::Completed => "completed", + JobStatus::Failed => "failed", + JobStatus::Cancelled => "cancelled", + JobStatus::Expired => "expired", + }, + }), + )), } } @@ -3055,7 +3093,16 @@ impl AdminService { .find(|descriptor| { descriptor.descriptor_kind == crank_registry::DescriptorKind::DescriptorSet }) - .ok_or_else(|| ApiError::not_found("descriptor set was not uploaded"))?; + .ok_or_else(|| { + ApiError::not_found_with_context( + "descriptor set was not uploaded", + json!({ + "operation_id": operation_id.as_str(), + "version": version, + "descriptor_kind": "descriptor_set", + }), + ) + })?; let bytes = self.storage.read_bytes(&descriptor.storage_ref).await?; let services = services_from_descriptor_set_bytes(&bytes) .map_err(|error| ApiError::validation(error.to_string()))?; @@ -3207,10 +3254,26 @@ impl AdminService { .find(|descriptor| { descriptor.descriptor_kind == crank_registry::DescriptorKind::WsdlUpload }) - .ok_or_else(|| ApiError::not_found("wsdl was not uploaded"))?; - let package_index = descriptor - .package_index - .ok_or_else(|| ApiError::not_found("wsdl inspection metadata is not available"))?; + .ok_or_else(|| { + ApiError::not_found_with_context( + "wsdl was not uploaded", + json!({ + "operation_id": operation_id.as_str(), + "version": version, + "descriptor_kind": "wsdl_upload", + }), + ) + })?; + let package_index = descriptor.package_index.ok_or_else(|| { + ApiError::not_found_with_context( + "wsdl inspection metadata is not available", + json!({ + "operation_id": operation_id.as_str(), + "version": version, + "descriptor_kind": "wsdl_upload", + }), + ) + })?; serde_json::from_value(package_index).map_err(|error| ApiError::internal(error.to_string())) } @@ -3225,10 +3288,10 @@ impl AdminService { .get_auth_profile(workspace_id, auth_profile_id) .await? .ok_or_else(|| { - ApiError::not_found(format!( - "auth profile {} was not found", - auth_profile_id.as_str() - )) + ApiError::not_found_with_context( + format!("auth profile {} was not found", auth_profile_id.as_str()), + json!({ "auth_profile_id": auth_profile_id.as_str() }), + ) }) }