use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use lex_api::handlers::State;
use tempfile::TempDir;
struct Server {
addr: SocketAddr,
_join: Option<thread::JoinHandle<()>>,
_server_holder: Arc<()>,
}
fn start_server() -> (Server, TempDir) {
let tmp = TempDir::new().unwrap();
let server = tiny_http::Server::http(("127.0.0.1", 0)).expect("bind ephemeral port");
let addr: SocketAddr = match server.server_addr() {
tiny_http::ListenAddr::IP(addr) => addr,
_ => panic!("expected IP listener"),
};
let state = Arc::new(State::open(tmp.path().to_path_buf()).unwrap());
let join = thread::spawn(move || {
lex_api::serve_on(server, state);
});
wait_until_serving(&addr);
(Server { addr, _join: Some(join), _server_holder: Arc::new(()) }, tmp)
}
fn wait_until_serving(addr: &SocketAddr) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let probe = b"GET /v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
while std::time::Instant::now() < deadline {
if let Ok(mut s) = TcpStream::connect_timeout(addr, Duration::from_millis(200)) {
s.set_read_timeout(Some(Duration::from_millis(200))).ok();
if s.write_all(probe).is_ok() {
let mut buf = [0u8; 16];
if s.read(&mut buf).is_ok() && buf.starts_with(b"HTTP/1.1 200") {
return;
}
}
}
thread::sleep(Duration::from_millis(20));
}
panic!("test server never became ready within 10s");
}
fn post_bytes(addr: &SocketAddr, path: &str, body: &[u8]) -> (u16, String) {
let mut req = format!(
"POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
).into_bytes();
req.extend_from_slice(body);
let deadline = std::time::Instant::now() + Duration::from_secs(30);
loop {
match try_post(addr, &req) {
Ok(result) => return result,
Err(e) => {
if std::time::Instant::now() >= deadline {
panic!("POST {path} failed after retries: {e}");
}
thread::sleep(Duration::from_millis(50));
}
}
}
}
fn get(addr: &SocketAddr, path: &str) -> (u16, String) {
let req = format!(
"GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"
).into_bytes();
let deadline = std::time::Instant::now() + Duration::from_secs(30);
loop {
match try_post(addr, &req) {
Ok(result) => return result,
Err(e) => {
if std::time::Instant::now() >= deadline {
panic!("GET {path} failed after retries: {e}");
}
thread::sleep(Duration::from_millis(50));
}
}
}
}
fn branch_head(addr: &SocketAddr) -> Option<String> {
let (status, body) = get(addr, "/v1/branches/main/head");
assert_eq!(status, 200, "branch head probe must succeed, got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
parsed["head_op"].as_str().map(|s| s.to_string())
}
fn try_post(addr: &SocketAddr, req: &[u8]) -> Result<(u16, String), String> {
let mut s = TcpStream::connect_timeout(addr, Duration::from_secs(5)).map_err(|e| e.to_string())?;
s.set_read_timeout(Some(Duration::from_secs(15))).map_err(|e| e.to_string())?;
s.write_all(req).map_err(|e| e.to_string())?;
let mut buf = Vec::new();
s.read_to_end(&mut buf).map_err(|e| e.to_string())?;
if buf.is_empty() {
return Err("empty response".into());
}
let text = String::from_utf8_lossy(&buf);
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((&text, ""));
let status = head.split_whitespace().nth(1).unwrap_or("0").parse().unwrap_or(0);
Ok((status, body.to_string()))
}
fn get_raw(addr: &SocketAddr, path: &str) -> (u16, Vec<u8>) {
let req = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n").into_bytes();
let mut s = TcpStream::connect_timeout(addr, Duration::from_secs(5)).unwrap();
s.set_read_timeout(Some(Duration::from_secs(15))).unwrap();
s.write_all(&req).unwrap();
let mut buf = Vec::new();
s.read_to_end(&mut buf).unwrap();
let sep = buf.windows(4).position(|w| w == b"\r\n\r\n").expect("no header terminator");
let head = String::from_utf8_lossy(&buf[..sep]);
let status = head.split_whitespace().nth(1).unwrap_or("0").parse().unwrap_or(0);
(status, buf[sep + 4..].to_vec())
}
fn extract(archive: &[u8], want: &str) -> Option<String> {
use std::io::Read;
let gz = flate2::read::GzDecoder::new(archive);
let mut ar = tar::Archive::new(gz);
for entry in ar.entries().ok()? {
let mut entry = entry.ok()?;
let path = entry.path().ok()?.to_string_lossy().to_string();
if path == want {
let mut s = String::new();
entry.read_to_string(&mut s).ok()?;
return Some(s);
}
}
None
}
fn pkg_archive(name: &str, version: &str, src_files: &[(&str, &str)]) -> Vec<u8> {
let toml = format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\n");
let mut files: Vec<(String, &str)> = vec![("lex.toml".to_string(), toml.as_str())];
for (path, contents) in src_files {
files.push((format!("src/{path}"), contents));
}
let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
{
let mut ar = tar::Builder::new(&mut enc);
for (path, contents) in &files {
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(0o644);
header.set_cksum();
ar.append_data(&mut header, path, contents.as_bytes()).unwrap();
}
ar.finish().unwrap();
}
enc.finish().unwrap()
}
#[test]
fn modifying_a_function_in_place_is_one_modify_not_an_add() {
let (srv, _tmp) = start_server();
let counter = |n: u8| -> String {
format!(
"fn counter() -> Int\n examples {{\n counter() => {n},\n }}\n{{ {n} }}\n"
)
};
let v1 = counter(1);
let archive_v1 = pkg_archive("multi", "0.1.0", &[("a.lex", v1.as_str())]);
let (status, body) = post_bytes(&srv.addr, "/v1/pkg/publish", &archive_v1);
assert_eq!(status, 200, "v1 publish must succeed, got: {body}");
let v2 = counter(2);
let archive_v2 = pkg_archive(
"multi",
"0.2.0",
&[("a.lex", v2.as_str()), ("b.lex", v2.as_str())],
);
let (status, body) = post_bytes(&srv.addr, "/v1/pkg/publish", &archive_v2);
assert_eq!(status, 200, "v2 publish must succeed, got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON response");
let ops = parsed["ops"].as_array().expect("ops array in publish response");
let of_kind = |k: &str| -> Vec<&serde_json::Value> {
ops.iter().filter(|op| op["kind"]["op"] == k).collect()
};
assert_eq!(
of_kind("modify_body").len(), 1,
"a.lex's `counter` changed 1 -> 2 in place: exactly one modify_body, got: {ops:#?}",
);
assert_eq!(
of_kind("add_function").len(), 1,
"b.lex's `counter` is its own function under its own prefix: one add_function, \
got: {ops:#?}",
);
assert!(
of_kind("remove_function").is_empty(),
"nothing was removed, got: {ops:#?}",
);
let (status, body) = get(&srv.addr, "/v1/pkg/multi/0.2.0");
assert_eq!(status, 200, "record must be readable, got: {body}");
let rec: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
let names: Vec<&str> = rec["function_names"].as_array().unwrap()
.iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(names.len(), 2, "two distinct functions, got: {names:?}");
assert!(
names.iter().all(|n| n.ends_with(".counter")) && names[0] != names[1],
"both are prefix-mangled `counter`s under different prefixes, got: {names:?}",
);
}
#[test]
fn multi_file_publish_does_not_spuriously_remove_a_name_owned_by_another_file() {
let (srv, _tmp) = start_server();
let src_other = concat!(
"fn other() -> Int\n",
" examples {\n",
" other() => 2,\n",
" }\n",
"{ 2 }\n",
);
let src_helper_v1 = concat!(
"fn helper() -> Int\n",
" examples {\n",
" helper() => 1,\n",
" }\n",
"{ 1 }\n",
);
let archive_v1 = pkg_archive("multi2", "0.1.0", &[("b.lex", src_helper_v1)]);
let (status, body) = post_bytes(&srv.addr, "/v1/pkg/publish", &archive_v1);
assert_eq!(status, 200, "v1 publish must succeed, got: {body}");
let src_helper_v2 = concat!(
"fn helper() -> Int\n",
" examples {\n",
" helper() => 3,\n",
" }\n",
"{ 3 }\n",
);
let archive_v2 = pkg_archive("multi2", "0.2.0", &[("a.lex", src_other), ("b.lex", src_helper_v2)]);
let (status, body) = post_bytes(&srv.addr, "/v1/pkg/publish", &archive_v2);
assert_eq!(status, 200, "v2 publish must succeed (helper must not be spuriously removed), got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON response");
let ops = parsed["ops"].as_array().expect("ops array in publish response");
let remove_ops: Vec<&serde_json::Value> = ops.iter()
.filter(|op| op["kind"]["op"] == "remove_function")
.collect();
assert!(
remove_ops.is_empty(),
"expected no remove_function ops -- `helper` is untouched by file a.lex \
and legitimately modified by b.lex, not removed. Got: {:#?}",
remove_ops,
);
let modify_ops: Vec<&serde_json::Value> = ops.iter()
.filter(|op| op["kind"]["op"] == "modify_body")
.collect();
assert_eq!(
modify_ops.len(), 1,
"expected exactly one modify_body op for helper's 1->3 change, got: {:#?}",
ops,
);
let add_ops: Vec<&serde_json::Value> = ops.iter()
.filter(|op| op["kind"]["op"] == "add_function")
.collect();
assert_eq!(
add_ops.len(), 1,
"expected exactly one add_function op for `other`, got: {:#?}",
ops,
);
}
#[test]
fn multi_file_publish_disambiguates_same_name_different_signature_functions() {
let (srv, _tmp) = start_server();
let field_v1 = "fn validate(x :: Int) -> Int { x }\n";
let schema_v1 = "fn validate(x :: Str) -> Str { x }\n";
let validator_v1 = "fn validate(x :: Bool) -> Bool { x }\n";
let archive_v1 = pkg_archive("threevalidate", "0.1.0", &[
("field.lex", field_v1),
("schema.lex", schema_v1),
("validator.lex", validator_v1),
]);
let (status, body) = post_bytes(&srv.addr, "/v1/pkg/publish", &archive_v1);
assert_eq!(status, 200, "v1 publish (three same-name, different-signature functions) must succeed, got: {body}");
let field_v2 = "fn validate(x :: Int) -> Int { x + 1 }\n";
let archive_v2 = pkg_archive("threevalidate", "0.2.0", &[
("field.lex", field_v2),
("schema.lex", schema_v1),
("validator.lex", validator_v1),
]);
let (status, body) = post_bytes(&srv.addr, "/v1/pkg/publish", &archive_v2);
assert_eq!(status, 200, "v2 publish must succeed -- the three `validate`s must be correctly disambiguated by signature, got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON response");
let ops = parsed["ops"].as_array().expect("ops array in publish response");
let modify_ops: Vec<&serde_json::Value> = ops.iter()
.filter(|op| op["kind"]["op"] == "modify_body")
.collect();
assert_eq!(
modify_ops.len(), 1,
"expected exactly one modify_body op (field.lex's validate changed; \
schema.lex's and validator.lex's did not), got: {:#?}",
ops,
);
let remove_ops: Vec<&serde_json::Value> = ops.iter()
.filter(|op| op["kind"]["op"] == "remove_function")
.collect();
assert!(
remove_ops.is_empty(),
"expected no remove_function ops -- all three `validate`s are still \
declared in v2, just correctly disambiguated by signature. Got: {:#?}",
remove_ops,
);
let add_ops: Vec<&serde_json::Value> = ops.iter()
.filter(|op| op["kind"]["op"] == "add_function")
.collect();
assert!(
add_ops.is_empty(),
"expected no add_function ops -- schema.lex's and validator.lex's \
`validate` already existed from v1 and are unchanged in v2, they \
must not be mistaken for new declarations. Got: {:#?}",
add_ops,
);
}
#[test]
fn publishing_one_package_never_touches_an_unrelated_package_in_the_same_tenant() {
let (srv, _tmp) = start_server();
let alpha_v1 = "fn only_in_alpha() -> Int { 1 }\n";
let archive_alpha_v1 = pkg_archive("alpha", "0.1.0", &[("lib.lex", alpha_v1)]);
let (status, body) = post_bytes(&srv.addr, "/v1/pkg/publish", &archive_alpha_v1);
assert_eq!(status, 200, "alpha v1 publish must succeed, got: {body}");
let bravo_src = "fn only_in_bravo() -> Int { 2 }\n";
let archive_bravo = pkg_archive("bravo", "0.1.0", &[("lib.lex", bravo_src)]);
let (status, body) = post_bytes(&srv.addr, "/v1/pkg/publish", &archive_bravo);
assert_eq!(status, 200, "bravo publish must succeed, got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON response");
let ops = parsed["ops"].as_array().expect("ops array in publish response");
assert_eq!(
ops.len(), 1,
"bravo's publish must produce exactly its own one add_function op \
and touch nothing belonging to alpha, got: {:#?}",
ops,
);
assert_eq!(
ops[0]["kind"]["op"], "add_function",
"expected bravo's own add_function, got: {:#?}", ops[0],
);
let alpha_v2 = "fn only_in_alpha() -> Int { 1 + 1 }\n";
let archive_alpha_v2 = pkg_archive("alpha", "0.2.0", &[("lib.lex", alpha_v2)]);
let (status, body) = post_bytes(&srv.addr, "/v1/pkg/publish", &archive_alpha_v2);
assert_eq!(status, 200, "alpha v2 publish must succeed, got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON response");
let ops = parsed["ops"].as_array().expect("ops array in publish response");
assert_eq!(
ops.len(), 1,
"expected exactly one op for alpha's changed body, got: {:#?}",
ops,
);
assert_eq!(
ops[0]["kind"]["op"], "modify_body",
"alpha's function must still be live and resolvable as a modification \
(bravo's publish must not have removed it) -- got: {:#?}", ops[0],
);
}
const IDEMPOTENT_SRC: &[(&str, &str)] = &[
(
"error.lex",
concat!(
"fn code_missing() -> Str\n",
" examples {\n",
" code_missing() => \"missing\",\n",
" }\n",
"{ \"missing\" }\n",
),
),
(
"schema.lex",
concat!(
"import \"./error\" as e\n",
"fn describe() -> Str\n",
" examples {\n",
" describe() => \"missing\",\n",
" }\n",
"{ e.code_missing() }\n",
),
),
];
#[test]
fn republishing_identical_source_emits_no_ops() {
let (srv, _tmp) = start_server();
let (status, body) = post_bytes(
&srv.addr,
"/v1/pkg/publish",
&pkg_archive("idem", "0.1.0", IDEMPOTENT_SRC),
);
assert_eq!(status, 200, "first publish must succeed, got: {body}");
let head_after_first = branch_head(&srv.addr);
assert!(head_after_first.is_some(), "first publish must move the branch head");
let (status, body) = post_bytes(
&srv.addr,
"/v1/pkg/publish",
&pkg_archive("idem", "0.2.0", IDEMPOTENT_SRC),
);
assert_eq!(status, 200, "republish must succeed, got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON response");
let ops = parsed["ops"].as_array().expect("ops array in publish response");
assert!(
ops.is_empty(),
"republishing byte-identical source must emit zero ops, got {}: {:#?}",
ops.len(), ops,
);
assert_eq!(
branch_head(&srv.addr), head_after_first,
"a no-op republish must not move the branch head",
);
}
#[test]
fn republished_function_names_are_identical_across_requests() {
let (srv, _tmp) = start_server();
let (status, body) = post_bytes(
&srv.addr,
"/v1/pkg/publish",
&pkg_archive("names", "0.1.0", IDEMPOTENT_SRC),
);
assert_eq!(status, 200, "first publish must succeed, got: {body}");
let (status, body) = post_bytes(
&srv.addr,
"/v1/pkg/publish",
&pkg_archive("names", "0.2.0", IDEMPOTENT_SRC),
);
assert_eq!(status, 200, "republish must succeed, got: {body}");
let names_of = |version: &str| -> Vec<String> {
let (status, body) = get(&srv.addr, &format!("/v1/pkg/names/{version}"));
assert_eq!(status, 200, "record for {version} must be readable, got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
let mut names: Vec<String> = parsed["function_names"].as_array()
.expect("function_names array")
.iter()
.map(|v| v.as_str().unwrap_or_default().to_string())
.collect();
names.sort();
names
};
let v1 = names_of("0.1.0");
let v2 = names_of("0.2.0");
assert!(
v1.iter().any(|n| n.starts_with("error_") && n.ends_with(".code_missing")),
"expected a mangled name for the locally-imported file, got: {v1:?}",
);
assert_eq!(
v1, v2,
"the same source must publish under the same names on every request (#826)",
);
}
#[test]
fn duplicate_version_publish_leaves_the_op_log_untouched() {
let (srv, _tmp) = start_server();
let (status, body) = post_bytes(
&srv.addr,
"/v1/pkg/publish",
&pkg_archive("dup", "0.1.0", IDEMPOTENT_SRC),
);
assert_eq!(status, 200, "first publish must succeed, got: {body}");
let head_after_first = branch_head(&srv.addr);
let changed: &[(&str, &str)] = &[(
"error.lex",
concat!(
"fn code_missing() -> Str\n",
" examples {\n",
" code_missing() => \"gone\",\n",
" }\n",
"{ \"gone\" }\n",
),
)];
let (status, body) = post_bytes(
&srv.addr,
"/v1/pkg/publish",
&pkg_archive("dup", "0.1.0", changed),
);
assert_eq!(status, 409, "duplicate version must be rejected, got: {body}");
assert_eq!(
branch_head(&srv.addr), head_after_first,
"a 409'd publish must not have appended any ops",
);
}
#[test]
fn each_declaration_is_published_exactly_once_however_many_files_import_it() {
let (srv, _tmp) = start_server();
let shared = concat!(
"fn code_one() -> Str\n examples {\n code_one() => \"one\",\n }\n{ \"one\" }\n",
"fn code_two() -> Str\n examples {\n code_two() => \"two\",\n }\n{ \"two\" }\n",
"fn code_three() -> Str\n examples {\n code_three() => \"three\",\n }\n{ \"three\" }\n",
);
let importer = |n: &str| -> String {
format!(
"import \"./error\" as e\nfn use_{n}() -> Str\n examples {{\n use_{n}() => \"one\",\n }}\n{{ e.code_one() }}\n"
)
};
let bodies: Vec<String> = ["a", "b", "c", "d", "f"].iter().map(|n| importer(n)).collect();
let mut files: Vec<(&str, &str)> = vec![("error.lex", shared)];
for (name, body) in [("a.lex", 0), ("b.lex", 1), ("c.lex", 2), ("d.lex", 3), ("f.lex", 4)] {
files.push((name, bodies[body].as_str()));
}
let (status, body) = post_bytes(
&srv.addr,
"/v1/pkg/publish",
&pkg_archive("dense", "0.1.0", &files),
);
assert_eq!(status, 200, "publish must succeed, got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON response");
let ops = parsed["ops"].as_array().expect("ops array in publish response");
let adds: Vec<&serde_json::Value> = ops.iter()
.filter(|op| op["kind"]["op"] == "add_function")
.collect();
assert_eq!(
adds.len(), 8,
"8 declarations in the package means 8 add_function ops; a per-importing-file \
load re-published `error.lex`'s three functions once per importer. Got {}: {:#?}",
adds.len(), ops,
);
let (status, body) = get(&srv.addr, "/v1/pkg/dense/0.1.0");
assert_eq!(status, 200, "record must be readable, got: {body}");
let rec: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
let names: Vec<String> = rec["function_names"].as_array().unwrap()
.iter().map(|v| v.as_str().unwrap().to_string()).collect();
let unique: std::collections::BTreeSet<&String> = names.iter().collect();
assert_eq!(unique.len(), names.len(), "no name is published twice, got: {names:?}");
assert_eq!(names.len(), 8, "one name per declaration, got: {names:?}");
let from_shared: Vec<&String> = names.iter().filter(|n| n.starts_with("error_")).collect();
assert_eq!(
from_shared.len(), 3,
"`error.lex`'s three functions appear once each, not once per importer, got: {names:?}",
);
assert!(
names.iter().all(|n| n.contains('.')),
"every published name carries its file's prefix, got: {names:?}",
);
}
#[test]
fn imports_are_attributed_to_the_file_that_declares_them() {
let (srv, _tmp) = start_server();
let files: Vec<(&str, &str)> = vec![
(
"helper.lex",
"import \"std.str\" as str\nfn shout(s :: Str) -> Str\n examples {\n shout(\"a\") => \"A\",\n }\n{ str.to_upper(s) }\n",
),
(
"main.lex",
"import \"./helper\" as h\nfn go() -> Str\n examples {\n go() => \"A\",\n }\n{ h.shout(\"a\") }\n",
),
];
let (status, body) = post_bytes(
&srv.addr,
"/v1/pkg/publish",
&pkg_archive("attrib", "0.1.0", &files),
);
assert_eq!(status, 200, "publish must succeed, got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON response");
let ops = parsed["ops"].as_array().expect("ops array");
let import_files: Vec<&str> = ops.iter()
.filter(|op| op["kind"]["op"] == "add_import")
.map(|op| op["kind"]["in_file"].as_str().unwrap_or("?"))
.collect();
assert_eq!(
import_files, vec!["src/helper.lex"],
"`std.str` is imported by helper.lex alone; main.lex merely imports helper. \
Got: {ops:#?}",
);
}
#[test]
fn a_type_error_anywhere_publishes_nothing() {
let (srv, _tmp) = start_server();
let head_before = branch_head(&srv.addr);
let files: Vec<(&str, &str)> = vec![
("a_ok.lex", "fn fine() -> Int\n examples {\n fine() => 1,\n }\n{ 1 }\n"),
("z_bad.lex", "fn broken() -> Int { \"not an int\" }\n"),
];
let (status, body) = post_bytes(
&srv.addr,
"/v1/pkg/publish",
&pkg_archive("atomic", "0.1.0", &files),
);
assert_eq!(status, 422, "a type error must be rejected, got: {status} {body}");
assert_eq!(
branch_head(&srv.addr), head_before,
"a rejected publish must leave the branch head where it was",
);
}
#[test]
fn release_snapshots_the_head_immutably() {
let (srv, _tmp) = start_server();
let archive = pkg_archive("relpkg", "0.1.0", &[("a.lex", "fn f() -> Int { 1 }\n")]);
let (s, b) = post_bytes(&srv.addr, "/v1/pkg/publish", &archive);
assert_eq!(s, 200, "publish: {b}");
let (s, b) = post_bytes(&srv.addr, "/v1/pkg/relpkg/release", br#"{"version":"1.0.0"}"#);
assert_eq!(s, 201, "release: {b}");
let v: serde_json::Value = serde_json::from_str(&b).unwrap();
assert!(v["head_op"].as_str().is_some(), "release response carries head_op: {b}");
let (s, b) = get(&srv.addr, "/v1/pkg/relpkg/1.0.0");
assert_eq!(s, 200, "read release: {b}");
let rec: serde_json::Value = serde_json::from_str(&b).unwrap();
assert!(rec["head_op"].as_str().is_some(), "record has head_op: {b}");
let (s, _b) = post_bytes(&srv.addr, "/v1/pkg/relpkg/release", br#"{"version":"1.0.0"}"#);
assert_eq!(s, 409, "re-release of an existing version must be rejected");
}
#[test]
fn released_version_archive_is_rendered_from_the_op_log_head() {
let (srv, _tmp) = start_server();
let src = "fn double(x :: Int) -> Int { x * 2 }\n";
let archive = pkg_archive("oplogpkg", "0.1.0", &[("lib.lex", src)]);
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/publish", &archive).0, 200, "publish");
assert_eq!(
post_bytes(&srv.addr, "/v1/pkg/oplogpkg/release", br#"{"version":"1.0.0"}"#).0,
201, "release"
);
let (status, body) = get_raw(&srv.addr, "/v1/pkg/oplogpkg/1.0.0/archive");
assert_eq!(status, 200, "released-version archive should be rendered, not 404");
assert!(!body.is_empty(), "archive body must be non-empty");
let manifest = extract(&body, "lex.toml").expect("archive has lex.toml");
assert!(manifest.contains("oplogpkg"), "manifest names the package: {manifest}");
let lib = extract(&body, "src/lib.lex").expect("archive has src/lib.lex");
assert!(
lib.contains("fn double(") && lib.contains("x * 2"),
"rendered source must carry the unmangled function: {lib}"
);
}
#[test]
fn released_multimodule_archive_renders_the_full_src_tree() {
let (srv, _tmp) = start_server();
let util = "fn twice(x :: Int) -> Int { x + x }\n";
let lib = "import \"./util\" as u\n\nfn quad(x :: Int) -> Int { u.twice(u.twice(x)) }\n";
let archive = pkg_archive("mm", "0.1.0", &[("util.lex", util), ("lib.lex", lib)]);
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/publish", &archive).0, 200, "publish");
assert_eq!(
post_bytes(&srv.addr, "/v1/pkg/mm/release", br#"{"version":"1.0.0"}"#).0,
201, "release"
);
let (status, body) = get_raw(&srv.addr, "/v1/pkg/mm/1.0.0/archive");
assert_eq!(status, 200, "multi-module archive should render");
let util_out = extract(&body, "src/util.lex").expect("archive has src/util.lex");
let lib_out = extract(&body, "src/lib.lex").expect("archive has src/lib.lex");
assert!(util_out.contains("fn twice("), "util.lex: {util_out}");
assert!(lib_out.contains("fn quad(") && lib_out.contains("import \"./util\""),
"lib.lex must keep quad + the local import: {lib_out}");
assert!(!lib_out.contains("fn lib_") && !util_out.contains("fn util_"),
"declarations must be de-mangled");
}
#[test]
fn release_allows_a_patch_for_a_body_only_change_and_requires_minor_for_additions() {
let (srv, _tmp) = start_server();
let v1 = "fn f() -> Int { 1 }\n";
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/publish", &pkg_archive("gate2", "0.1.0", &[("lib.lex", v1)])).0, 200);
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/gate2/release", br#"{"version":"1.0.0"}"#).0, 201);
let v2 = "fn f() -> Int { 42 }\n";
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/publish", &pkg_archive("gate2", "0.2.0", &[("lib.lex", v2)])).0, 200);
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/gate2/release", br#"{"version":"1.0.1"}"#).0, 201, "body-only change is a valid patch");
let v3 = "fn f() -> Int { 42 }\nfn g() -> Int { 7 }\n";
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/publish", &pkg_archive("gate2", "0.3.0", &[("lib.lex", v3)])).0, 200);
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/gate2/release", br#"{"version":"1.0.2"}"#).0, 422, "addition as patch refused");
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/gate2/release", br#"{"version":"1.1.0"}"#).0, 201, "addition as minor accepted");
}
#[test]
fn api_diff_reports_change_between_two_releases() {
let (srv, _tmp) = start_server();
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/publish",
&pkg_archive("adiff", "0.1.0", &[("lib.lex", "fn f() -> Int { 1 }\n")])).0, 200);
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/adiff/release", br#"{"version":"1.0.0"}"#).0, 201);
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/publish",
&pkg_archive("adiff", "0.2.0", &[("lib.lex", "fn f() -> Int { 1 }\nfn g() -> Int { 2 }\n")])).0, 200);
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/adiff/release", br#"{"version":"1.1.0"}"#).0, 201);
let (status, body) = get(&srv.addr, "/v1/pkg/adiff/api-diff?from=1.0.0&to=1.1.0");
assert_eq!(status, 200, "api-diff: {body}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["change"], "additive", "adding g is additive: {body}");
assert!(v["detail"].as_str().unwrap().contains("g"), "detail names g: {body}");
assert_eq!(v["renames"].as_array().unwrap().len(), 0, "an addition is not a rename");
assert_eq!(get(&srv.addr, "/v1/pkg/adiff/api-diff").0, 400);
}
#[test]
fn review_inbox_lists_stages_and_a_verdict_updates_it() {
let (srv, _tmp) = start_server();
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/publish",
&pkg_archive("rev", "0.1.0", &[("lib.lex", "fn f() -> Int { 1 }\nfn g() -> Int { 2 }\n")])).0, 200);
let (status, body) = get(&srv.addr, "/v1/review/inbox");
assert_eq!(status, 200, "inbox: {body}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let items = v["items"].as_array().unwrap();
assert_eq!(items.len(), 2, "two stages in the inbox: {body}");
assert!(items.iter().all(|it| it["needs_review"] == true), "all need review: {body}");
let stage_id = items[0]["stage_id"].as_str().unwrap().to_string();
let verdict = format!(r#"{{"stage_id":"{stage_id}","verdict":"approve","reviewer":"alfonso"}}"#);
let (s, b) = post_bytes(&srv.addr, "/v1/review/verdict", verdict.as_bytes());
assert_eq!(s, 201, "verdict: {b}");
let (_s, body) = get(&srv.addr, "/v1/review/inbox");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let approved = v["items"].as_array().unwrap().iter()
.find(|it| it["stage_id"] == stage_id).unwrap();
assert_eq!(approved["review"], "approved", "verdict reflected: {body}");
assert_eq!(approved["needs_review"], false);
assert_eq!(post_bytes(&srv.addr, "/v1/review/verdict",
br#"{"stage_id":"nope","verdict":"approve","reviewer":"x"}"#).0, 404);
assert_eq!(post_bytes(&srv.addr, "/v1/review/verdict",
format!(r#"{{"stage_id":"{stage_id}","verdict":"maybe","reviewer":"x"}}"#).as_bytes()).0, 400);
}
#[test]
fn release_records_declared_dependencies() {
let (srv, _tmp) = start_server();
assert_eq!(post_bytes(&srv.addr, "/v1/pkg/publish",
&pkg_archive("dg", "0.1.0", &[("lib.lex", "fn f() -> Int { 1 }\n")])).0, 200);
let (s, b) = post_bytes(&srv.addr, "/v1/pkg/dg/release",
br#"{"version":"1.0.0","dependencies":["lex-nt","lex-crypto"]}"#);
assert_eq!(s, 201, "release: {b}");
let (s, body) = get(&srv.addr, "/v1/pkg/dg/1.0.0");
assert_eq!(s, 200, "record: {body}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let deps: Vec<String> = v["dependencies"].as_array().unwrap()
.iter().map(|d| d.as_str().unwrap().to_string()).collect();
assert!(deps.contains(&"lex-nt".to_string()) && deps.contains(&"lex-crypto".to_string()),
"declared deps recorded: {body}");
}