mod common;
use std::path::Path;
use common::*;
use semora_proto::{Level, Scope};
use semora_server::auth::{apikey, users};
use semora_server::projects::access::{self, ProjectAction};
#[tokio::test(flavor = "multi_thread")]
async fn push_file_then_push_again_replaces_it() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("notes.md", "# Notes\nfirst");
let out = cli.run(&["push", "notes.md", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
let v = out.json();
let id = v["id"].as_str().unwrap().to_string();
assert_eq!(v["rev"], 1);
assert_eq!(v["title"], "Notes");
let cache = cli.cache();
assert_eq!(cache.len(), 1, "{cache:?}");
assert!(
Path::new(&cache[0][0]).is_absolute() && cache[0][0].ends_with("notes.md"),
"{cache:?}"
);
assert_eq!(cache[0][1..], [server.url.clone(), id.clone()]);
cli.write("notes.md", "# Notes\nsecond");
let v = cli.run(&["push", "notes.md", "--json"]).await.json();
assert_eq!(v["id"], id.as_str(), "same document");
assert_eq!(v["rev"], 2);
let v = cli
.run(&["push", "notes.md", "--new", "--json"])
.await
.json();
assert_ne!(v["id"], id.as_str(), "--new forces a new document");
let cache = cli.cache();
assert_eq!(
cache.len(),
1,
"the new document replaces the line: {cache:?}"
);
assert_eq!(cache[0][2], v["id"].as_str().unwrap());
}
#[tokio::test(flavor = "multi_thread")]
async fn push_text_output_shows_urls() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("a.md", "# Alpha");
let out = cli
.run(&["push", "a.md", "-m", "first", "--tag", "x,y"])
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains(&format!("{}/d/", server.url)),
"{}",
out.stdout
);
assert!(out.stdout.contains("ws://127.0.0.1"), "{}", out.stdout);
assert!(out.stdout.contains("Alpha"));
}
#[tokio::test(flavor = "multi_thread")]
async fn push_directory_as_bundle_skipping_hidden_and_unsupported() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("site/index.md", "# Site\n[guide](guide/setup.md)");
cli.write("site/guide/setup.md", "# Setup");
cli.write("site/img/logo.png", b"\x89PNG\r\n\x1a\n");
cli.write("site/.git/config", "[core]");
cli.write("site/node_modules/x/index.js", "x");
cli.write("site/notes.txt", "not served");
let out = cli.run(&["push", "site", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
let v = out.json();
assert_eq!(v["kind"], "bundle");
assert_eq!(v["files"], 3, "{v}");
assert!(
out.stderr.contains("notes.txt"),
"unsupported files are named in a warning: {}",
out.stderr
);
assert!(cli.cache()[0][0].ends_with("site"), "{:?}", cli.cache());
let v2 = cli.run(&["push", "site", "--json"]).await.json();
assert_eq!(v2["id"], v["id"]);
assert_eq!(v2["files"], 3);
}
#[tokio::test(flavor = "multi_thread")]
async fn single_file_with_local_links_warns() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("index.md", "# Home\nSee [the guide](guide.md).");
cli.write("guide.md", "# Guide");
let out = cli.run(&["push", "index.md"]).await;
assert_eq!(out.code, 0);
assert!(
out.stderr.to_lowercase().contains("bundle"),
"{}",
out.stderr
);
}
#[tokio::test(flavor = "multi_thread")]
async fn get_show_ls_and_whoami() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("doc.md", "# Doc Title\nbody text");
let id = cli
.run(&["push", "doc.md", "--json", "--tag", "alpha"])
.await
.json()["id"]
.as_str()
.unwrap()
.to_string();
let out = cli.run(&["get", &id]).await;
assert_eq!(out.code, 0);
assert_eq!(out.stdout, "# Doc Title\nbody text");
let out = cli.run(&["get", &id, "doc.md", "-o", "copy.md"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(
std::fs::read_to_string(cli.path("copy.md")).unwrap(),
"# Doc Title\nbody text"
);
let v = cli.run(&["show", &id, "--json"]).await.json();
assert_eq!(v["title"], "Doc Title");
assert_eq!(v["tags"], serde_json::json!(["alpha"]));
let out = cli.run(&["show", &format!("{}/d/{id}/", server.url)]).await;
assert_eq!(out.code, 0, "ids may be given as URLs: {}", out.stderr);
assert!(out.stdout.contains("Doc Title"));
let out = cli.run(&["ls"]).await;
assert!(
out.stdout.contains(&id) && out.stdout.contains("Doc Title"),
"{}",
out.stdout
);
let v = cli.run(&["ls", "--json"]).await.json();
assert_eq!(v["docs"][0]["id"], id.as_str());
let v = cli.run(&["whoami", "--json"]).await.json();
assert_eq!(v["user"]["email"], "admin@example.com");
assert_eq!(v["key"]["name"], "cli-test");
}
#[tokio::test(flavor = "multi_thread")]
async fn tag_and_rm() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("t.md", "# T");
let id = cli
.run(&["push", "t.md", "--json", "--tag", "old"])
.await
.json()["id"]
.as_str()
.unwrap()
.to_string();
let out = cli.run(&["tag", &id, "+new", "-old", "plain"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
let v = cli.run(&["show", &id, "--json"]).await.json();
assert_eq!(v["tags"], serde_json::json!(["new", "plain"]));
assert_eq!(cli.run(&["rm", &id]).await.code, 0);
assert_eq!(cli.run(&["show", &id]).await.code, 4);
}
#[tokio::test(flavor = "multi_thread")]
async fn exit_codes_for_auth_and_missing_documents() {
let server = Server::start().await;
let mut cli = Cli::new(&server);
let out = cli.run(&["show", "AAAAAAAAAA"]).await;
assert_eq!(out.code, 4, "{}", out.stderr);
cli.key = Some("sm_AAAAAAAA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into());
let out = cli.run(&["whoami"]).await;
assert_eq!(out.code, 3, "{}", out.stderr);
cli.key = None;
let out = cli.run(&["whoami"]).await;
assert_eq!(
out.code, 3,
"no key at all is an auth error: {}",
out.stderr
);
assert!(out.stderr.contains("semora login"), "{}", out.stderr);
}
#[tokio::test(flavor = "multi_thread")]
async fn dotenv_supplies_the_key_but_real_env_wins() {
let server = Server::start().await;
let mut cli = Cli::new(&server);
cli.write(".env", format!("SEMORA_API_KEY={}\n", server.key));
cli.key = None;
let out = cli.run(&["whoami"]).await;
assert_eq!(out.code, 0, ".env key used: {}", out.stderr);
cli.key = Some("sm_BBBBBBBB_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".into());
assert_eq!(cli.run(&["whoami"]).await.code, 3, "process env beats .env");
let out = cli.run(&["whoami", "--api-key", &server.key]).await;
assert_eq!(out.code, 0, "--api-key beats everything: {}", out.stderr);
}
#[tokio::test(flavor = "multi_thread")]
async fn dotenv_in_a_parent_directory_is_found() {
let server = Server::start().await;
let mut cli = Cli::new(&server);
cli.write(
".env",
format!("SEMORA_API_KEY={}\nSEMORA_DEFAULT_TAGS=proj\n", server.key),
);
cli.write("sub/dir/x.md", "# X");
cli.key = None;
let mut cmd = cli.command(&["push", "x.md", "--json"]);
cmd.current_dir(cli.path("sub/dir"));
let out = tokio::task::spawn_blocking(move || cmd.output().unwrap())
.await
.unwrap();
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let id = serde_json::from_slice::<serde_json::Value>(&out.stdout).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
cli.key = Some(server.key.clone());
let v = cli.run(&["show", &id, "--json"]).await.json();
assert_eq!(
v["tags"],
serde_json::json!(["proj"]),
"SEMORA_DEFAULT_TAGS from .env"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn meta_pairs_become_json() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("m.md", "# M");
let id = cli
.run(&[
"push",
"m.md",
"--json",
"--meta",
"project=seed",
"--meta",
"priority=2",
])
.await
.json()["id"]
.as_str()
.unwrap()
.to_string();
let v = cli.run(&["show", &id, "--json"]).await.json();
assert_eq!(
v["meta"],
serde_json::json!({"project": "seed", "priority": 2})
);
}
#[tokio::test(flavor = "multi_thread")]
async fn remembered_but_deleted_document_gets_recreated() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("r.md", "# R");
let id = cli.run(&["push", "r.md", "--json"]).await.json()["id"]
.as_str()
.unwrap()
.to_string();
cli.run(&["rm", &id]).await;
let out = cli.run(&["push", "r.md", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_ne!(out.json()["id"], id.as_str());
assert!(out.stderr.contains("new document"), "{}", out.stderr);
}
#[tokio::test(flavor = "multi_thread")]
async fn push_to_explicit_id_that_does_not_exist_fails_with_4() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("x.md", "# X");
assert_eq!(
cli.run(&["push", "x.md", "--id", "ZZZZZZZZZZ"]).await.code,
4
);
}
#[tokio::test(flavor = "multi_thread")]
async fn keys_create_list_and_revoke() {
let server = Server::start().await;
let mut cli = Cli::new(&server);
let out = cli
.run(&[
"keys",
"create",
"ci-bot",
"--scopes",
"docs:read",
"--expires-in-days",
"7",
"--json",
])
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
let created = out.json();
let secret = created["secret"].as_str().unwrap().to_string();
let id = created["key"]["id"].as_i64().unwrap();
assert_eq!(created["key"]["scopes"], serde_json::json!(["docs:read"]));
let listed = cli.run(&["keys", "ls", "--json"]).await.json();
assert!(
listed
.as_array()
.unwrap()
.iter()
.any(|k| k["name"] == "ci-bot")
);
let text = cli.run(&["keys", "ls"]).await.stdout;
assert!(
text.contains("ci-bot") && text.contains("expires"),
"{text}"
);
let as_bot = Cli {
key: Some(secret.clone()),
..Cli::new(&server)
};
cli.write("x.md", "# X");
assert_eq!(as_bot.run(&["whoami"]).await.code, 0);
assert_eq!(cli.run(&["keys", "revoke", &id.to_string()]).await.code, 0);
cli.key = Some(secret);
assert_eq!(cli.run(&["whoami"]).await.code, 3);
}
#[tokio::test(flavor = "multi_thread")]
async fn skill_prints_the_same_guide_as_the_server() {
let server = Server::start().await;
let cli = Cli::new(&server);
let out = cli.run(&["skill"]).await;
assert_eq!(out.code, 0);
let served = reqwest::get(format!("{}/docs.md", server.url))
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(out.stdout, served);
assert!(out.stdout.contains(&server.url));
}
#[tokio::test(flavor = "multi_thread")]
async fn missing_server_url_is_a_clear_error() {
let server = Server::start().await;
let mut cli = Cli::new(&server);
cli.url = String::new();
let mut cmd = cli.command(&["whoami"]);
cmd.env_remove("SEMORA_URL");
let out = tokio::task::spawn_blocking(move || cmd.output().unwrap())
.await
.unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("SEMORA_URL"));
}
#[tokio::test(flavor = "multi_thread")]
async fn project_ls_shows_the_level() {
let server = Server::start().await;
let cli = Cli::new(&server);
let out = cli.run(&["project", "init", "--name", "proj"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
let out = cli.run(&["project", "ls"]).await;
assert!(
out.stdout.contains("proj") && out.stdout.contains("owner"),
"{}",
out.stdout
);
let bo = users::create(
&server.state.db,
"bo@example.com",
"Bo",
"correct horse battery",
false,
)
.await
.unwrap();
let (_, bo_key) = apikey::create(&server.state, bo.id, "bo", &Scope::all(), None, "web")
.await
.unwrap();
let project = access::project_for(
&server.state.db,
server.user_id,
"proj",
ProjectAction::View,
)
.await
.unwrap();
semora_server::collab::add_collaborator(
&server.state.db,
project.id,
bo.id,
Level::Reviewer,
Some(server.user_id),
)
.await
.unwrap();
let mut bo_cli = Cli::new(&server);
bo_cli.key = Some(bo_key);
let out = bo_cli.run(&["project", "ls", "--json"]).await;
assert_eq!(out.json()[0]["level"], "reviewer", "{}", out.stdout);
let out = bo_cli.run(&["project", "ls"]).await;
assert!(out.stdout.contains("reviewer"), "{}", out.stdout);
}
#[tokio::test(flavor = "multi_thread")]
async fn show_prints_who_pushed() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("plan.md", "# Plan");
let out = cli.run(&["push", "plan.md", "--json"]).await;
let id = out.json()["id"].as_str().unwrap().to_string();
let out = cli.run(&["show", &id]).await;
assert!(out.stdout.contains("by Admin (cli-test)"), "{}", out.stdout);
}