use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
struct Fixture {
root: PathBuf,
}
impl Fixture {
fn new(label: &str) -> Self {
let ordinal = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"noxid-wo19-endpoint-cache-{label}-{}-{ordinal}",
std::process::id()
));
fs::create_dir_all(&root).expect("create endpoint-cache fixture");
fs::write(
root.join("Noxid.toml"),
"[app]\ntitle = \"Endpoint cache\"\n",
)
.expect("write project config");
fs::write(root.join("package.json"), "{\"type\":\"module\"}\n")
.expect("write module marker");
Self { root }
}
fn write(&self, relative: &str, contents: &str) {
let path = self.root.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create fixture parent");
}
fs::write(path, contents).expect("write fixture file");
}
fn build(&self) -> Output {
Command::new(env!("CARGO_BIN_EXE_noxid"))
.arg("build")
.arg(&self.root)
.arg("--out-dir")
.arg(self.root.join("dist"))
.output()
.expect("build endpoint-cache fixture")
}
fn run_node(&self, source: &str) -> Output {
let script = self.root.join("dist/assert-cache.mjs");
fs::write(&script, source).expect("write endpoint-cache Node assertion");
Command::new("node")
.arg(script.file_name().expect("script filename"))
.current_dir(self.root.join("dist"))
.output()
.expect("run endpoint-cache Node assertion")
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn assert_success(output: &Output, context: &str) {
assert!(
output.status.success(),
"{context}:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn endpoint_cache_is_declared_in_manifests_and_emitted_on_success_only() {
let fixture = Fixture::new("swr");
fixture.write(
"server/api/catalog.get.nox",
r#"endpoint Catalog {
cache: swr 60
result: String
handler { return "catalog" }
}
"#,
);
fixture.write(
"server/host.js",
r#"export async function authorize({ capability }) {
return capability === "cache.invalidate";
}
export async function invalidateCache(tags, { environment }) {
environment.invalidations.push(...tags);
return { invalidated: tags.length };
}
"#,
);
let build = fixture.build();
assert_success(&build, "build cached GET endpoint");
let security = fs::read_to_string(fixture.root.join("dist/server/security.manifest.json"))
.expect("read endpoint security manifest");
assert!(
security.contains(
"\"cache\":{\"id\":\"endpoint-cache:Catalog\",\"mode\":\"swr\",\"seconds\":60,\"tags\":[\"endpoint:Catalog@1\"]}"
),
"cache policy missing from security product: {security}"
);
let graph = fs::read_to_string(fixture.root.join("dist/app.graph.json"))
.expect("read application graph");
assert!(
graph.contains(
"{\"from\":\"endpoint:Catalog@1\",\"kind\":\"caches-as\",\"to\":\"endpoint-cache:Catalog\"}"
),
"cache edge missing from application graph: {graph}"
);
let node = fixture.run_node(
r#"import { fetch as handle } from "./server/handler.js";
const environment = { invalidations: [] };
let response = await handle(new Request("http://noxid.test/api/catalog"), environment);
if (response.status !== 200) throw new Error(`success changed: ${response.status}`);
if (response.headers.get("cache-control") !== "public, s-maxage=60, stale-while-revalidate=60") throw new Error(`cache-control missing: ${response.headers.get("cache-control")}`);
if (response.headers.get("x-noxid-cache-mode") !== "swr") throw new Error("mode missing");
if (response.headers.get("x-noxid-cache-tags") !== "endpoint:Catalog@1") throw new Error("stable tag missing");
response = await handle(new Request("http://noxid.test/_noxid/revalidate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tags: ["endpoint:Catalog@1"] }),
}), environment);
if (response.status !== 200 || environment.invalidations.join(",") !== "endpoint:Catalog@1") throw new Error(`revalidation failed: ${response.status} ${environment.invalidations}`);
"#,
);
assert_success(&node, "exercise emitted endpoint cache contract");
}
#[test]
fn endpoint_cache_rejects_mutating_routes_before_emission() {
let fixture = Fixture::new("post");
fixture.write(
"server/api/catalog.post.nox",
r#"endpoint Catalog {
cache: isr 300
result: String
}
"#,
);
let build = fixture.build();
assert!(
!build.status.success(),
"mutating cached endpoint unexpectedly built"
);
let stderr = String::from_utf8_lossy(&build.stderr);
assert!(
stderr.contains("error[ENDPOINT_CACHE_REQUIRES_GET]")
&& stderr.contains("only valid for GET endpoints"),
"mutating cache failure did not teach the legal alternative: {stderr}"
);
assert!(
!fixture.root.join("dist/server/handler.js").exists(),
"invalid cached endpoint emitted a handler"
);
}
#[test]
fn cached_host_analysis_ignores_inert_handler_decoys() {
let fixture = Fixture::new("host-decoys");
fixture.write(
"server/api/viewer.get.nox",
"endpoint Viewer { cache: swr 60 result: String }\n",
);
fixture.write(
"server/host.js",
r#"// "endpoint:Viewer@1": () => "comment";
const stringDecoy = '"endpoint:Viewer@1": () => "string"';
const templateDecoy = `"endpoint:Viewer@1": () => "template"`;
export const endpoints = Object.freeze({
"endpoint:Viewer@1": (_input, { environment }) => environment.viewer,
});
void stringDecoy;
void templateDecoy;
"#,
);
let build = fixture.build();
assert!(
!build.status.success(),
"inert host decoys admitted public caching"
);
let stderr = String::from_utf8_lossy(&build.stderr);
assert!(
stderr.contains("error[ENDPOINT_CACHE_HOST_HANDLER_UNANALYZABLE]"),
"host decoy refusal lacked its teaching diagnostic: {stderr}"
);
assert!(
!fixture.root.join("dist/server/handler.js").exists(),
"rejected personalized cache handler was published"
);
}