Skip to main content

eui_core/host/
tests.rs

1//! VAPP-196 (eApps T1): PACKAGE TESTS, the twin of
2//! `packages/exponential-ui/src/host/tests.ts`. A package carries `tests:
3//! [{name, records?, now?, as?, expr | query | action, expect}]`;
4//! [`validate_package`](super::validate_package) runs every one on an
5//! otherwise valid package, each against its OWN in-memory store seeded with
6//! `records`, on a fixed clock (`now`, UTC) as the member `as`. A failing
7//! test is an install issue at `/tests/<i>` (expected vs got). Locked by
8//! `fixtures/package-tests.json`.
9
10use 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
25/// Caps: tests per package.
26pub const PACKAGE_TEST_LIMIT_TESTS: usize = 50;
27/// The characters (code points) of a `got` an issue prints.
28pub const PACKAGE_TEST_LIMIT_GOT_MAX: usize = 300;
29/// The clock a test runs at when it names none.
30pub const PACKAGE_TEST_DEFAULT_NOW: &str = "2026-01-01T00:00:00.000Z";
31/// The member a test runs as when it names none.
32pub const PACKAGE_TEST_DEFAULT_AS: &str = "member";
33/// eApps R3: the team role a test runs as when it names none, and the roles it may name.
34pub const PACKAGE_TEST_DEFAULT_ROLE: &str = "member";
35pub const PACKAGE_TEST_ROLES: &[&str] = &["member", "owner"];
36/// What a test runs: exactly one of these keys.
37pub const PACKAGE_TEST_KINDS: &[&str] = &["expr", "query", "action"];
38
39/// One test's outcome (`runPackageTests`).
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct PackageTestResult {
42    pub name: String,
43    pub pass: bool,
44    /// What the test produced (`{error}` for a failure).
45    pub got: Value,
46}
47
48/// The seed row keys that are not data.
49const 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
65/// Does `got` satisfy `expect`? An object matches by the keys it names
66/// (recursively; a missing key reads null), a list item by item with the
67/// same length, anything else by JSON equality (`1` equals `1.0`).
68pub 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
76/// The shape issues of a package's `tests` (they run only without any).
77pub 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
189/// A test's store: its `records` (kinds sorted, rows in order), ids
190/// `<kind>-<n>` unless given, authored by `author` (else the test's member),
191/// stamped `now`.
192fn 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
213/// A records source's value over the test's store (`{error}` when it is
214/// not a records source).
215fn 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
226/// Run one (valid) test: what it produced.
227fn 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    // eApps R3: the test reads what its member may read, and writes under the policy.
235    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                // `{source: "records:…"}` = that binding's value; anything else the literal.
244                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
299/// Every test of a package, run (a package whose tests are malformed runs none).
300pub 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
318/// The issue text of a failed test: expected vs got, both canonical JSON
319/// (got cut at [`PACKAGE_TEST_LIMIT_GOT_MAX`] code points + `…`).
320pub 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
329/// The failing tests as install issues (`/tests/<i>`).
330pub 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}