use std::{env, fs, time::{SystemTime, UNIX_EPOCH}};
use http::Method;
use pest::Parser;
use crate::request::RequestProtocol;
use super::*;
#[test]
fn schema_object_declaration_rule_accepts_indented_fields() {
let source = r#"schema User {
id: UUID
birthday?: DATE
}
"#;
assert!(CollectionParser::parse(Rule::schema_object_declaration, source).is_ok());
}
#[test]
fn preamble_rule_accepts_scalar_and_schema_declarations() {
let source = r#"
scalar Food = enum("pizza", "taco", "salad")
schema User {
id: UUID
birthday?: DATE
favoriteFoods: Food[]
}
"#;
assert!(CollectionParser::parse(Rule::preamble, source).is_ok());
}
#[test]
fn preamble_rule_accepts_environment_blocks() {
let source = r#"
name = Example Collection
$ API_ORIGIN = https://prod.example.com
env local
$ API_ORIGIN = http://localhost:3000
"#;
assert!(CollectionParser::parse(Rule::preamble, source).is_ok());
}
#[test]
fn preamble_rule_accepts_oauth_blocks() {
let source = r#"
oauth api
grant = client_credentials
issuer = https://login.example.com
client_id = secret.env("CLIENT_ID")
client_secret = secret.env("CLIENT_SECRET")
param audience = https://api.example.com
access_token -> $API_ACCESS_TOKEN
"#;
assert!(CollectionParser::parse(Rule::preamble, source).is_ok());
}
#[test]
fn preamble_rule_accepts_cookie_directives() {
let source = r#"
@ session = [[ session ]]
@ region = us-east-1
"#;
assert!(CollectionParser::parse(Rule::preamble, source).is_ok());
}
#[test]
fn preamble_rule_accepts_redaction_directives() {
let source = r#"
redact_header = X-Session-Token
redact_capture = SESSION_ID
redact_body = body.session.accessToken
"#;
assert!(CollectionParser::parse(Rule::preamble, source).is_ok());
}
#[test]
fn preamble_rule_accepts_reliability_directives() {
let source = r#"
timeout = 45s
poll_until = 2m
poll_every = 2s
"#;
assert!(CollectionParser::parse(Rule::preamble, source).is_ok());
}
#[test]
fn request_collection_rule_accepts_environment_preamble_before_requests() {
let source = r#"
name = Example Collection
$ API_ORIGIN = https://prod.example.com
env local
$ API_ORIGIN = http://localhost:3000
---
Get profile
GET {{ API_ORIGIN }}/profile
"#;
assert!(CollectionParser::parse(Rule::request_collection, source).is_ok());
}
#[test]
fn parse_request_collection_exposes_environment_preamble_items() {
let source = r#"
name = Example Collection
$ API_ORIGIN = https://prod.example.com
env local
$ API_ORIGIN = http://localhost:3000
---
Get profile
GET {{ API_ORIGIN }}/profile
"#;
let pair = super::declarations::parse_request_collection(source).unwrap();
let rules = pair.into_inner().map(|pair| pair.as_rule()).collect::<Vec<_>>();
assert!(rules.contains(&Rule::collection_name));
assert!(rules.contains(&Rule::variable));
assert!(rules.contains(&Rule::environment_block));
assert!(rules.contains(&Rule::requests));
}
#[test]
fn parse_collection_rejects_structured_cookies_with_manual_cookie_header() {
let source = r#"
name = Cookie Conflict
---
Conflicting Cookie Request
GET https://example.com
@ session = abc123
* Cookie = session=manual
"#;
let err = parse_collection(source, std::path::PathBuf::new())
.expect_err("collection should reject mixed cookie styles");
assert!(err
.to_string()
.contains("structured '@ CookieName = ...' cookies cannot be mixed"));
}
#[test]
fn inspect_collection_editor_support_reports_local_ranges_and_symbols() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
let root = env::temp_dir().join(format!("hen-inspect-{unique}"));
let fragments_dir = root.join("fragments");
fs::create_dir_all(&fragments_dir).expect("fragments dir should exist");
fs::write(fragments_dir.join("common.hen"), "^ & status == 200\n")
.expect("fragment should exist");
let source = r#"name = Inspect Example
oauth api
grant = client_credentials
issuer = https://login.example.com
client_id = secret.env("CLIENT_ID")
client_secret = secret.env("CLIENT_SECRET")
param audience = https://api.example.com
access_token -> $API_ACCESS_TOKEN
scalar UserId = UUID
schema User {
id: UserId
}
---
Seed user
GET https://example.com/seed
---
Get user
GET https://example.com/users/1
session = app
auth = api
> requires: Seed user
<< fragments/common.hen
"#;
let result = inspect_collection_editor_support(source, root.clone())
.expect("inspect should succeed");
let scalar_line = source
.lines()
.position(|line| line == "scalar UserId = UUID")
.expect("scalar line should exist");
let schema_line = source
.lines()
.position(|line| line == "schema User {")
.expect("schema line should exist");
let import_line = source
.lines()
.position(|line| line == "<< fragments/common.hen")
.expect("import line should exist");
assert_eq!(result.declarations.len(), 2);
assert_eq!(result.declarations[0].kind, "scalar");
assert_eq!(result.declarations[0].name, "UserId");
assert_eq!(result.declarations[0].range.start.line, scalar_line);
assert_eq!(result.declarations[1].kind, "schema");
assert_eq!(result.declarations[1].name, "User");
assert_eq!(result.declarations[1].range.start.line, schema_line);
assert_eq!(result.requests.len(), 2);
assert_eq!(result.requests[1].dependencies, vec!["Seed user"]);
assert_eq!(result.requests[1].session_name.as_deref(), Some("app"));
assert_eq!(result.requests[1].auth_profile.as_deref(), Some("api"));
assert_eq!(result.requests[1].fragment_includes.len(), 1);
assert_eq!(result.requests[1].fragment_includes[0].path, "fragments/common.hen");
assert_eq!(result.requests[1].fragment_includes[0].range.start.line, import_line);
assert_eq!(result.symbols.requests, vec!["Seed user", "Get user"]);
assert_eq!(result.symbols.schemas, vec!["User"]);
assert_eq!(result.symbols.scalars, vec!["UserId"]);
assert_eq!(result.symbols.oauth_profiles, vec!["api"]);
assert_eq!(result.symbols.sessions, vec!["app"]);
fs::remove_dir_all(root).ok();
}
#[test]
fn inspect_collection_syntax_accepts_environment_blocks() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
name = Example Collection
$ API_ORIGIN = https://prod.example.com
env local
$ API_ORIGIN = http://localhost:3000
---
Get profile
GET {{ API_ORIGIN }}/profile
"#;
let summary = inspect_collection_syntax(source, working_dir).unwrap();
assert_eq!(summary.requests.len(), 1);
assert_eq!(summary.requests[0].method, "GET");
}
#[test]
fn inspect_collection_syntax_accepts_oauth_blocks() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
oauth api
grant = client_credentials
issuer = https://login.example.com
client_id = secret.env("CLIENT_ID")
client_secret = secret.env("CLIENT_SECRET")
access_token -> $API_ACCESS_TOKEN
---
Get profile
auth = api
GET https://example.com/profile
"#;
let summary = inspect_collection_syntax(source, working_dir).unwrap();
assert_eq!(summary.requests.len(), 1);
assert_eq!(summary.requests[0].method, "GET");
assert_eq!(summary.requests[0].protocol_context.as_ref().unwrap()["authProfile"], "api");
}
#[test]
fn inspect_collection_syntax_tolerant_returns_partial_summary_for_invalid_oauth_reference() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
name = Example Collection
env local
$ API_ORIGIN = http://localhost:3000
---
Get profile
auth = missing
GET https://example.com/profile
"#;
let summary = inspect_collection_syntax_tolerant(source, working_dir)
.expect("tolerant summary should exist");
assert_eq!(summary.name, "Example Collection");
assert_eq!(summary.available_environments[0], "local");
assert_eq!(summary.requests.len(), 1);
assert_eq!(summary.requests[0].method, "GET");
assert_eq!(summary.requests[0].url, "https://example.com/profile");
assert_eq!(summary.requests[0].protocol_context.as_ref().unwrap()["authProfile"], "missing");
}
#[test]
fn inspect_collection_syntax_accepts_reliability_directives() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
timeout = 45s
poll_every = 2s
---
Wait for job
GET https://example.com/jobs/123
poll_until = 1m
"#;
let summary = inspect_collection_syntax(source, working_dir).unwrap();
assert_eq!(summary.requests.len(), 1);
assert_eq!(summary.requests[0].method, "GET");
}
#[test]
fn inspect_collection_syntax_rejects_invalid_redact_body_target() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
redact_body = header.Authorization
---
Get profile
GET https://example.com/profile
"#;
let err = inspect_collection_syntax(source, working_dir).unwrap_err();
assert!(
err.to_string()
.contains("redact_body must target a body path like body.token or json(body.payload).token")
);
}
#[test]
fn parse_collection_attaches_assertion_labels() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Get profile
GET https://example.com/profile
# The page loads
^ & status == 200
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert_eq!(collection.requests.len(), 1);
assert_eq!(collection.requests[0].assertions[0].label(), Some("The page loads"));
}
#[test]
fn parse_collection_applies_reliability_defaults_and_overrides() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
timeout = 45s
poll_every = 3s
---
Wait with inherited timeout
GET https://example.com/jobs/123
poll_until = 1m
---
Wait with overrides
GET https://example.com/jobs/456
timeout = 5s
poll_until = 30s
poll_every = 500ms
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert_eq!(collection.requests[0].reliability.timeout, "45s");
assert_eq!(collection.requests[0].reliability.poll_until.as_deref(), Some("1m"));
assert_eq!(collection.requests[0].reliability.poll_every.as_deref(), Some("3s"));
assert_eq!(collection.requests[1].reliability.timeout, "5s");
assert_eq!(collection.requests[1].reliability.poll_until.as_deref(), Some("30s"));
assert_eq!(collection.requests[1].reliability.poll_every.as_deref(), Some("500ms"));
}
#[test]
fn parse_collection_applies_default_timeout_when_omitted() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Simple request
GET https://example.com/health
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert_eq!(collection.requests[0].reliability.timeout, "30s");
assert_eq!(collection.requests[0].reliability.poll_until, None);
assert_eq!(collection.requests[0].reliability.poll_every, None);
}
#[test]
fn parse_collection_preserves_duplicate_query_directives() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
List pets
GET https://example.com/pets
? tag = dog
? tag = cat
? page = 1
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert_eq!(
collection.requests[0].http_operation().query_params,
vec![
("tag".to_string(), "dog".to_string()),
("tag".to_string(), "cat".to_string()),
("page".to_string(), "1".to_string()),
]
);
}
#[test]
fn inspect_collection_syntax_ignores_non_adjacent_assertion_comments() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Get profile
GET https://example.com/profile
# The page loads
header.X-Debug = enabled
^ & status == 200
"#;
let summary = inspect_collection_syntax(source, working_dir).unwrap();
assert_eq!(summary.requests.len(), 1);
assert_eq!(summary.requests[0].method, "GET");
}
#[test]
fn request_collection_exposes_declarations_before_requests() {
let source = r#"
scalar Food = enum("pizza", "taco", "salad")
schema User {
id: UUID
}
---
Get User
GET https://example.com/user/123
"#;
let collection = super::declarations::parse_request_collection(source).unwrap();
let rules: Vec<_> = collection.into_inner().map(|pair| pair.as_rule()).collect();
assert!(rules.contains(&Rule::declaration));
assert!(rules.contains(&Rule::requests));
}
#[test]
fn parse_collection_registers_schema_declarations() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
scalar Food = enum("pizza", "taco", "salad")
schema User {
id: UUID
birthday?: DATE
favoriteFoods: Food[]
}
---
Get User
GET https://example.com/user/123
^ & body === User
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert!(matches!(
collection.schema_registry.get("Food"),
Some(crate::schema::RegistryEntry::Scalar(_))
));
assert!(matches!(
collection.schema_registry.get("User"),
Some(crate::schema::RegistryEntry::Schema(_))
));
assert_eq!(collection.requests.len(), 1);
}
#[test]
fn parse_collection_builds_graphql_requests() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Get User GraphQL
protocol = graphql
POST https://example.com/graphql
operation = GetUser
variables = {"id":"123"}
~~~graphql
query GetUser($id: ID!) {
user(id: $id) {
id
}
}
~~~
^ & body.data.user.id == "123"
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert_eq!(collection.requests.len(), 1);
let request = &collection.requests[0];
assert_eq!(request.protocol(), RequestProtocol::Graphql);
let graphql = request
.graphql_operation()
.expect("graphql operation should exist");
assert_eq!(graphql.http.method, Method::POST);
assert_eq!(graphql.http.url, "https://example.com/graphql");
assert_eq!(graphql.operation_name.as_deref(), Some("GetUser"));
assert_eq!(graphql.variables_json.as_deref(), Some("{\"id\":\"123\"}"));
assert!(graphql.document.contains("query GetUser($id: ID!)"));
assert!(graphql.http.form_data.is_empty());
assert!(graphql.http.body.is_none());
assert!(graphql.http.body_content_type.is_none());
}
#[test]
fn parse_collection_registers_oauth_profiles_and_request_auth_reference() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
oauth api
grant = client_credentials
issuer = https://login.example.com
client_id = secret.env("CLIENT_ID")
client_secret = secret.env("CLIENT_SECRET")
access_token -> $API_ACCESS_TOKEN
token_type -> $API_TOKEN_TYPE
---
Get profile
auth = api
GET https://example.com/profile
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert!(collection.oauth_profiles.contains_key("api"));
assert_eq!(collection.requests.len(), 1);
assert_eq!(collection.requests[0].auth_profile_name.as_deref(), Some("api"));
assert_eq!(
collection.requests[0]
.protocol_context_json()
.expect("protocol context should exist")["authProfile"],
"api"
);
}
#[test]
fn parse_collection_applies_selected_environment_overrides() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
name = Example Collection
$ API_ORIGIN = https://prod.example.com
env local
$ API_ORIGIN = http://localhost:3000
---
Get profile
GET {{ API_ORIGIN }}/profile
"#;
let default_collection = parse_collection(source, working_dir.clone()).unwrap();
let local_collection =
parse_collection_with_environment(source, working_dir, Some("local")).unwrap();
assert_eq!(default_collection.requests.len(), 1);
assert_eq!(local_collection.requests.len(), 1);
assert_eq!(
default_collection.requests[0].http_operation().url,
"https://prod.example.com/profile"
);
assert_eq!(
local_collection.requests[0].http_operation().url,
"http://localhost:3000/profile"
);
}
#[test]
fn parse_collection_applies_selected_environment_over_shell_backed_base_value() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
name = Example Collection
$ CLIENT_ID = $(printf base-client)
env local
$ CLIENT_ID = local-client
---
Get profile
GET https://example.com/profile
* X-Client-Id = {{ CLIENT_ID }}
"#;
let default_collection = parse_collection(source, working_dir.clone()).unwrap();
let local_collection =
parse_collection_with_environment(source, working_dir, Some("local")).unwrap();
assert_eq!(
default_collection.requests[0]
.http_operation()
.headers
.get("X-Client-Id")
.map(String::as_str),
Some("base-client")
);
assert_eq!(
local_collection.requests[0]
.http_operation()
.headers
.get("X-Client-Id")
.map(String::as_str),
Some("local-client")
);
}
#[test]
fn inspect_collection_syntax_accepts_local_secret_references_without_resolution() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
name = Secret Verify Fixture
$ API_TOKEN = secret.env("HEN_TEST_API_TOKEN")
$ CLIENT_ID = secret.file("./secrets/client_id.txt")
---
Get profile
GET https://example.com/profile
* Authorization = Bearer {{ API_TOKEN }}
* X-Client-Id = {{ CLIENT_ID }}
"#;
let summary = inspect_collection_syntax(source, working_dir).unwrap();
assert_eq!(summary.requests.len(), 1);
assert_eq!(summary.requests[0].method, "GET");
}
#[test]
fn parse_collection_resolves_file_secret_provider() {
let tempdir = tempfile::tempdir().expect("tempdir should exist");
let working_dir = tempdir.path().to_path_buf();
std::fs::write(working_dir.join("client_id.txt"), "demo-client-id\n")
.expect("secret file should be written");
let source = r#"
name = Secret File Fixture
$ CLIENT_ID = secret.file("./client_id.txt")
---
Get profile
GET https://example.com/profile
* X-Client-Id = {{ CLIENT_ID }}
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert_eq!(
collection.requests[0]
.http_operation()
.headers
.get("X-Client-Id")
.map(String::as_str),
Some("demo-client-id")
);
}
#[test]
fn parse_collection_caches_file_secret_provider_values_within_one_parse() {
let tempdir = tempfile::tempdir().expect("tempdir should exist");
let working_dir = tempdir.path().to_path_buf();
std::fs::write(working_dir.join("secret.txt"), "first").expect("secret file should exist");
let source = r#"
name = Secret Cache Fixture
$ FIRST = secret.file("./secret.txt")
$ CHANGE = $(printf second > ./secret.txt)
$ SECOND = secret.file("./secret.txt")
---
Get profile
GET https://example.com/profile
* X-First = {{ FIRST }}
* X-Second = {{ SECOND }}
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert_eq!(
collection.requests[0]
.http_operation()
.headers
.get("X-First")
.map(String::as_str),
Some("first")
);
assert_eq!(
collection.requests[0]
.http_operation()
.headers
.get("X-Second")
.map(String::as_str),
Some("first")
);
}
#[test]
fn inspect_collection_syntax_inherits_session_request_target() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Initialize MCP
protocol = mcp
session = app
POST https://example.com/mcp
call = initialize
---
List tools
session = app
call = tools/list
"#;
let summary = inspect_collection_syntax(source, working_dir).unwrap();
assert_eq!(summary.requests.len(), 2);
assert_eq!(summary.requests[1].method, "POST");
assert_eq!(summary.requests[1].url, "https://example.com/mcp");
assert_eq!(summary.requests[1].protocol, "mcp");
}
#[test]
fn parse_collection_builds_sse_requests() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Open stream
protocol = sse
session = prices
GET https://example.com/prices/stream
---
Receive price update
session = prices
receive
within = 5s
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert_eq!(collection.requests.len(), 2);
let open = &collection.requests[0];
assert_eq!(open.protocol(), RequestProtocol::Sse);
assert_eq!(open.session_name.as_deref(), Some("prices"));
assert_eq!(open.http_operation().method, Method::GET);
assert_eq!(open.http_operation().url, "https://example.com/prices/stream");
assert!(matches!(
open.sse_operation().expect("sse operation should exist").action,
crate::request::SseAction::Open
));
let receive = &collection.requests[1];
assert_eq!(receive.protocol(), RequestProtocol::Sse);
assert_eq!(receive.session_name.as_deref(), Some("prices"));
assert_eq!(receive.http_operation().method, Method::GET);
assert_eq!(
receive.http_operation().url,
"https://example.com/prices/stream"
);
assert!(matches!(
receive.sse_operation().expect("sse operation should exist").action,
crate::request::SseAction::Receive { .. }
));
}
#[test]
fn inspect_collection_syntax_builds_sse_receive_context() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Open stream
protocol = sse
session = prices
GET https://example.com/prices/stream
---
Receive price update
session = prices
receive
within = 5s
"#;
let summary = inspect_collection_syntax(source, working_dir).unwrap();
assert_eq!(summary.requests.len(), 2);
assert_eq!(summary.requests[1].method, "GET");
assert_eq!(summary.requests[1].url, "https://example.com/prices/stream");
assert_eq!(summary.requests[1].protocol, "sse");
assert_eq!(summary.requests[1].protocol_context.as_ref().unwrap()["action"], "receive");
assert_eq!(summary.requests[1].protocol_context.as_ref().unwrap()["within"], "5s");
}
#[test]
fn inspect_collection_syntax_rejects_sse_receive_without_within() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Receive price update
protocol = sse
session = prices
GET https://example.com/prices/stream
receive
"#;
let err = inspect_collection_syntax(source, working_dir).unwrap_err();
assert!(err
.to_string()
.contains("SSE receive steps require 'within = ...'"));
}
#[test]
fn inspect_collection_syntax_rejects_invalid_sse_within_duration() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Receive price update
protocol = sse
session = prices
GET https://example.com/prices/stream
receive
within = soon
"#;
let err = inspect_collection_syntax(source, working_dir).unwrap_err();
assert!(err
.to_string()
.contains("invalid within duration 'soon'"));
}
#[test]
fn parse_collection_builds_ws_requests() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Open socket
protocol = ws
session = chat
GET wss://example.com/chat
---
Send hello
session = chat
~~~json
{"type":"hello"}
~~~
---
Receive reply
session = chat
receive
within = 2s
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert_eq!(collection.requests.len(), 3);
let open = &collection.requests[0];
assert_eq!(open.protocol(), RequestProtocol::Ws);
assert!(matches!(
open.ws_operation().expect("ws operation should exist").action,
crate::request::WsAction::Open
));
let send = &collection.requests[1];
assert_eq!(send.protocol(), RequestProtocol::Ws);
assert_eq!(send.http_operation().method, Method::GET);
assert_eq!(send.http_operation().url, "wss://example.com/chat");
assert!(matches!(
send.ws_operation().expect("ws operation should exist").action,
crate::request::WsAction::Send {
kind: crate::request::WsSendKind::Json,
..
}
));
let receive = &collection.requests[2];
assert_eq!(receive.protocol(), RequestProtocol::Ws);
assert_eq!(receive.http_operation().method, Method::GET);
assert_eq!(receive.http_operation().url, "wss://example.com/chat");
assert!(matches!(
receive.ws_operation().expect("ws operation should exist").action,
crate::request::WsAction::Receive { .. }
));
}
#[test]
fn parse_collection_builds_ws_exchange_requests() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Open socket
protocol = ws
session = chat
GET wss://example.com/chat
---
Send hello
session = chat
within = 2s
~~~json
{"type":"hello"}
~~~
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert_eq!(collection.requests.len(), 2);
let exchange = &collection.requests[1];
assert!(matches!(
exchange.ws_operation().expect("ws operation should exist").action,
crate::request::WsAction::Exchange {
kind: crate::request::WsSendKind::Json,
..
}
));
}
#[test]
fn inspect_collection_syntax_builds_ws_context() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Open socket
protocol = ws
session = chat
GET wss://example.com/chat
---
Send hello
session = chat
~~~text
hello
~~~
---
Receive reply
session = chat
receive
within = 2s
"#;
let summary = inspect_collection_syntax(source, working_dir).unwrap();
assert_eq!(summary.requests.len(), 3);
assert_eq!(summary.requests[1].protocol, "ws");
assert_eq!(summary.requests[1].protocol_context.as_ref().unwrap()["action"], "send");
assert_eq!(summary.requests[1].protocol_context.as_ref().unwrap()["kind"], "text");
assert_eq!(summary.requests[2].protocol, "ws");
assert_eq!(summary.requests[2].protocol_context.as_ref().unwrap()["action"], "receive");
assert_eq!(summary.requests[2].protocol_context.as_ref().unwrap()["within"], "2s");
}
#[test]
fn inspect_collection_syntax_builds_ws_exchange_context() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Open socket
protocol = ws
session = chat
GET wss://example.com/chat
---
Send hello
session = chat
within = 2s
~~~
hello
~~~
"#;
let summary = inspect_collection_syntax(source, working_dir).unwrap();
assert_eq!(summary.requests[1].protocol_context.as_ref().unwrap()["action"], "exchange");
assert_eq!(summary.requests[1].protocol_context.as_ref().unwrap()["kind"], "text");
assert_eq!(summary.requests[1].protocol_context.as_ref().unwrap()["within"], "2s");
}
#[test]
fn inspect_collection_syntax_accepts_defaulted_prompt_variable_assignments() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
name = WebSocket Fixture
description = Demonstrates WebSocket open authoring with a defaulted prompt variable.
$ WS_ORIGIN = [[ ws_origin = wss://example.com ]]
---
Open socket
protocol = ws
session = chat
GET {{ WS_ORIGIN }}/chat
"#;
let summary = inspect_collection_syntax(source, working_dir).unwrap();
assert_eq!(summary.requests.len(), 1);
assert_eq!(summary.requests[0].protocol, "ws");
assert!(summary.requests[0].url.ends_with("/chat"));
}
#[test]
fn inspect_collection_syntax_rejects_ws_send_without_body() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Open socket
protocol = ws
session = chat
GET wss://example.com/chat
---
Send hello
session = chat
send = text
"#;
let err = inspect_collection_syntax(source, working_dir).unwrap_err();
assert!(err
.to_string()
.contains("WebSocket send kind 'text' requires a body block"));
}
#[test]
fn inspect_collection_syntax_rejects_conflicting_ws_send_kind_and_body_type() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Open socket
protocol = ws
session = chat
GET wss://example.com/chat
---
Send hello
session = chat
send = text
~~~json
{"type":"hello"}
~~~
"#;
let err = inspect_collection_syntax(source, working_dir).unwrap_err();
assert!(err
.to_string()
.contains("conflicts with body block type 'json'"));
}
#[test]
fn inspect_collection_syntax_rejects_session_request_without_target_anchor() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
List tools
protocol = mcp
session = app
call = tools/list
"#;
let err = inspect_collection_syntax(source, working_dir).unwrap_err();
assert!(err
.to_string()
.contains("session-backed request omitted its method/URL"));
}
#[test]
fn inspect_collection_syntax_rejects_mcp_tool_call_without_tool() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Broken MCP
protocol = mcp
POST https://example.com/mcp
call = tools/call
"#;
let err = inspect_collection_syntax(source, working_dir).unwrap_err();
assert!(err.to_string().contains("requires 'tool = ...'"));
}
#[test]
fn inspect_collection_syntax_rejects_graphql_without_graphql_block() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Broken GraphQL
protocol = graphql
POST https://example.com/graphql
~~~application/json
{"query":"query { viewer { id } }"}
~~~
"#;
let err = inspect_collection_syntax(source, working_dir).unwrap_err();
assert!(err.to_string().contains("~~~graphql document block"));
}
#[test]
fn inspect_collection_syntax_rejects_graphql_form_fields() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Broken GraphQL
protocol = graphql
POST https://example.com/graphql
~ note = hello
~~~graphql
query Viewer {
viewer { id }
}
~~~
"#;
let err = inspect_collection_syntax(source, working_dir).unwrap_err();
assert!(err
.to_string()
.contains("GraphQL requests do not support form fields"));
}
#[test]
fn parsed_scalar_declarations_validate_values() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
scalar UserId = string & format(uuid)
scalar Status = enum("queued", "running", "done")
scalar Handle = string & len(3..24) & pattern(/^[a-z][a-z0-9_]*$/)
scalar Age = integer & range(0..130)
---
Get User
GET https://example.com/user/123
^ & body === UserId
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert!(collection
.schema_registry
.validate_scalar_target(
"UserId",
&serde_json::json!("550e8400-e29b-41d4-a716-446655440000")
)
.is_ok());
assert!(collection
.schema_registry
.validate_scalar_target("Status", &serde_json::json!("running"))
.is_ok());
assert!(collection
.schema_registry
.validate_scalar_target("Handle", &serde_json::json!("alice_1"))
.is_ok());
assert!(collection
.schema_registry
.validate_scalar_target("Age", &serde_json::json!(42))
.is_ok());
let user_id_error = collection
.schema_registry
.validate_scalar_target("UserId", &serde_json::json!("not-a-uuid"))
.unwrap_err();
assert!(matches!(
user_id_error.reason,
crate::schema::ScalarValidationErrorKind::FormatMismatch { .. }
));
let handle_error = collection
.schema_registry
.validate_scalar_target("Handle", &serde_json::json!("ab"))
.unwrap_err();
assert!(matches!(
handle_error.reason,
crate::schema::ScalarValidationErrorKind::LengthOutOfRange { .. }
));
let age_error = collection
.schema_registry
.validate_scalar_target("Age", &serde_json::json!(131))
.unwrap_err();
assert!(matches!(
age_error.reason,
crate::schema::ScalarValidationErrorKind::RangeOutOfRange { .. }
));
}
#[test]
fn parsed_scalar_declarations_preserve_named_scalar_chains() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
scalar WORD = string & pattern(/^[a-z]+$/)
scalar HANDLE = WORD & len(3..24)
---
Get User
GET https://example.com/user/123
^ & body === HANDLE
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert!(collection
.schema_registry
.validate_scalar_target("HANDLE", &serde_json::json!("alice"))
.is_ok());
let pattern_error = collection
.schema_registry
.validate_scalar_target("HANDLE", &serde_json::json!("Alice"))
.unwrap_err();
assert!(matches!(
pattern_error.reason,
crate::schema::ScalarValidationErrorKind::PatternMismatch { .. }
));
}
#[test]
fn parsed_schema_declarations_validate_objects() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
scalar Food = enum("pizza", "taco")
schema Address {
city: string
postalCode: string
}
schema User {
id: UUID
birthday?: DATE?
favoriteFoods: Food[]
address: Address
}
---
Get User
GET https://example.com/user/123
^ & body === User
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert!(collection
.schema_registry
.validate_schema_target(
"User",
&serde_json::json!({
"id": "550e8400-e29b-41d4-a716-446655440000",
"birthday": null,
"favoriteFoods": ["pizza", "taco"],
"address": {
"city": "Austin",
"postalCode": "78701"
},
"extra": true
})
)
.is_ok());
let error = collection
.schema_registry
.validate_schema_target(
"User",
&serde_json::json!({
"id": "550e8400-e29b-41d4-a716-446655440000",
"favoriteFoods": ["pizza", "burger"],
"address": {
"city": "Austin",
"postalCode": "78701"
}
})
)
.unwrap_err();
assert_eq!(error.path, "$.favoriteFoods[1]");
assert!(matches!(
error.reason,
crate::schema::SchemaValidationErrorKind::ScalarViolation { .. }
));
}
#[test]
fn parsed_schema_declarations_validate_root_array_schemas() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
schema User {
id: UUID
}
schema Users = User[]
---
Get Users
GET https://example.com/users
^ & body === Users
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert!(collection
.schema_registry
.validate_schema_target(
"Users",
&serde_json::json!([
{ "id": "550e8400-e29b-41d4-a716-446655440000" },
{ "id": "123e4567-e89b-12d3-a456-426614174000" }
])
)
.is_ok());
let error = collection
.schema_registry
.validate_schema_target(
"Users",
&serde_json::json!([
{ "id": "550e8400-e29b-41d4-a716-446655440000" },
{ "id": null }
])
)
.unwrap_err();
assert_eq!(error.path, "$[1].id");
assert!(matches!(
error.reason,
crate::schema::SchemaValidationErrorKind::TypeMismatch { .. }
));
}
#[test]
fn parse_collection_allows_forward_references_in_declarations() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
scalar HANDLE = WORD & len(3..24)
scalar WORD = string & pattern(/^[a-z]+$/)
schema User {
id: HANDLE
}
---
Get User
GET https://example.com/user/123
^ & body === User
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert!(collection.schema_registry.get("HANDLE").is_some());
assert!(collection.schema_registry.get("WORD").is_some());
assert_eq!(collection.requests.len(), 1);
}
#[test]
fn parse_collection_rejects_declaration_after_first_request_delimiter() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Get User
GET https://example.com/user/123
schema User {
id: UUID
}
"#;
assert!(parse_collection(source, working_dir).is_err());
}
#[test]
fn parse_collection_rejects_cyclic_schema_references() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
schema User {
address: Address
}
schema Address {
user: User
}
---
Get User
GET https://example.com/user/123
^ & body === User
"#;
let error = parse_collection(source, working_dir).unwrap_err();
assert!(error.to_string().contains("circular reference"));
assert!(error.to_string().contains("User"));
assert!(error.to_string().contains("Address"));
}
#[test]
fn inspect_collection_syntax_accepts_imported_schema_declarations() {
let temp_dir = tempfile::tempdir().unwrap();
let working_dir = temp_dir.path().to_path_buf();
let imported = working_dir.join("common.hen");
std::fs::write(
&imported,
r#"
scalar Food = enum("pizza", "taco")
schema User {
id: UUID
favoriteFoods: Food[]
}
"#,
)
.unwrap();
let source = r#"
<< ./common.hen
---
Get User
GET https://example.com/user/123
^ & body === User
"#;
let summary = inspect_collection_syntax(source, working_dir).unwrap();
assert_eq!(summary.requests.len(), 1);
assert_eq!(summary.requests[0].method, "GET");
assert_eq!(summary.requests[0].url, "https://example.com/user/123");
}
#[test]
fn parse_collection_rejects_duplicate_imported_declaration_names() {
let temp_dir = tempfile::tempdir().unwrap();
let working_dir = temp_dir.path().to_path_buf();
let imported = working_dir.join("common.hen");
std::fs::write(
&imported,
r#"
schema User {
id: UUID
}
"#,
)
.unwrap();
let source = r#"
<< ./common.hen
schema User {
id: UUID
}
---
Get User
GET https://example.com/user/123
^ & body === User
"#;
let error = parse_collection(source, working_dir).unwrap_err();
assert!(error.to_string().contains("User is already defined"));
}
#[test]
fn parse_collection_accepts_schema_combinators_and_const_scalars() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
scalar CardKind = const("card")
scalar BankKind = const("bank")
schema CardCheckout {
method: CardKind
cardLast4: string
}
schema BankCheckout {
method: BankKind
accountId: string
}
schema Checkout = discriminator(method,
"card": CardCheckout,
"bank": BankCheckout
)
schema PaymentMethod = oneOf(CardCheckout, BankCheckout)
schema Contact = anyOf(EMAIL, URI)
schema NonCardCheckout = not(CardCheckout)
---
Validate checkout
GET https://example.com/checkout/123
^ & body === Checkout
"#;
let collection = parse_collection(source, working_dir).unwrap();
assert!(collection
.schema_registry
.validate_schema_target(
"Checkout",
&serde_json::json!({ "method": "card", "cardLast4": "4242" })
)
.is_ok());
assert!(collection
.schema_registry
.validate_schema_target(
"PaymentMethod",
&serde_json::json!({ "method": "bank", "accountId": "acct_123" })
)
.is_ok());
assert!(collection
.schema_registry
.validate_schema_target("Contact", &serde_json::json!("user@example.com"))
.is_ok());
assert!(collection
.schema_registry
.validate_schema_target(
"NonCardCheckout",
&serde_json::json!({ "method": "bank", "accountId": "acct_123" })
)
.is_ok());
}
#[test]
fn schema_combinators_report_union_and_discriminator_failures() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
scalar CardKind = const("card")
scalar BankKind = const("bank")
schema CardCheckout {
method: CardKind
cardLast4: string
}
schema BankCheckout {
method: BankKind
accountId: string
}
schema OverlapA {
id: UUID
}
schema OverlapB {
id: UUID
}
schema Checkout = discriminator(method,
"card": CardCheckout,
"bank": BankCheckout
)
schema ExactOne = oneOf(OverlapA, OverlapB)
schema NonCardCheckout = not(CardCheckout)
---
Validate checkout
GET https://example.com/checkout/123
^ & body === Checkout
"#;
let collection = parse_collection(source, working_dir).unwrap();
let discriminator_error = collection
.schema_registry
.validate_schema_target(
"Checkout",
&serde_json::json!({ "method": "cash", "note": "walk-in" })
)
.unwrap_err();
assert_eq!(discriminator_error.path, "$.method");
assert!(matches!(
discriminator_error.reason,
crate::schema::SchemaValidationErrorKind::DiscriminatorUnknownTag { .. }
));
let one_of_error = collection
.schema_registry
.validate_schema_target(
"ExactOne",
&serde_json::json!({ "id": "550e8400-e29b-41d4-a716-446655440000" })
)
.unwrap_err();
assert!(matches!(
one_of_error.reason,
crate::schema::SchemaValidationErrorKind::OneOfMultipleMatches { .. }
));
let not_error = collection
.schema_registry
.validate_schema_target(
"NonCardCheckout",
&serde_json::json!({ "method": "card", "cardLast4": "4242" })
)
.unwrap_err();
assert!(matches!(
not_error.reason,
crate::schema::SchemaValidationErrorKind::NotMatched { .. }
));
}