Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

260 linesCodeBlameRaw
1//! The search database as the migrations make it, for tests: SQLite (which
2//! D1 is), each migration in a transaction of its own as D1 applies it.
3//! What each search asks of it is run here too.
4
5use rusqlite::Connection;
6use rusqlite::types::Value;
7
8use crate::sql::{Param, Sql};
9
10/// The migrations, in order: each file's name and SQL.
11fn migrations() -> Vec<(String, String)> {
12 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
13 let mut names: Vec<String> = std::fs::read_dir(&dir)
14 .unwrap()
15 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
16 .filter(|name| name.ends_with(".sql"))
17 .collect();
18 names.sort();
19 names.into_iter().map(|name| (name.clone(), std::fs::read_to_string(dir.join(&name)).unwrap())).collect()
20}
21
22fn apply(db: &Connection, name: &str, sql: &str) {
23 db.execute_batch(&format!("BEGIN;\n{sql}\nCOMMIT;")).unwrap_or_else(|error| panic!("{name}: {error}"));
24}
25
26/// A database with the migrations before `name` applied.
27fn database_before(name: &str) -> Connection {
28 let db = Connection::open_in_memory().unwrap();
29 for (file, sql) in migrations() {
30 if file.as_str() >= name {
31 break;
32 }
33 apply(&db, &file, &sql);
34 }
35 db
36}
37
38fn migrate_from(db: &Connection, name: &str) {
39 for (file, sql) in migrations() {
40 if file.as_str() >= name {
41 apply(db, &file, &sql);
42 }
43 }
44}
45
46fn database() -> Connection {
47 let db = Connection::open_in_memory().unwrap();
48 for (file, sql) in migrations() {
49 apply(&db, &file, &sql);
50 }
51 db
52}
53
54fn values(params: &[Param]) -> Vec<Value> {
55 params
56 .iter()
57 .map(|param| match param {
58 Param::Text(text) => Value::Text(text.clone()),
59 Param::Int(number) => Value::Integer(*number),
60 Param::Null => Value::Null,
61 })
62 .collect()
63}
64
65fn run(db: &Connection, text: &str, params: Vec<Param>) {
66 db.execute(text, rusqlite::params_from_iter(values(&params))).unwrap_or_else(|error| panic!("{text}: {error}"));
67}
68
69/// The `number` column of every row `sql` returns, in order.
70fn numbers(db: &Connection, sql: &Sql) -> Vec<i64> {
71 let mut statement = db.prepare(&sql.text).unwrap_or_else(|error| panic!("{}: {error}", sql.text));
72 let index = statement.column_index("number").unwrap();
73 statement
74 .query_map(rusqlite::params_from_iter(values(&sql.params)), |row| row.get::<_, i64>(index))
75 .unwrap()
76 .map(Result::unwrap)
77 .collect()
78}
79
80#[cfg(test)]
81mod tests {
82 use g1t_contracts::access::{BasePermission, RepoGrant, RepoRole};
83 use g1t_contracts::{Membership, User, Viewer};
84
85 use super::*;
86 use crate::index::UPSERT_ITEM;
87 use crate::query::{ItemKind, Query};
88 use crate::sql::{Page, items_count, items_page};
89 use crate::visibility::Reader;
90
91 const PAGE: Page = Page { limit: 20, offset: 0 };
92
93 fn repo(db: &Connection, id: &str, namespace: &str, name: &str, private: bool) {
94 run(
95 db,
96 "INSERT INTO repos (repo_id, namespace, name, private, created_at, updated_at) VALUES (?, ?, ?, ?, '', '')",
97 vec![Param::from(id), Param::from(namespace), Param::from(name), Param::Int(i64::from(private))],
98 );
99 }
100
101 /// A work item of acme numbered `number`, filed in `origin`, linked to `repos`.
102 fn item(db: &Connection, number: i64, origin: Option<&str>, repos: &str, title: &str) {
103 run(
104 db,
105 UPSERT_ITEM,
106 vec![
107 Param::from("item"),
108 Param::Text(format!("wki_{number}")),
109 Param::opt(origin),
110 Param::from("acme"),
111 Param::Int(number),
112 Param::from(repos),
113 Param::from(title),
114 Param::from("the body"),
115 Param::from("open"),
116 Param::from("open"),
117 Param::from("ana"),
118 Param::Null,
119 Param::from("|bug|"),
120 Param::from("2026-10-01T00:00:00Z"),
121 Param::Text(format!("2026-10-01T00:00:{number:02}Z")),
122 ],
123 );
124 }
125
126 fn member(base: BasePermission) -> Viewer {
127 Some(User {
128 id: "usr_1".into(),
129 username: "ana".into(),
130 workspaces: vec![Membership { base_permission: Some(base), ..Membership::member("acme") }],
131 ..User::default()
132 })
133 }
134
135 fn outsider(grants: &[&str]) -> Viewer {
136 Some(User {
137 id: "usr_2".into(),
138 username: "bo".into(),
139 grants: grants
140 .iter()
141 .map(|id| RepoGrant { repo_id: (*id).into(), workspace: "acme".into(), role: RepoRole::Read, team: None })
142 .collect(),
143 ..User::default()
144 })
145 }
146
147 /// acme/web is public, acme/api private; item 1 was filed in web, 2 in
148 /// api (and targets web), 3 in no repository (and targets web).
149 fn filled() -> Connection {
150 let db = database();
151 repo(&db, "rep_web", "acme", "web", false);
152 repo(&db, "rep_api", "acme", "api", true);
153 item(&db, 1, Some("rep_web"), "|rep_web|", "crash on login");
154 item(&db, 2, Some("rep_api"), "|rep_api|rep_web|", "crash in the api");
155 item(&db, 3, None, "|rep_web|", "crash everywhere");
156 db
157 }
158
159 fn found(db: &Connection, text: &str, viewer: &Viewer) -> Vec<i64> {
160 let query = Query::parse(text);
161 let reader = Reader::of(viewer);
162 let mut page = numbers(db, &items_page(&query, &reader, ItemKind::Item, PAGE));
163 page.sort();
164 page
165 }
166
167 #[test]
168 fn issues_are_dropped_and_pull_requests_kept() {
169 let db = database_before("0003");
170 repo(&db, "rep_web", "acme", "web", false);
171 for (kind, number, title) in [("issue", 1, "crash on login"), ("pull", 2, "fix the crash")] {
172 run(
173 &db,
174 "INSERT INTO items (repo_id, kind, number, title, body, state, status, author, labels, created_at, updated_at, requested_by)
175 VALUES ('rep_web', ?, ?, ?, 'body', 'open', 'open', 'ana', '|bug|', '', '', 'bo')",
176 vec![Param::from(kind), Param::Int(number), Param::from(title)],
177 );
178 }
179 migrate_from(&db, "0003");
180 let rows: Vec<(String, String, Option<String>, String, String)> = db
181 .prepare("SELECT kind, key, workspace, repos, requested_by FROM items")
182 .unwrap()
183 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)))
184 .unwrap()
185 .map(Result::unwrap)
186 .collect();
187 assert_eq!(rows, vec![("pull".into(), "rep_web/2".into(), None, "|".into(), "bo".into())]);
188 // The full-text index holds the pull request, and nothing of the issue.
189 let pulls = numbers(&db, &items_page(&Query::parse("crash"), &Reader::default(), ItemKind::Pull, PAGE));
190 assert_eq!(pulls, vec![2]);
191 let matched: i64 = db.query_row("SELECT count(*) FROM items_fts WHERE items_fts MATCH 'login'", [], |row| row.get(0)).unwrap();
192 assert_eq!(matched, 0);
193 }
194
195 #[test]
196 fn a_work_item_indexed_again_is_updated_in_place() {
197 let db = filled();
198 item(&db, 1, Some("rep_web"), "|rep_web|rep_api|", "a new title");
199 let rows: i64 = db.query_row("SELECT count(*) FROM items WHERE key = 'wki_1'", [], |row| row.get(0)).unwrap();
200 assert_eq!(rows, 1);
201 assert_eq!(found(&db, "title", &None), vec![1]);
202 assert!(found(&db, "login", &None).is_empty());
203 }
204
205 #[test]
206 fn members_see_every_work_item() {
207 let db = filled();
208 // Even a member whose base permission reads no repository.
209 assert_eq!(found(&db, "crash", &member(BasePermission::None)), vec![1, 2, 3]);
210 assert_eq!(found(&db, "crash", &member(BasePermission::Read)), vec![1, 2, 3]);
211 }
212
213 #[test]
214 fn others_see_what_was_filed_where_they_read() {
215 let db = filled();
216 // Signed out, or in no way part of acme: what was filed in its public repository.
217 assert_eq!(found(&db, "crash", &None), vec![1]);
218 assert_eq!(found(&db, "crash", &outsider(&[])), vec![1]);
219 // Given a role on the private one: what was filed there too, never
220 // what was filed in no repository.
221 assert_eq!(found(&db, "crash", &outsider(&["rep_api"])), vec![1, 2]);
222 let count = items_count(&Query::parse("crash"), &Reader::of(&None), ItemKind::Item);
223 assert_eq!(numbers_count(&db, &count), 1);
224 }
225
226 #[test]
227 fn repo_matches_any_linked_repository_and_org_the_workspace() {
228 let db = filled();
229 let viewer = member(BasePermission::Read);
230 assert_eq!(found(&db, "crash repo:acme/web", &viewer), vec![1, 2, 3]);
231 assert_eq!(found(&db, "crash repo:acme/api", &viewer), vec![2]);
232 assert_eq!(found(&db, "crash -repo:acme/api", &viewer), vec![1, 3]);
233 assert_eq!(found(&db, "crash org:acme", &viewer), vec![1, 2, 3]);
234 assert_eq!(found(&db, "workspace:other", &viewer), Vec::<i64>::new());
235 // Public: filed in a public repository. Private: anything else.
236 assert_eq!(found(&db, "crash is:public", &viewer), vec![1]);
237 assert_eq!(found(&db, "crash is:private", &viewer), vec![2, 3]);
238 // Without words, by qualifiers alone.
239 assert_eq!(found(&db, "is:open label:bug", &viewer), vec![1, 2, 3]);
240 }
241
242 #[test]
243 fn repo_names_a_private_repository_only_to_who_may_read_it() {
244 let db = filled();
245 // A member who reads none of acme's private repositories sees every
246 // work item, but repo: answers of acme/api as of one that is not there.
247 let viewer = member(BasePermission::None);
248 assert_eq!(found(&db, "crash repo:acme/api", &viewer), found(&db, "crash repo:acme/nope", &viewer));
249 assert_eq!(found(&db, "crash -repo:acme/api", &viewer), found(&db, "crash -repo:acme/nope", &viewer));
250 assert_eq!(found(&db, "crash -repo:acme/api", &viewer), vec![1, 2, 3]);
251 // Given a role on it, it is there.
252 let granted = Some(User { workspaces: vec![Membership::member("acme")], ..outsider(&["rep_api"]).unwrap() });
253 assert_eq!(found(&db, "crash repo:acme/api", &granted), vec![2]);
254 assert_eq!(found(&db, "crash -repo:acme/api", &granted), vec![1, 3]);
255 }
256
257 fn numbers_count(db: &Connection, sql: &Sql) -> i64 {
258 db.query_row(&sql.text, rusqlite::params_from_iter(values(&sql.params)), |row| row.get(0)).unwrap()
259 }
260}