| 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 | |
| 5 | use rusqlite::Connection; |
| 6 | use rusqlite::types::Value; |
| 7 | |
| 8 | use crate::sql::{Param, Sql}; |
| 9 | |
| 10 | /// The migrations, in order: each file's name and SQL. |
| 11 | fn 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 | |
| 22 | fn 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. |
| 27 | fn 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 | |
| 38 | fn 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 | |
| 46 | fn 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 | |
| 54 | fn 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 | |
| 65 | fn run(db: &Connection, text: &str, params: Vec<Param>) { |
| 66 | db.execute(text, rusqlite::params_from_iter(values(¶ms))).unwrap_or_else(|error| panic!("{text}: {error}")); |
| 67 | } |
| 68 | |
| 69 | /// The `number` column of every row `sql` returns, in order. |
| 70 | fn 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)] |
| 81 | mod 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 | fn numbers_count(db: &Connection, sql: &Sql) -> i64 { |
| 243 | db.query_row(&sql.text, rusqlite::params_from_iter(values(&sql.params)), |row| row.get(0)).unwrap() |
| 244 | } |
| 245 | } |