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