use std::sync::Arc;
use assert_cmd::cargo::cargo_bin;
use serde_json::Value;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::process::Command;
use tokio::sync::{oneshot, Mutex};
const EXPECTED_ORG: &str = "org_test";
struct HeaderServer {
base_url: String,
seen_org: Arc<Mutex<Option<String>>>,
shutdown: Option<oneshot::Sender<()>>,
join: Option<tokio::task::JoinHandle<()>>,
}
impl HeaderServer {
async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let seen_org = Arc::new(Mutex::new(None));
let seen_org_task = Arc::clone(&seen_org);
let (shutdown, mut stop) = oneshot::channel();
let join = tokio::spawn(async move {
tokio::select! {
result = listener.accept() => {
let (mut socket, _) = result.unwrap();
let mut request = Vec::new();
loop {
let mut chunk = [0u8; 1024];
let read = socket.read(&mut chunk).await.unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&chunk[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let request = String::from_utf8_lossy(&request);
let org = request.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
(name.eq_ignore_ascii_case("x-org-id")).then(|| value.trim().to_string())
});
*seen_org_task.lock().await = org.clone();
let (status, body) = if org.as_deref() == Some(EXPECTED_ORG) {
(
"200 OK",
r#"{"id":"prj_demo","slug":"demo","name":"Demo","orgId":"org_test","isActive":true}"#,
)
} else {
(
"404 Not Found",
r#"{"error":{"code":"project_not_found","message":"organization context required"}}"#,
)
};
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
}
_ = &mut stop => {}
}
});
Self {
base_url: format!("http://{addr}/v1"),
seen_org,
shutdown: Some(shutdown),
join: Some(join),
}
}
async fn shutdown(mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
if let Some(join) = self.join.take() {
let _ = join.await;
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn linked_project_org_is_sent_to_project_scoped_commands() {
let server = HeaderServer::start().await;
let workspace = tempfile::tempdir().unwrap();
let config = tempfile::tempdir().unwrap();
let link_dir = workspace.path().join(".sylphx");
std::fs::create_dir_all(&link_dir).unwrap();
std::fs::write(
link_dir.join("project.json"),
r#"{"projectId":"prj_demo","orgId":"org_test"}"#,
)
.unwrap();
let output = Command::new(cargo_bin!("sylphx"))
.current_dir(workspace.path())
.env("SYLPHX_CONFIG_DIR", config.path())
.args([
"--token",
"svc_test",
"--api-url",
&server.base_url,
"projects",
"get",
"prj_demo",
"--json",
])
.output()
.await
.unwrap();
assert!(
output.status.success(),
"CLI must preserve linked org context; stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let body: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(body["id"], "prj_demo");
assert_eq!(server.seen_org.lock().await.as_deref(), Some(EXPECTED_ORG));
server.shutdown().await;
}