api_search.rs
⎇
Raw
1//! Search API: one walk serves both scopes, streamed as SSE.
2
3mod common;
4
5use axum::http::StatusCode;
6use common::*;
7
8/// Root id for the whole-root (".") user root is 1 (first row inserted).
9const ROOT: i64 = 1;
10
11/// The `data:` payloads of an SSE body, in order.
12fn events(body: &str) -> Vec<serde_json::Value> {
13 body.lines()
14 .filter_map(|l| l.strip_prefix("data:"))
15 .map(|d| serde_json::from_str(d.trim()).expect("event is JSON"))
16 .collect()
17}
18
19#[tokio::test]
20async fn both_scopes_stream_from_one_walk() {
21 let env = Env::new().await;
22 let admin = env.admin().await;
23 let r = admin
24 .get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
25 .await;
26 assert_eq!(r.status, StatusCode::OK);
27 assert_eq!(
28 r.header("content-type").as_deref(),
29 Some("text/event-stream")
30 );
31 assert_eq!(r.header("x-accel-buffering").as_deref(), Some("no"));
32
33 let evs = events(&r.text());
34 // The name hit: matched on the entry's own name, with the server's
35 // sniffed kind.
36 let file = evs
37 .iter()
38 .find(|e| e["type"] == "file")
39 .expect("a name hit");
40 assert_eq!(file["path"], "docs/inner/hello.txt");
41 assert_eq!(file["kind"], "text");
42 assert_eq!(file["is_dir"], false);
43 // The content hit, from the same walk.
44 let m = evs
45 .iter()
46 .find(|e| e["type"] == "match")
47 .expect("a content hit");
48 assert_eq!(m["path"], "docs/inner/hello.txt");
49 assert_eq!(m["line"], 1);
50 assert_eq!(m["text"], "hello world");
51 // The stream always ends with the summary.
52 let done = evs.last().expect("a done event");
53 assert_eq!(done["type"], "done");
54 assert_eq!(done["stopped"], false);
55 assert!(done["scanned"].as_u64().unwrap() >= 8);
56}
57
58#[tokio::test]
59async fn path_narrows_the_walk_to_a_subfolder() {
60 let env = Env::new().await;
61 let admin = env.admin().await;
62 let paths = |body: String| -> Vec<String> {
63 events(&body)
64 .iter()
65 .filter(|e| e["type"] == "file")
66 .map(|e| e["path"].as_str().unwrap().to_string())
67 .collect()
68 };
69 // Inside `docs`: the hit is found and its path stays root-relative.
70 let r = admin
71 .get(&format!(
72 "/api/search?q=hello&scope=name&root={ROOT}&path=docs"
73 ))
74 .await;
75 assert_eq!(r.status, StatusCode::OK);
76 assert_eq!(paths(r.text()), vec!["docs/inner/hello.txt".to_string()]);
77 // The start folder itself is not a result.
78 let r = admin
79 .get(&format!(
80 "/api/search?q=docs&scope=name&root={ROOT}&path=docs"
81 ))
82 .await;
83 assert!(paths(r.text()).is_empty());
84 // Outside `docs`: nothing.
85 let r = admin
86 .get(&format!(
87 "/api/search?q=hello&scope=name&root={ROOT}&path=src"
88 ))
89 .await;
90 assert!(paths(r.text()).is_empty());
91 // A missing or escaping start folder is rejected.
92 let r = admin
93 .get(&format!("/api/search?q=hello&root={ROOT}&path=nope"))
94 .await;
95 assert!(r.status.is_client_error());
96 let r = admin
97 .get(&format!("/api/search?q=hello&root={ROOT}&path=../"))
98 .await;
99 assert!(r.status.is_client_error());
100}
101
102#[tokio::test]
103async fn name_scope_ignores_the_parent_path() {
104 let env = Env::new().await;
105 let admin = env.admin().await;
106 let r = admin
107 .get(&format!("/api/search?q=docs&scope=name&root={ROOT}"))
108 .await;
109 let paths: Vec<String> = events(&r.text())
110 .iter()
111 .filter(|e| e["type"] == "file")
112 .map(|e| e["path"].as_str().unwrap().to_string())
113 .collect();
114 // The folder itself, never the files inside it.
115 assert_eq!(paths, vec!["docs".to_string()]);
116}
117
118#[tokio::test]
119async fn search_rejects_bad_input_and_anonymous_callers() {
120 let env = Env::new().await;
121 let admin = env.admin().await;
122 let anon = Client::new(env.app.clone());
123
124 assert_eq!(
125 anon.get("/api/search?q=hello").await.status,
126 StatusCode::UNAUTHORIZED
127 );
128 assert_eq!(
129 admin.get("/api/search?q=%20").await.status,
130 StatusCode::BAD_REQUEST
131 );
132 assert_eq!(
133 admin.get("/api/search?q=hello&scope=nope").await.status,
134 StatusCode::BAD_REQUEST
135 );
136 assert_eq!(
137 admin.get("/api/search?q=hello&root=999").await.status,
138 StatusCode::FORBIDDEN
139 );
140}
141