Krolyxon
Commit 4e844bb

ferreniumtestsindex.rs

271 lines · 9.8 KB · Rust

1//! Discovery, metadata precedence, hiding, the index page and live updates.
2
3mod common;
4
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use axum::body::{Body, to_bytes};
9use axum::http::{Request, StatusCode};
10use common::{Work, bare, git, repo_with};
11use ferrenium::config::Config;
12use ferrenium::index::Refresh;
13use ferrenium::server;
14use ferrenium::state::{AppState, Overrides};
15use tower::ServiceExt;
16
17/// `repos` holds extra `[repos]` keys; `extra` any further tables.
18fn config(scan_path: &std::path::Path, repos: &str, extra: &str) -> Config {
19 let toml = format!(
20 "[repos]\nscan_path = {:?}\n{repos}\n[watch]\ndebounce_ms = 100\n{extra}",
21 scan_path.to_str().unwrap()
22 );
23 let mut config = Config::parse(&toml).expect("parse");
24 config.finish().expect("valid");
25 config
26}
27
28fn state(config: Config) -> Arc<AppState> {
29 let state = AppState::new(config, Overrides::default());
30 let config = state.config.load_full();
31 state.index.full_scan(&config);
32 state
33}
34
35async fn page(state: &Arc<AppState>, uri: &str) -> String {
36 let res = server::app(state.clone())
37 .oneshot(Request::get(uri).body(Body::empty()).unwrap())
38 .await
39 .unwrap();
40 assert_eq!(res.status(), StatusCode::OK);
41 String::from_utf8(
42 to_bytes(res.into_body(), usize::MAX)
43 .await
44 .unwrap()
45 .to_vec(),
46 )
47 .unwrap()
48}
49
50#[test]
51fn metadata_precedence_and_hiding() {
52 let root = tempfile::tempdir().unwrap();
53 let r = root.path();
54
55 // description file only
56 let (a, a_head) = repo_with(r, "alpha.git", &[("README.md", "# alpha\n")]);
57 std::fs::write(a.join("description"), "From the description file\n").unwrap();
58 // git config beats description file; gitweb.* is honored
59 let (b, _) = repo_with(r, "forks/beta.git", &[("readme.txt", "beta\n")]);
60 std::fs::write(b.join("description"), "ignored\n").unwrap();
61 git(&b, &["config", "gitweb.description", "From gitweb"]);
62 git(&b, &["config", "gitweb.owner", "bob"]);
63 // ferrenium.* beats gitweb.*; TOML beats everything
64 let (c, _) = repo_with(r, "gamma.git", &[("x.rs", "fn main() {}\n")]);
65 git(
66 &c,
67 &["config", "ferrenium.description", "From ferrenium config"],
68 );
69 git(&c, &["config", "gitweb.description", "loses"]);
70 git(&c, &["config", "ferrenium.section", "tools"]);
71 // empty repo with git's placeholder description
72 bare(r, "empty.git", &[]);
73 // hidden three ways
74 repo_with(r, "private/secret.git", &[("a", "a")]);
75 let (h2, _) = repo_with(r, "byconfig.git", &[("a", "a")]);
76 git(&h2, &["config", "ferrenium.hidden", "true"]);
77 repo_with(r, "bytoml.git", &[("a", "a")]);
78
79 let state = state(config(
80 r,
81 "hide = [\"private/*\"]\nsection_from_path = true",
82 r#"
83 [[repo]]
84 name = "gamma"
85 description = "From TOML"
86 [[repo]]
87 name = "bytoml"
88 hidden = true
89 [[repo]]
90 name = "nonexistent"
91 "#,
92 ));
93 let index = state.index.load();
94
95 let names: Vec<_> = index.repos.keys().map(String::as_str).collect();
96 assert_eq!(names, ["alpha", "empty", "forks/beta", "gamma"]);
97 let hidden: Vec<_> = index.hidden.iter().map(String::as_str).collect();
98 assert_eq!(hidden, ["byconfig", "bytoml", "private/secret"]);
99
100 let alpha = &index.repos["alpha"].meta;
101 assert_eq!(
102 alpha.description.as_deref(),
103 Some("From the description file")
104 );
105 assert_eq!(alpha.default_branch.as_deref(), Some("main"));
106 assert_eq!(alpha.head.unwrap().to_string(), a_head);
107 assert_eq!(alpha.branches, 1);
108 assert_eq!(alpha.readme.as_ref().unwrap().path, "README.md");
109 assert!(alpha.last_activity.is_some());
110 assert_eq!(alpha.section, None);
111
112 let beta = &index.repos["forks/beta"].meta;
113 assert_eq!(beta.description.as_deref(), Some("From gitweb"));
114 assert_eq!(beta.owner.as_deref(), Some("bob"));
115 assert_eq!(beta.section.as_deref(), Some("forks")); // section_from_path
116 assert_eq!(beta.readme.as_ref().unwrap().path, "readme.txt");
117
118 let gamma = &index.repos["gamma"].meta;
119 assert_eq!(gamma.description.as_deref(), Some("From TOML"));
120 assert_eq!(gamma.section.as_deref(), Some("tools"));
121 assert_eq!(gamma.readme, None);
122
123 let empty = &index.repos["empty"].meta;
124 assert_eq!(empty.description, None); // placeholder ignored
125 assert_eq!(empty.head, None);
126 assert_eq!(empty.default_branch.as_deref(), Some("main"));
127 assert_eq!(empty.last_activity, None);
128
129 assert!(
130 index.warnings.iter().any(|w| w.contains("\"nonexistent\"")),
131 "{:?}",
132 index.warnings
133 );
134}
135
136#[test]
137fn reftable_repos_are_skipped_with_a_warning() {
138 let root = tempfile::tempdir().unwrap();
139 repo_with(root.path(), "normal.git", &[("a", "a")]);
140 bare(root.path(), "rt.git", &["--ref-format=reftable"]);
141 let state = state(config(root.path(), "", ""));
142 let index = state.index.load();
143 assert_eq!(index.repos.keys().collect::<Vec<_>>(), ["normal"]);
144 assert!(
145 index
146 .warnings
147 .iter()
148 .any(|w| w.starts_with("rt:") && w.contains("reftable")),
149 "{:?}",
150 index.warnings
151 );
152}
153
154#[test]
155fn unchanged_repos_keep_their_generation() {
156 let root = tempfile::tempdir().unwrap();
157 let (a, _) = repo_with(root.path(), "a.git", &[("f", "1")]);
158 repo_with(root.path(), "b.git", &[("f", "1")]);
159 let state = state(config(root.path(), "", ""));
160 let cfg = state.config.load_full();
161 let before = state.index.load();
162
163 let stats = state.index.full_scan(&cfg);
164 assert!(!stats.any_change(), "{stats:?}");
165 let after = state.index.load();
166 assert!(Arc::ptr_eq(&before.repos["a"], &after.repos["a"]));
167
168 let work = Work::clone_of(&a);
169 work.commit("f", "2", "change");
170 work.push("main");
171 assert_eq!(state.index.refresh(&cfg, &a), Refresh::Updated("a".into()));
172 let refreshed = state.index.load();
173 assert!(refreshed.repos["a"].generation > after.repos["a"].generation);
174 assert!(Arc::ptr_eq(&after.repos["b"], &refreshed.repos["b"]));
175 assert_eq!(state.index.refresh(&cfg, &a), Refresh::Unchanged);
176}
177
178#[tokio::test]
179async fn index_page_lists_sections_filter_and_sort() {
180 let root = tempfile::tempdir().unwrap();
181 let r = root.path();
182 let (a, _) = repo_with(r, "zeta.git", &[("f", "1")]);
183 git(
184 &a,
185 &["config", "ferrenium.description", "Last <b>letter</b>"],
186 );
187 git(&a, &["config", "ferrenium.section", "greek"]);
188 repo_with(r, "alpha.git", &[("f", "1")]);
189 repo_with(r, "with space.git", &[("f", "1")]);
190 bare(r, "empty.git", &[]);
191 let state = state(config(r, "", ""));
192
193 let html = page(&state, "/").await;
194 assert!(html.contains("4 Repositories"));
195 assert!(html.contains(r#"href="/alpha""#));
196 assert!(html.contains(r#"href="/with%20space""#));
197 assert!(html.contains("Last &#60;b&#62;letter&#60;/b&#62;") || html.contains("Last &lt;b&gt;"));
198 assert!(html.contains("[ greek ]"));
199 // Unsectioned repos come first, in name order.
200 let pos = |s: &str| html.find(s).unwrap_or_else(|| panic!("{s} missing"));
201 assert!(pos("/alpha\"") < pos("/empty\"") && pos("/empty\"") < pos("[ greek ]"));
202 // Just pushed: fresh dot. Empty repo: no age.
203 assert!(html.contains(r#"class="dot""#));
204 assert!(html.contains(">Empty<"));
205
206 let filtered = page(&state, "/?q=LETTER").await;
207 assert!(filtered.contains(r#"href="/zeta""#));
208 assert!(!filtered.contains(r#"href="/alpha""#));
209 assert!(filtered.contains(r#"value="LETTER""#));
210
211 let none = page(&state, "/?q=nothing-matches").await;
212 assert!(none.contains("[No matches]"));
213 assert!(!none.contains("data-filter-empty hidden"));
214
215 let by_activity = page(&state, "/?sort=activity").await;
216 assert!(!by_activity.contains("[ greek ]"));
217 assert!(by_activity.contains(r#"name="sort" value="activity""#));
218}
219
220#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
221async fn push_updates_the_index_quickly() {
222 let root = tempfile::tempdir().unwrap();
223 let (repo, first) = repo_with(root.path(), "live.git", &[("f", "1")]);
224 let state = state(config(root.path(), "", ""));
225 tokio::spawn(ferrenium::index::watch::run(
226 state.clone(),
227 Default::default(),
228 ));
229 // Let the watcher install its watches.
230 tokio::time::sleep(Duration::from_millis(200)).await;
231
232 let wait_for = |pred: &dyn Fn(&ferrenium::index::RepoIndex) -> bool| {
233 let deadline = Instant::now() + Duration::from_secs(5);
234 loop {
235 if pred(&state.index.load()) {
236 return Instant::now();
237 }
238 assert!(Instant::now() < deadline, "index did not update in time");
239 std::thread::sleep(Duration::from_millis(20));
240 }
241 };
242
243 // A push to an existing repo.
244 let work = Work::clone_of(&repo);
245 let second = work.commit("f", "2", "second");
246 assert_ne!(first, second);
247 let pushed = Instant::now();
248 work.push("main");
249 let seen =
250 wait_for(&|idx| idx.repos["live"].meta.head.map(|h| h.to_string()) == Some(second.clone()));
251 let latency = seen - pushed;
252 assert!(latency < Duration::from_secs(1), "took {latency:?}");
253
254 // A new branch counts too.
255 git(
256 work.path(),
257 &["push", "-q", "origin", "main:refs/heads/feature/x"],
258 );
259 wait_for(&|idx| idx.repos["live"].meta.branches == 2);
260
261 // A new repository appears, a deleted one disappears.
262 repo_with(root.path(), "nested/new.git", &[("f", "1")]);
263 wait_for(&|idx| idx.repos.contains_key("nested/new"));
264 std::fs::remove_dir_all(&repo).unwrap();
265 wait_for(&|idx| !idx.repos.contains_key("live"));
266
267 // Hiding via git config takes effect without a restart.
268 let new_dir = root.path().join("nested/new.git");
269 git(&new_dir, &["config", "ferrenium.hidden", "true"]);
270 wait_for(&|idx| idx.hidden.contains("nested/new"));
271}