crates/mock-upcloud/tests/sweep_upcloud_api_conformance.rs
//! ★ **Every UpCloud call the real cloud guest can make has a handler in the mock** (lane
//! TEST-SWEEP-MONETIZE, 2026-10-24).
//!
//! The `upcloud` guest (monetize-impl `guests/upcloud`) builds no path of its own: it is an
//! `upcloud_api::Exchange` under `upcloud_api::Over`, so the ONE place a typed call becomes a
//! method and a path is `Over` (edda `crates/upcloud-api/src/over.rs`). Two readings of that
//! surface are driven against the mock here, and a route the mock does not know answers
//! `404 NOT_FOUND "Not found."` (`http::route`'s fall-through) — which is told apart from a
//! `404 SERVER_NOT_FOUND` for a uuid that does not exist (the route matched; the resource did
//! not).
//!
//! 1. The trait, as linked: every `UpCloudApi` method of the `upcloud-api` this workspace locks,
//! through the mock's in-process face (`face::FakeUpCloud`, the same `Over`).
//! 2. The sibling's source, as the guest links it: the `get("…")` / `send(Method::…, "…")` lines
//! of `../../../edda/crates/upcloud-api/src/over.rs` (edda main, which the guest's Build Thing
//! links) — so a path edda adds reaches this test before the published crate does. Without the
//! sibling the list written down here (edda main 2026-10-24) is used, and the test says so.
use std::path::Path;
use mock_upcloud::face::FakeUpCloud;
use mock_upcloud::{Clock, Estate, Faults, Mock};
use serde_json::{json, Value};
use upcloud_api::{Backups, BootOrder, Console, DeviceKind, NewStorage, Reply, SizeSpelling, Stop, UpCloudApi, WithStorages};
const GHOST: &str = "00000000-0000-4000-8000-00000000beef";
fn mock() -> std::sync::Arc<Mock> {
Mock::new(Estate::new(Clock::virtual_only(), Faults::none(), 4242))
}
/// The fall-through of the mock's router: no handler for this method and path.
fn is_a_route_miss(status: u16, body: &Value) -> bool {
status == 404 && body.pointer("/error/error_code").and_then(Value::as_str) == Some("NOT_FOUND") && body.pointer("/error/error_message").and_then(Value::as_str) == Some("Not found.")
}
fn row(name: &str, r: Result<Reply, String>) -> (String, Option<(u16, Value)>) {
match r {
Ok(reply) => (name.to_string(), Some((reply.status, reply.body))),
Err(e) => (format!("{name}: {e}"), None),
}
}
/// 1. The trait as linked: every method of `UpCloudApi`, each answered by SOME handler.
#[test]
fn every_typed_call_of_the_linked_upcloud_api_has_a_handler_in_the_mock() {
let api = FakeUpCloud::over(mock(), "ucat_mock_sweep_conformance");
let labels = [("shop", "one")];
let new = NewStorage { title: "sweep", zone: "se-sto1", size_gib: 10, tier: "maxiops", labels: &labels };
let rows = vec![
row("account", api.account()),
row("price", api.price()),
row("servers", api.servers()),
row("servers_labelled", api.servers_labelled(&labels)),
row("server", api.server(GHOST)),
row("firewall_rules", api.firewall_rules(GHOST)),
row("storages_private", api.storages_private()),
row("storages_labelled", api.storages_labelled(&labels)),
row("storage", api.storage(GHOST)),
row("zones", api.zones()),
row("plans", api.plans()),
row("create_server", api.create_server(&json!({"server": {"zone": "se-sto1", "title": "sweep", "hostname": "sweep", "plan": "no-such-plan"}}))),
row("stop_server soft", api.stop_server(GHOST, Stop::Soft { timeout_s: 60 })),
row("stop_server hard", api.stop_server(GHOST, Stop::Hard)),
row("start_server", api.start_server(GHOST)),
row("modify_server_plan", api.modify_server_plan(GHOST, "1xCPU-1GB")),
row("set_boot_order", api.set_boot_order(GHOST, BootOrder::Disk)),
row("set_console off", api.set_console(GHOST, Console::Off)),
row("set_console vnc", api.set_console(GHOST, Console::Vnc { password: "sweep-sweep" })),
row("attach_storage", api.attach_storage(GHOST, DeviceKind::Cdrom, GHOST, None)),
row("attach_storage at", api.attach_storage(GHOST, DeviceKind::Disk, GHOST, Some("virtio"))),
row("detach_storage", api.detach_storage(GHOST, "virtio:0")),
row("eject_cdrom", api.eject_cdrom(GHOST)),
row("delete_server", api.delete_server(GHOST, WithStorages::AndTheirBackups)),
row("delete_server keep", api.delete_server(GHOST, WithStorages::LeaveThem)),
row("create_storage", api.create_storage(&new)),
row("clone_storage", api.clone_storage(GHOST, "sweep-clone", "se-sto1", "maxiops")),
row("import_direct_upload", api.import_direct_upload(GHOST)),
row("modify_storage_size", api.modify_storage_size(GHOST, 20)),
row("modify_storage_size_as number", api.modify_storage_size_as(GHOST, 20, SizeSpelling::Number)),
row("modify_storage_size_as string", api.modify_storage_size_as(GHOST, 20, SizeSpelling::String)),
row("resize_filesystem", api.resize_filesystem(GHOST)),
row("delete_storage", api.delete_storage(GHOST, Backups::Delete)),
row("delete_storage unsaid", api.delete_storage(GHOST, Backups::Unsaid)),
];
// `upload_direct` is `Call::Upload`, not an API path: the guest never carries it (monetize
// never uploads a medium through the cloud guest's door), so it is not a route here.
let misses: Vec<String> = rows
.iter()
.filter_map(|(name, answer)| match answer {
Some((status, body)) if is_a_route_miss(*status, body) => Some(format!("{name}: no handler ({status} {body})")),
Some(_) => None,
// A transport-level refusal from the face is not a missing route either; it names itself.
None => Some(name.clone()),
})
.collect();
assert!(misses.is_empty(), "the mock has no handler for these typed calls of the real guest:\n {}", misses.join("\n "));
assert!(rows.len() >= 30, "{} rows: the trait shrank?", rows.len());
}
/// The `(method, path)` pairs `Over` emits on edda main (2026-10-24), used when the sibling is
/// not beside this checkout. The `{…}` parts are a uuid; a trailing `{}` is a label query.
const EDDA_MAIN_PATHS: &[(&str, &str)] = &[
("GET", "/account"),
("GET", "/plan"),
("GET", "/price"),
("GET", "/server"),
("GET", "/server/{uuid}"),
("GET", "/server/{uuid}/firewall_rule"),
("GET", "/server{}"),
("GET", "/storage/backup{}"),
("GET", "/storage/private"),
("GET", "/storage/{uuid}"),
("GET", "/storage/{uuid}/import"),
("GET", "/storage{}"),
("GET", "/zone"),
("POST", "/server"),
("POST", "/server/{server}/cdrom/eject"),
("POST", "/server/{server}/storage/attach"),
("POST", "/server/{server}/storage/detach"),
("POST", "/server/{uuid}/start"),
("POST", "/server/{uuid}/stop"),
("POST", "/storage"),
("POST", "/storage/{backup}/restore"),
("POST", "/storage/{uuid}/clone"),
("POST", "/storage/{uuid}/import"),
("POST", "/storage/{uuid}/resize"),
("PUT", "/server/{uuid}"),
("PUT", "/server/{uuid}/firewall_rule"),
("PUT", "/storage/{uuid}"),
];
/// The `(method, path)` pairs out of edda's `over.rs`: `self.get("…")`, `self.get(format!("…"))`,
/// `self.send(Method::Post, "…", …)`, `self.send(Method::Put, format!("…"), …)`.
fn paths_in(over_rs: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for line in over_rs.lines() {
let line = line.trim();
let (method, rest) = if let Some(r) = line.strip_prefix("self.get(") {
("GET", r)
} else if let Some(r) = line.strip_prefix("self.send(Method::") {
let Some((m, r)) = r.split_once(',') else { continue };
(match m.trim() { "Post" => "POST", "Put" => "PUT", "Delete" => "DELETE", "Patch" => "PATCH", _ => continue }, r.trim_start())
} else {
continue;
};
let rest = rest.strip_prefix("format!(").unwrap_or(rest);
let Some(rest) = rest.strip_prefix('"') else { continue };
let Some((path, _)) = rest.split_once('"') else { continue };
out.push((method.to_string(), path.to_string()));
}
out.sort();
out.dedup();
out
}
/// A path as `Over` writes it, made concrete: every `{…}` a ghost uuid, a `{}` tail dropped.
fn concrete(path: &str) -> String {
let mut s = String::new();
let mut rest = path;
while let Some(i) = rest.find('{') {
s.push_str(&rest[..i]);
let Some(j) = rest[i..].find('}') else { break };
if j > 1 {
s.push_str(GHOST);
}
rest = &rest[i + j + 1..];
}
s.push_str(rest);
s
}
/// 2. The sibling's source, as the guest links it.
#[test]
fn every_path_edda_main_emits_has_a_handler_in_the_mock() {
let sibling = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../edda/crates/upcloud-api/src/over.rs");
let (from, pairs): (String, Vec<(String, String)>) = match std::fs::read_to_string(&sibling) {
Ok(src) => {
let pairs = paths_in(&src);
assert!(pairs.len() >= EDDA_MAIN_PATHS.len(), "{}: {} paths read, fewer than the {} written down here — the parser missed a spelling", sibling.display(), pairs.len(), EDDA_MAIN_PATHS.len());
(sibling.display().to_string(), pairs)
}
Err(_) => {
eprintln!("no edda sibling at {}: the list written down for edda main 2026-10-24 is used", sibling.display());
("the list in this test (edda main 2026-10-24)".into(), EDDA_MAIN_PATHS.iter().map(|(m, p)| (m.to_string(), p.to_string())).collect())
}
};
let m = mock();
let mut misses = Vec::new();
for (method, path) in &pairs {
let target = format!("/1.3{}", concrete(path));
let body = match method.as_str() {
"GET" => Value::Null,
_ => json!({}),
};
let (status, answer) = mock_upcloud::http::probe(&m, method, &target, body);
if is_a_route_miss(status, &answer) {
misses.push(format!("{method} {path} (probed as {method} {target})"));
}
}
assert!(misses.is_empty(), "the mock has no handler for these paths of {from}:\n {}", misses.join("\n "));
assert!(pairs.len() >= 27, "{from}: only {} paths", pairs.len());
}
#[test]
fn the_parser_reads_every_spelling_over_uses() {
let src = r#"
fn account(&self) -> Result<Reply, String> {
self.get("/account".into())
}
fn server(&self, uuid: &str) -> Result<Reply, String> {
self.get(format!("/server/{uuid}"))
}
fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> {
self.get(format!("/server{}", label_query(labels)))
}
fn create_server(&self, document: &Value) -> Result<Reply, String> {
self.send(Method::Post, "/server".into(), document.clone())
}
fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String> {
self.send(Method::Put, format!("/server/{uuid}"), body::plan(plan))
}
"#;
let pairs = paths_in(src);
assert_eq!(pairs, vec![("GET".to_string(), "/account".to_string()), ("GET".into(), "/server/{uuid}".into()), ("GET".into(), "/server{}".into()), ("POST".into(), "/server".into()), ("PUT".into(), "/server/{uuid}".into())]);
assert_eq!(concrete("/server/{uuid}/firewall_rule"), format!("/server/{GHOST}/firewall_rule"));
assert_eq!(concrete("/storage/backup{}"), "/storage/backup");
assert_eq!(concrete("/server{}"), "/server");
assert!(is_a_route_miss(404, &json!({"error": {"error_code": "NOT_FOUND", "error_message": "Not found."}})));
assert!(!is_a_route_miss(404, &json!({"error": {"error_code": "SERVER_NOT_FOUND", "error_message": "server x not found"}})));
}