1use std::collections::HashMap;
11
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14
15use super::actions::{apply_record_batch_as, run_action, ActionContext};
16use super::record_policy::{readable_rows, RecordViewer};
17use super::package::PackageIssue;
18use super::query::{expand_query_source, parse_records_source, records_value_owned, RecordRow, RecordsContext, RECORDS_SCHEME};
19use super::records::validate_record;
20use super::signature::canonical_json;
21use super::sources::{parse_source, source_with_params};
22use super::{js_trim, utf16_cmp};
23use crate::cel::{evaluate_expression, parse_timestamp, ExpressionEnv};
24
25pub const PACKAGE_TEST_LIMIT_TESTS: usize = 50;
27pub const PACKAGE_TEST_LIMIT_GOT_MAX: usize = 300;
29pub const PACKAGE_TEST_DEFAULT_NOW: &str = "2026-01-01T00:00:00.000Z";
31pub const PACKAGE_TEST_DEFAULT_AS: &str = "member";
33pub const PACKAGE_TEST_DEFAULT_ROLE: &str = "member";
35pub const PACKAGE_TEST_ROLES: &[&str] = &["member", "owner"];
36pub const PACKAGE_TEST_KINDS: &[&str] = &["expr", "query", "action"];
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct PackageTestResult {
42 pub name: String,
43 pub pass: bool,
44 pub got: Value,
46}
47
48const SEED_KEYS: [&str; 2] = ["id", "author"];
50
51fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
52 PackageIssue { path: path.into(), message: message.into() }
53}
54
55fn escape(key: &str) -> String {
56 key.replace('~', "~0").replace('/', "~1")
57}
58
59fn sorted_keys(m: &Map<String, Value>) -> Vec<&String> {
60 let mut keys: Vec<&String> = m.keys().collect();
61 keys.sort_by(|a, b| utf16_cmp(a, b));
62 keys
63}
64
65pub fn test_matches(expect: &Value, got: &Value) -> bool {
69 match expect {
70 Value::Object(e) => got.as_object().is_some_and(|g| e.iter().all(|(k, v)| test_matches(v, g.get(k).unwrap_or(&Value::Null)))),
71 Value::Array(e) => got.as_array().is_some_and(|g| g.len() == e.len() && e.iter().zip(g).all(|(e, g)| test_matches(e, g))),
72 _ => crate::json::strict_eq(expect, got),
73 }
74}
75
76pub fn validate_package_tests(pkg: &Map<String, Value>) -> Vec<PackageIssue> {
78 let Some(tests) = pkg.get("tests") else { return Vec::new() };
79 let Some(tests) = tests.as_array() else { return vec![issue("/tests", "tests is a list of {name, expr | query | action, expect}")] };
80 let mut issues = Vec::new();
81 if tests.len() > PACKAGE_TEST_LIMIT_TESTS {
82 issues.push(issue("/tests", format!("at most {PACKAGE_TEST_LIMIT_TESTS} tests")));
83 }
84 let empty = Map::new();
85 let records = pkg.get("records").and_then(Value::as_object).unwrap_or(&empty);
86 let queries = pkg.get("queries").and_then(Value::as_object).unwrap_or(&empty);
87 let actions = pkg.get("actions").and_then(Value::as_object).unwrap_or(&empty);
88 for (i, t) in tests.iter().enumerate() {
89 let at = format!("/tests/{i}");
90 let Some(t) = t.as_object() else {
91 issues.push(issue(at, "a test is {name, expr | query | action, expect}"));
92 continue;
93 };
94 if !t.get("name").and_then(Value::as_str).is_some_and(|n| !js_trim(n).is_empty()) {
95 issues.push(issue(format!("{at}/name"), "name is a non-empty string"));
96 }
97 let kinds: Vec<&str> = PACKAGE_TEST_KINDS.iter().copied().filter(|k| t.contains_key(*k)).collect();
98 if kinds.len() != 1 {
99 issues.push(issue(&at, format!("a test runs one of {}", PACKAGE_TEST_KINDS.join(", "))));
100 }
101 if !t.contains_key("expect") {
102 issues.push(issue(format!("{at}/expect"), "expect is required"));
103 }
104 if let Some(now) = t.get("now") {
105 if now.as_str().and_then(parse_timestamp).is_none() {
106 issues.push(issue(format!("{at}/now"), "now is an ISO timestamp"));
107 }
108 }
109 if let Some(r#as) = t.get("as") {
110 if !r#as.as_str().is_some_and(|s| !s.is_empty()) {
111 issues.push(issue(format!("{at}/as"), "as is a member id"));
112 }
113 }
114 if let Some(role) = t.get("role") {
115 if !role.as_str().is_some_and(|r| PACKAGE_TEST_ROLES.contains(&r)) {
116 issues.push(issue(format!("{at}/role"), "role is member or owner"));
117 }
118 }
119 if let Some(seed) = t.get("records") {
120 match seed.as_object() {
121 None => issues.push(issue(format!("{at}/records"), "records is {<kind>: [rows]}")),
122 Some(seed) => {
123 for kind in sorted_keys(seed) {
124 let kat = format!("{at}/records/{}", escape(kind));
125 let Some(schema) = records.get(kind).and_then(Value::as_object).and_then(|d| d.get("schema")).filter(|s| s.is_object()) else {
126 issues.push(issue(kat, format!("records:{kind} names no declared kind")));
127 continue;
128 };
129 let Some(rows) = seed[kind.as_str()].as_array() else {
130 issues.push(issue(kat, "a kind's sample is a list of rows"));
131 continue;
132 };
133 for (j, row) in rows.iter().enumerate() {
134 let Some(row) = row.as_object() else {
135 issues.push(issue(format!("{kat}/{j}"), "a row is an object"));
136 continue;
137 };
138 for key in SEED_KEYS {
139 if let Some(v) = row.get(key) {
140 if !v.as_str().is_some_and(|s| !s.is_empty()) {
141 issues.push(issue(format!("{kat}/{j}/{key}"), format!("{key} is a non-empty string")));
142 }
143 }
144 }
145 for i in validate_record(schema, &seed_data(row)) {
146 issues.push(issue(format!("{kat}/{j}{}", i.path), i.message));
147 }
148 }
149 }
150 }
151 }
152 }
153 let [kind] = kinds.as_slice() else { continue };
154 let body = &t[*kind];
155 let kat = format!("{at}/{kind}");
156 match *kind {
157 "expr" => match body.as_object() {
158 Some(b) if b.get("expr").is_some_and(Value::is_string) => {
159 if b.get("vars").is_some_and(|v| !v.is_object()) {
160 issues.push(issue(format!("{kat}/vars"), "vars is an object"));
161 }
162 }
163 _ => issues.push(issue(format!("{kat}/expr"), "expr is a string")),
164 },
165 "query" => {
166 let name = body.get("name").and_then(Value::as_str).unwrap_or("");
167 if !queries.contains_key(name) {
168 issues.push(issue(format!("{kat}/name"), format!("query {name} names no package query")));
169 }
170 if body.as_object().and_then(|b| b.get("params")).is_some_and(|p| !p.is_object()) {
171 issues.push(issue(format!("{kat}/params"), "params is an object"));
172 }
173 }
174 _ => {
175 let name = body.get("name").and_then(Value::as_str).unwrap_or("");
176 if !actions.contains_key(name) {
177 issues.push(issue(format!("{kat}/name"), format!("action {name} names no package action")));
178 }
179 }
180 }
181 }
182 issues
183}
184
185fn seed_data(row: &Map<String, Value>) -> Value {
186 Value::Object(row.iter().filter(|(k, _)| !SEED_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect())
187}
188
189fn seed_rows(records: Option<&Value>, me: &str, now: &str) -> Vec<RecordRow> {
193 let mut rows = Vec::new();
194 let Some(records) = records.and_then(Value::as_object) else { return rows };
195 for kind in sorted_keys(records) {
196 let Some(list) = records[kind.as_str()].as_array() else { continue };
197 for (j, row) in list.iter().enumerate() {
198 let Some(row) = row.as_object() else { continue };
199 let author = row.get("author").and_then(Value::as_str).unwrap_or(me).to_string();
200 let id = row.get("id").and_then(Value::as_str).map_or_else(|| format!("{kind}-{}", j + 1), str::to_string);
201 rows.push(RecordRow { id, kind: kind.clone(), data: seed_data(row), created_by: Some(author.clone()), author_id: Some(author), created_at: now.into(), updated_at: now.into() });
202 }
203 }
204 rows
205}
206
207fn error(message: String) -> Value {
208 let mut m = Map::new();
209 m.insert("error".into(), Value::String(message));
210 Value::Object(m)
211}
212
213fn records_source_value(source: &str, pkg: &Map<String, Value>, rows: &[RecordRow], me: &str, now: &str) -> Value {
216 let expanded = expand_query_source(source, pkg.get("queries").filter(|q| q.is_object()));
217 let query = parse_source(&expanded).filter(|p| p.scheme == RECORDS_SCHEME).and_then(|p| parse_records_source(&p));
218 let Some(query) = query else { return error(format!("{source} is not a records source")) };
219 let names = HashMap::new();
220 let computed = if query.app.is_none() { super::query::kind_computed(pkg.get("records"), query.from.as_deref()) } else { None };
221 let refs = if query.app.is_none() { super::query::kind_refs(pkg.get("records"), query.from.as_deref()) } else { None };
222 let transitions = if query.app.is_none() { super::record_transitions::kind_transitions(query.from.as_deref().and_then(|k| pkg.get("records").and_then(|r| r.get(k)))) } else { None };
223 records_value_owned(rows, &query, RecordsContext { me: Some(me), now, today: None, names: &names, computed: computed.as_ref(), refs: refs.as_ref(), issues: None, transitions: transitions.as_ref(), role: None }).unwrap_or(Value::Null)
224}
225
226fn run_test(pkg: &Map<String, Value>, t: &Map<String, Value>) -> Value {
228 let now = t.get("now").and_then(Value::as_str).unwrap_or(PACKAGE_TEST_DEFAULT_NOW);
229 let now_ms = parse_timestamp(now);
230 let me = t.get("as").and_then(Value::as_str).unwrap_or(PACKAGE_TEST_DEFAULT_AS);
231 let role = t.get("role").and_then(Value::as_str).unwrap_or(PACKAGE_TEST_DEFAULT_ROLE);
232 let kinds = pkg.get("records").filter(|r| r.is_object());
233 let viewer = RecordViewer { id: Some(me), role: Some(role), access: None };
234 let stored = seed_rows(t.get("records"), me, now);
236 let stored_values: Vec<Value> = stored.iter().map(|r| serde_json::to_value(r).unwrap_or(Value::Null)).collect();
237 let visible: Vec<bool> = stored_values.iter().map(|v| readable_rows(std::slice::from_ref(v), kinds, &viewer).len() == 1).collect();
238 let rows: Vec<RecordRow> = stored.iter().zip(&visible).filter(|(_, v)| **v).map(|(r, _)| r.clone()).collect();
239 if let Some(expr) = t.get("expr").and_then(Value::as_object) {
240 let mut vars = Map::new();
241 if let Some(given) = expr.get("vars").and_then(Value::as_object) {
242 for (k, v) in given {
243 let value = match v.as_object() {
245 Some(o) if o.len() == 1 && o.get("source").is_some_and(Value::is_string) => records_source_value(o["source"].as_str().unwrap_or(""), pkg, &rows, me, now),
246 _ => v.clone(),
247 };
248 vars.insert(k.clone(), value);
249 }
250 }
251 let env = ExpressionEnv { now: now_ms, offset: None, max_steps: None };
252 return evaluate_expression(expr.get("expr").and_then(Value::as_str).unwrap_or(""), &vars, &env).unwrap_or_else(error);
253 }
254 if let Some(query) = t.get("query").and_then(Value::as_object) {
255 let name = query.get("name").and_then(Value::as_str).unwrap_or("");
256 let from = pkg.get("queries").and_then(|q| q.get(name)).and_then(|q| q.get("from")).and_then(Value::as_str).unwrap_or("");
257 let mut params = Map::new();
258 params.insert("query".into(), Value::String(name.into()));
259 if let Some(given) = query.get("params").and_then(Value::as_object) {
260 for (k, v) in given {
261 params.insert(k.clone(), Value::String(v.as_str().map_or_else(|| canonical_json(v), str::to_string)));
262 }
263 }
264 let source = source_with_params(&format!("{RECORDS_SCHEME}:{from}"), Some(&Value::Object(params)));
265 return records_source_value(&source, pkg, &rows, me, now);
266 }
267 let a = t.get("action").and_then(Value::as_object);
268 let field = |k: &str| a.and_then(|a| a.get(k));
269 let name = field("name").and_then(Value::as_str).unwrap_or("");
270 let ctx = ActionContext { data: field("data"), me: Some(me), now: now_ms, offset: None, max_steps: None };
271 let pkg_value = Value::Object(pkg.clone());
272 let plan = match run_action(&pkg_value, name, field("params").unwrap_or(&Value::Null), &ctx) {
273 Ok(plan) => plan,
274 Err(e) => return error(e),
275 };
276 let writes = plan.get("writes").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
277 let (after, _) = match apply_record_batch_as(&stored_values, writes, kinds, Some(me), Some(role), None, now, |i| format!("new-{}", i + 1)) {
278 Ok(out) => out,
279 Err(e) => return error(e),
280 };
281 let mut records: Map<String, Value> = Map::new();
282 for row in readable_rows(&after, kinds, &viewer) {
283 let mut out = row.get("data").and_then(Value::as_object).cloned().unwrap_or_default();
284 out.insert("id".into(), row.get("id").cloned().unwrap_or(Value::Null));
285 let author = row.get("authorId").filter(|v| !v.is_null()).or_else(|| row.get("createdBy")).cloned().unwrap_or(Value::Null);
286 out.insert("author".into(), author);
287 let kind = row.get("kind").and_then(Value::as_str).unwrap_or("").to_string();
288 if let Value::Array(list) = records.entry(kind).or_insert_with(|| Value::Array(Vec::new())) {
289 list.push(Value::Object(out));
290 }
291 }
292 let mut got = Map::new();
293 got.insert("records".into(), Value::Object(records));
294 got.insert("sets".into(), plan.get("sets").cloned().unwrap_or(Value::Array(Vec::new())));
295 got.insert("calls".into(), plan.get("calls").cloned().unwrap_or(Value::Array(Vec::new())));
296 Value::Object(got)
297}
298
299pub fn run_package_tests(pkg: &Value) -> Vec<PackageTestResult> {
301 let Some(p) = pkg.as_object() else { return Vec::new() };
302 let Some(tests) = p.get("tests").and_then(Value::as_array) else { return Vec::new() };
303 if !validate_package_tests(p).is_empty() {
304 return Vec::new();
305 }
306 let empty = Map::new();
307 tests
308 .iter()
309 .map(|t| {
310 let t = t.as_object().unwrap_or(&empty);
311 let got = run_test(p, t);
312 let pass = test_matches(t.get("expect").unwrap_or(&Value::Null), &got);
313 PackageTestResult { name: t.get("name").and_then(Value::as_str).unwrap_or("").to_string(), pass, got }
314 })
315 .collect()
316}
317
318pub fn test_failure(name: &str, expect: &Value, got: &Value) -> String {
321 let g = canonical_json(got);
322 let text = match g.char_indices().nth(PACKAGE_TEST_LIMIT_GOT_MAX) {
323 Some((cut, _)) => format!("{}…", &g[..cut]),
324 None => g,
325 };
326 format!("{name}: expected {}, got {text}", canonical_json(expect))
327}
328
329pub fn package_test_issues(pkg: &Value) -> Vec<PackageIssue> {
331 let tests = pkg.get("tests").and_then(Value::as_array);
332 run_package_tests(pkg)
333 .into_iter()
334 .enumerate()
335 .filter(|(_, r)| !r.pass)
336 .map(|(i, r)| issue(format!("/tests/{i}"), test_failure(&r.name, tests.and_then(|t| t[i].get("expect")).unwrap_or(&Value::Null), &r.got)))
337 .collect()
338}