feat: complete Epic 1 production foundation
This commit is contained in:
@@ -36,7 +36,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local";
|
||||
const TEST_AUTH_PASSWORD: &str = "test-password";
|
||||
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
||||
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
@@ -123,15 +123,6 @@ async fn creates_publishes_and_tests_rest_operation() {
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let published = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let test_run_response = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.header("x-request-id", "req_admin_test_run")
|
||||
@@ -151,6 +142,19 @@ async fn creates_publishes_and_tests_rest_operation() {
|
||||
"0af7651916cd43dd8448eb211c80319c"
|
||||
);
|
||||
let test_run = test_run_response.json::<Value>().await.unwrap();
|
||||
let published = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, &operation_id).await,
|
||||
)
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(listed["items"][0]["name"], "crm_create_lead");
|
||||
assert_eq!(
|
||||
@@ -160,20 +164,24 @@ async fn creates_publishes_and_tests_rest_operation() {
|
||||
assert_eq!(listed["items"][0]["target_action"], "POST");
|
||||
assert_eq!(published["published_version"], 1);
|
||||
assert_eq!(test_run["ok"], true);
|
||||
assert_eq!(
|
||||
test_run["request_preview"]["body"]["email"],
|
||||
"user@example.com"
|
||||
);
|
||||
assert_eq!(test_run["request_preview"]["body_configured"], true);
|
||||
let safe_preview = test_run["request_preview"].to_string();
|
||||
assert!(!safe_preview.contains("user@example.com"));
|
||||
assert_eq!(test_run["response_preview"]["id"], "lead_123");
|
||||
let logs = registry
|
||||
.list_invocation_logs(crank_registry::ListInvocationLogsQuery {
|
||||
workspace_id: &WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
level: None,
|
||||
status: None,
|
||||
outcome_group: None,
|
||||
search_text: None,
|
||||
source: Some(crank_core::InvocationSource::AdminTestRun),
|
||||
operation_id: Some(&crank_core::OperationId::new(&operation_id)),
|
||||
agent_id: None,
|
||||
created_after: None,
|
||||
created_before: None,
|
||||
cursor_created_at: None,
|
||||
cursor_id: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
@@ -267,6 +275,10 @@ async fn updates_archives_and_deletes_operation() {
|
||||
|
||||
let updated = client
|
||||
.patch(format!("{base_url}/operations/{operation_id}"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, &operation_id).await,
|
||||
)
|
||||
.json(&json!({
|
||||
"display_name": "Create Lead Updated",
|
||||
"category": "marketing",
|
||||
@@ -350,6 +362,10 @@ async fn updates_archives_and_deletes_operation() {
|
||||
|
||||
let archived = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/archive"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, &operation_id).await,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -360,29 +376,29 @@ async fn updates_archives_and_deletes_operation() {
|
||||
|
||||
let deleted = client
|
||||
.delete(format!("{base_url}/operations/{operation_id}"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, &operation_id).await,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(deleted.status(), reqwest::StatusCode::CONFLICT);
|
||||
let deleted = deleted.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
deleted["error"]["context"]["error_code"],
|
||||
"operation_delete_forbidden"
|
||||
);
|
||||
|
||||
let retained = client
|
||||
.get(format!("{base_url}/operations/{operation_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(deleted["operation_id"], operation_id);
|
||||
|
||||
let missing = client
|
||||
.get(format!("{base_url}/operations/{operation_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let missing_status = missing.status();
|
||||
let missing = missing.json::<Value>().await.unwrap();
|
||||
assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND);
|
||||
assert_eq!(missing["error"]["code"], "not_found");
|
||||
assert_eq!(
|
||||
missing["error"]["context"],
|
||||
json!({
|
||||
"operation_id": operation_id
|
||||
})
|
||||
);
|
||||
assert_eq!(retained["status"], "archived");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -408,6 +424,10 @@ async fn creates_binds_and_publishes_agent() {
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, &operation_id).await,
|
||||
)
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
@@ -432,6 +452,10 @@ async fn creates_binds_and_publishes_agent() {
|
||||
|
||||
let bindings = client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!([
|
||||
{
|
||||
"operation_id": operation_id,
|
||||
@@ -449,6 +473,10 @@ async fn creates_binds_and_publishes_agent() {
|
||||
.unwrap();
|
||||
let published = client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
@@ -489,6 +517,10 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() {
|
||||
assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, &operation_id).await,
|
||||
)
|
||||
.json(&json!({"version": 1}))
|
||||
.send()
|
||||
.await
|
||||
@@ -535,6 +567,10 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() {
|
||||
let saved = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&catalog)
|
||||
.send()
|
||||
.await
|
||||
@@ -565,6 +601,10 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() {
|
||||
let published = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!({"version": 1}))
|
||||
.send()
|
||||
.await
|
||||
@@ -576,7 +616,7 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
|
||||
async fn agent_binding_rejects_draft_operation_before_publish() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_publish_filters_drafts");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
@@ -604,6 +644,10 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
|
||||
.post(format!(
|
||||
"{base_url}/operations/{published_operation_id}/publish"
|
||||
))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, &published_operation_id).await,
|
||||
)
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
@@ -642,9 +686,47 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
|
||||
.await;
|
||||
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let rejected = client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!([
|
||||
{
|
||||
"operation_id": published_operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "crm_published_tool",
|
||||
"tool_title": "Published Tool",
|
||||
"tool_description_override": null,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"operation_id": draft_operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "crm_draft_tool",
|
||||
"tool_title": "Draft Tool",
|
||||
"tool_description_override": null,
|
||||
"enabled": true
|
||||
}
|
||||
]))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rejected.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let rejected_body = rejected.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
rejected_body["error"]["code"],
|
||||
"agent_binding_not_published"
|
||||
);
|
||||
|
||||
assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!([
|
||||
{
|
||||
"operation_id": published_operation_id,
|
||||
@@ -653,14 +735,6 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
|
||||
"tool_title": "Published Tool",
|
||||
"tool_description_override": null,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"operation_id": draft_operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "crm_draft_tool",
|
||||
"tool_title": "Draft Tool",
|
||||
"tool_description_override": null,
|
||||
"enabled": true
|
||||
}
|
||||
]))
|
||||
.send()
|
||||
@@ -672,6 +746,10 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
|
||||
let published = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
@@ -694,24 +772,15 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let draft_version = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}/versions/2"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(published["published_version"], 1);
|
||||
assert_eq!(agent_detail["current_draft_version"], 2);
|
||||
assert_eq!(agent_detail["current_draft_version"], 1);
|
||||
assert_eq!(agent_detail["latest_published_version"], 1);
|
||||
assert_eq!(published_version["bindings"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
published_version["bindings"][0]["operation_id"],
|
||||
published_operation_id
|
||||
);
|
||||
assert_eq!(draft_version["bindings"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -737,6 +806,10 @@ async fn updates_lists_and_deletes_agent() {
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, &operation_id).await,
|
||||
)
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
@@ -761,6 +834,10 @@ async fn updates_lists_and_deletes_agent() {
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!([
|
||||
{
|
||||
"operation_id": operation_id,
|
||||
@@ -777,6 +854,10 @@ async fn updates_lists_and_deletes_agent() {
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
@@ -800,6 +881,10 @@ async fn updates_lists_and_deletes_agent() {
|
||||
|
||||
let updated = client
|
||||
.patch(format!("{base_url}/agents/{agent_id}"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!({
|
||||
"slug": "support-escalation",
|
||||
"display_name": "Support Escalation",
|
||||
@@ -807,11 +892,17 @@ async fn updates_lists_and_deletes_agent() {
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated["agent_id"], agent_id);
|
||||
assert_eq!(
|
||||
updated.status(),
|
||||
reqwest::StatusCode::CONFLICT,
|
||||
"published Agent summary/slug must not mutate published MCP endpoint identity"
|
||||
);
|
||||
let updated = updated.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
updated["error"]["context"]["error_code"],
|
||||
"agent_published_summary_immutable"
|
||||
);
|
||||
|
||||
let detail = client
|
||||
.get(format!("{base_url}/agents/{agent_id}"))
|
||||
@@ -821,148 +912,35 @@ async fn updates_lists_and_deletes_agent() {
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detail["slug"], "support-escalation");
|
||||
assert_eq!(detail["display_name"], "Support Escalation");
|
||||
assert_eq!(detail["slug"], "support-team");
|
||||
assert_eq!(detail["display_name"], "Support Team");
|
||||
assert_eq!(detail["operation_count"], 1);
|
||||
assert_eq!(detail["mcp_endpoint"], "/mcp/v1/default/support-escalation");
|
||||
assert_eq!(detail["mcp_endpoint"], "/mcp/v1/default/support-team");
|
||||
|
||||
let deleted = client
|
||||
let delete_response = client
|
||||
.delete(format!("{base_url}/agents/{agent_id}"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(deleted["agent_id"], agent_id);
|
||||
assert_eq!(
|
||||
delete_response.status(),
|
||||
reqwest::StatusCode::CONFLICT,
|
||||
"published Agent must not be hard-deleted because immutable versions/history must remain"
|
||||
);
|
||||
let delete_body = delete_response.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
delete_body["error"]["context"]["error_code"],
|
||||
"agent_delete_forbidden"
|
||||
);
|
||||
|
||||
let missing = client
|
||||
let still_present = client
|
||||
.get(format!("{base_url}/agents/{agent_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let missing_status = missing.status();
|
||||
let missing = missing.json::<Value>().await.unwrap();
|
||||
assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND);
|
||||
assert_eq!(missing["error"]["code"], "not_found");
|
||||
assert_eq!(
|
||||
missing["error"]["context"],
|
||||
json!({
|
||||
"agent_id": agent_id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn unpublishes_and_archives_agent() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_statuses");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let operation = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_create_lead_agent_status",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let created = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.json(&json!({
|
||||
"slug": "sales-routing",
|
||||
"display_name": "Sales Routing",
|
||||
"description": "Routing agent",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let agent_id = created["agent_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.json(&json!([
|
||||
{
|
||||
"operation_id": operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "crm_create_lead_agent_status",
|
||||
"tool_title": "Create Lead",
|
||||
"tool_description_override": null,
|
||||
"enabled": true
|
||||
}
|
||||
]))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let unpublished = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/unpublish"))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unpublished["agent_id"], agent_id);
|
||||
|
||||
let draft_detail = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(draft_detail["status"], "draft");
|
||||
assert_eq!(draft_detail["latest_published_version"], 1);
|
||||
|
||||
let archived = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/archive"))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(archived["agent_id"], agent_id);
|
||||
|
||||
let archived_detail = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(archived_detail["status"], "archived");
|
||||
assert_eq!(still_present.status(), reqwest::StatusCode::OK);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user