mod helpers; use serde_json::Value; /// Recursively mask dynamic fields (timestamps, durations, versions) for stable comparison. fn normalize_value(value: &mut Value) { match value { Value::Object(map) => { for (key, val) in map.iter_mut() { match key.as_str() { "duration_ms" if val.is_number() => *val = Value::Number(0.into()), "tool_version" if val.is_string() => *val = Value::String("MASKED".into()), "cached_at" | "fetched_at" | "last_accessed" if val.is_string() => { *val = Value::String("MASKED_TIMESTAMP".into()) } _ => normalize_value(val), } } } Value::Array(arr) => { for item in arr.iter_mut() { normalize_value(item); } } _ => {} } } /// Normalize robot JSON for golden comparison: mask all dynamic fields. fn normalize_robot(mut json: Value) -> Value { normalize_value(&mut json); json } /// Assert that a robot success envelope has the correct structural invariants. fn assert_robot_success_structure(json: &Value, expected_command: &str) { // "ok" must be bool true assert_eq!( json.get("ok").and_then(|v| v.as_bool()), Some(true), "ok field must be true" ); // "data" must be an object assert!( json.get("data").is_some_and(|v| v.is_object()), "data field must be an object" ); // "meta" must be an object with required fields let meta = json.get("meta").expect("meta field is required"); assert!(meta.is_object(), "meta must be an object"); // meta.schema_version must be number == 1 let sv = meta .get("schema_version") .expect("meta.schema_version required"); assert!(sv.is_number(), "meta.schema_version must be a number"); assert_eq!(sv.as_u64(), Some(1), "meta.schema_version must equal 1"); // meta.tool_version must be a string let tv = meta .get("tool_version") .expect("meta.tool_version required"); assert!(tv.is_string(), "meta.tool_version must be a string"); // meta.command must be a string matching expected let cmd = meta.get("command").expect("meta.command required"); assert!(cmd.is_string(), "meta.command must be a string"); assert_eq!( cmd.as_str().unwrap(), expected_command, "meta.command must match the command run" ); // meta.duration_ms must be a number let dur = meta.get("duration_ms").expect("meta.duration_ms required"); assert!(dur.is_number(), "meta.duration_ms must be a number"); } /// Assert that a robot error envelope has the correct structural invariants. fn assert_robot_error_structure(json: &Value) { // "ok" must be bool false assert_eq!( json.get("ok").and_then(|v| v.as_bool()), Some(false), "ok field must be false on error" ); // "data" should be absent or null let data = json.get("data"); assert!( data.is_none() || data.unwrap().is_null(), "data field must be null or absent on error" ); // "error" must be an object with code and message let error = json.get("error").expect("error field is required"); assert!(error.is_object(), "error must be an object"); assert!( error.get("code").is_some_and(|v| v.is_string()), "error.code must be a string" ); assert!( error.get("message").is_some_and(|v| v.is_string()), "error.message must be a string" ); // "meta" structural invariants (same as success) let meta = json.get("meta").expect("meta field is required"); assert!(meta.is_object(), "meta must be an object"); assert!( meta.get("schema_version").is_some_and(|v| v.is_number()), "meta.schema_version must be a number" ); assert!( meta.get("tool_version").is_some_and(|v| v.is_string()), "meta.tool_version must be a string" ); assert!( meta.get("command").is_some_and(|v| v.is_string()), "meta.command must be a string" ); assert!( meta.get("duration_ms").is_some_and(|v| v.is_number()), "meta.duration_ms must be a number" ); } /// Load a golden snapshot file, parse as JSON. fn load_golden(name: &str) -> Value { let path = helpers::fixture_path(&format!("golden/{name}")); let content = std::fs::read_to_string(&path) .unwrap_or_else(|e| panic!("failed to read golden file {}: {e}", path.display())); serde_json::from_str(&content) .unwrap_or_else(|e| panic!("failed to parse golden file {}: {e}", path.display())) } /// Write a golden snapshot file if it does not exist (first-run bootstrap). fn write_golden_if_missing(name: &str, value: &Value) { let path = helpers::fixture_path(&format!("golden/{name}")); if !path.exists() { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).expect("failed to create golden dir"); } let pretty = serde_json::to_string_pretty(value).expect("failed to serialize golden snapshot"); std::fs::write(&path, pretty).expect("failed to write golden snapshot"); } } // --------------------------------------------------------------------------- // Golden structure tests // --------------------------------------------------------------------------- #[test] fn test_golden_list_robot_structure() { let env = helpers::TestEnv::new(); helpers::fetch_fixture(&env, "petstore.json", "petstore"); let a = helpers::run_cmd(&env, &["list", "petstore", "--robot"]).success(); let json = helpers::parse_robot_json(&a.get_output().stdout); assert_robot_success_structure(&json, "list"); // Snapshot comparison let normalized = normalize_robot(json); write_golden_if_missing("list.json", &normalized); let golden = load_golden("list.json"); assert_eq!( normalized, golden, "list robot output does not match golden snapshot" ); } #[test] fn test_golden_show_robot_structure() { let env = helpers::TestEnv::new(); helpers::fetch_fixture(&env, "petstore.json", "petstore"); let a = helpers::run_cmd( &env, &["show", "petstore", "/pets", "--method", "GET", "--robot"], ) .success(); let json = helpers::parse_robot_json(&a.get_output().stdout); assert_robot_success_structure(&json, "show"); let normalized = normalize_robot(json); write_golden_if_missing("show.json", &normalized); let golden = load_golden("show.json"); assert_eq!( normalized, golden, "show robot output does not match golden snapshot" ); } #[test] fn test_golden_search_robot_structure() { let env = helpers::TestEnv::new(); helpers::fetch_fixture(&env, "petstore.json", "petstore"); let a = helpers::run_cmd(&env, &["search", "petstore", "pet", "--robot"]).success(); let json = helpers::parse_robot_json(&a.get_output().stdout); assert_robot_success_structure(&json, "search"); let normalized = normalize_robot(json); write_golden_if_missing("search.json", &normalized); let golden = load_golden("search.json"); assert_eq!( normalized, golden, "search robot output does not match golden snapshot" ); } #[test] fn test_golden_schemas_list_robot_structure() { let env = helpers::TestEnv::new(); helpers::fetch_fixture(&env, "petstore.json", "petstore"); let a = helpers::run_cmd(&env, &["schemas", "petstore", "--robot"]).success(); let json = helpers::parse_robot_json(&a.get_output().stdout); assert_robot_success_structure(&json, "schemas"); let normalized = normalize_robot(json); write_golden_if_missing("schemas_list.json", &normalized); let golden = load_golden("schemas_list.json"); assert_eq!( normalized, golden, "schemas list robot output does not match golden snapshot" ); } #[test] fn test_golden_schemas_show_robot_structure() { let env = helpers::TestEnv::new(); helpers::fetch_fixture(&env, "petstore.json", "petstore"); let a = helpers::run_cmd(&env, &["schemas", "petstore", "--show", "Pet", "--robot"]).success(); let json = helpers::parse_robot_json(&a.get_output().stdout); assert_robot_success_structure(&json, "schemas"); let normalized = normalize_robot(json); write_golden_if_missing("schemas_show.json", &normalized); let golden = load_golden("schemas_show.json"); assert_eq!( normalized, golden, "schemas show robot output does not match golden snapshot" ); } #[test] fn test_golden_tags_robot_structure() { let env = helpers::TestEnv::new(); helpers::fetch_fixture(&env, "petstore.json", "petstore"); let a = helpers::run_cmd(&env, &["tags", "petstore", "--robot"]).success(); let json = helpers::parse_robot_json(&a.get_output().stdout); assert_robot_success_structure(&json, "tags"); let normalized = normalize_robot(json); write_golden_if_missing("tags.json", &normalized); let golden = load_golden("tags.json"); assert_eq!( normalized, golden, "tags robot output does not match golden snapshot" ); } #[test] fn test_golden_aliases_robot_structure() { let env = helpers::TestEnv::new(); helpers::fetch_fixture(&env, "petstore.json", "petstore"); let a = helpers::run_cmd(&env, &["aliases", "--list", "--robot"]).success(); let json = helpers::parse_robot_json(&a.get_output().stdout); assert_robot_success_structure(&json, "aliases"); // Aliases output has dynamic timestamps so we only check structure, // not golden snapshot equality. Normalize and check key shape. let data = json.get("data").unwrap(); assert!(data.get("aliases").is_some_and(|v| v.is_array())); assert!(data.get("count").is_some_and(|v| v.is_number())); } #[test] fn test_golden_error_structure() { let env = helpers::TestEnv::new(); // Do NOT fetch -- alias does not exist let a = helpers::run_cmd(&env, &["list", "nonexistent", "--robot"]); // Command should fail with non-zero exit let a = a.failure(); // Error JSON goes to stderr let stderr = std::str::from_utf8(&a.get_output().stderr).expect("stderr not UTF-8"); let json: Value = serde_json::from_str(stderr.trim()).expect("stderr is not valid robot JSON"); assert_robot_error_structure(&json); // Verify error code assert_eq!( json["error"]["code"].as_str(), Some("ALIAS_NOT_FOUND"), "error code should be ALIAS_NOT_FOUND" ); // Verify meta.command assert_eq!( json["meta"]["command"].as_str(), Some("list"), "meta.command should be list" ); }