Skip to main content

sofie_testing/
checks.rs

1use std::fmt::Debug;
2
3use serde::Serialize;
4use serde::de::DeserializeOwned;
5use serde_json::Value as Json;
6use sofie_core::{
7    Cast, Constraint, Error, FieldError, FieldRules, RowReader, SqlType, SqlValue, Variants,
8};
9use sofie_db::{Aggregate, Create, Id, Input, Load};
10use sofie_values::{Fixture, InputFixture, Samples, Seed};
11
12use crate::TestApp;
13use crate::fixture::{FixtureOwner, fixture_input, fixture_value};
14
15const ROUND_TRIPS: usize = 3;
16
17/// A bound counts characters of text and the value of a number.
18#[must_use]
19pub fn describe(constraint: Constraint) -> String {
20    match constraint {
21        Constraint::Required => "required".to_owned(),
22        Constraint::Unique => "unique".to_owned(),
23        Constraint::MinLength(bound) | Constraint::Min(bound) => format!("min {bound}"),
24        Constraint::MaxLength(bound) | Constraint::Max(bound) => format!("max {bound}"),
25    }
26}
27
28pub fn assert_moves<E: Variants>(expected: &[(&str, &[&str])]) {
29    assert_eq!(
30        moves::<E>(),
31        expected
32            .iter()
33            .map(|(from, to)| (
34                (*from).to_owned(),
35                to.iter().map(|to| (*to).to_owned()).collect()
36            ))
37            .collect::<Vec<(String, Vec<String>)>>(),
38        "{}'s transitions changed",
39        E::TYPE
40    );
41    for (_, from) in E::VARIANTS {
42        for (_, to) in E::VARIANTS {
43            let listed = E::TRANSITIONS
44                .iter()
45                .any(|(source, target)| source.name() == from.name() && target.name() == to.name());
46            let refusal = from.to(*to).err();
47            assert_eq!(
48                refusal.is_none(),
49                listed,
50                "{} from {} to {}: {refusal:?}",
51                E::TYPE,
52                from.name(),
53                to.name()
54            );
55        }
56    }
57}
58
59fn moves<E: Variants>() -> Vec<(String, Vec<String>)> {
60    E::VARIANTS
61        .iter()
62        .map(|(name, from)| {
63            let targets: Vec<String> = E::TRANSITIONS
64                .iter()
65                .filter(|(source, _)| source.name() == from.name())
66                .map(|(_, target)| target.name().to_owned())
67                .collect();
68            ((*name).to_owned(), targets)
69        })
70        .filter(|(_, targets)| !targets.is_empty())
71        .collect()
72}
73
74pub fn assert_boundary<T: Fixture + Serialize + DeserializeOwned>(
75    field: &str,
76    rules: FieldRules<T>,
77    constraint: &str,
78) {
79    let declared: Vec<String> = rules.constraints.iter().copied().map(describe).collect();
80    let Some(kept) = rules
81        .constraints
82        .iter()
83        .copied()
84        .find(|kept| describe(*kept) == constraint)
85    else {
86        panic!("`{field}` no longer keeps `{constraint}`; it keeps {declared:?}");
87    };
88    let (key, accepted, refused) = boundary(kept, &fixture_json::<T>());
89    let refusal = |json: &Json| {
90        let value: T = serde_json::from_value(json.clone())
91            .unwrap_or_else(|error| panic!("`{field}` cannot hold {json} to test it: {error}"));
92        rules
93            .refusal(field, &value)
94            .map(|error: FieldError| error.key)
95    };
96    if let Some(accepted) = accepted {
97        assert_ne!(
98            refusal(&accepted),
99            Some(key.into()),
100            "`{field}` refuses {accepted} at its bound"
101        );
102    }
103    assert_eq!(
104        refusal(&refused),
105        Some(key.into()),
106        "`{field}` accepts {refused} past its bound"
107    );
108}
109
110fn fixture_json<T: Fixture + Serialize>() -> Json {
111    serde_json::to_value(T::fixture(&mut Seed::new(0))).expect("a fixture serializes to JSON")
112}
113
114fn boundary(constraint: Constraint, fixture: &Json) -> (&'static str, Option<Json>, Json) {
115    let text = |length: i64| -> Json {
116        let Json::String(sample) = fixture else {
117            panic!("a length is a bound of text, and the fixture is {fixture}");
118        };
119        let length = usize::try_from(length).unwrap_or_default();
120        Json::String(
121            sample
122                .chars()
123                .chain(std::iter::repeat('x'))
124                .take(length)
125                .collect(),
126        )
127    };
128    match constraint {
129        Constraint::Required => ("validation.required", Some(fixture.clone()), Json::from("")),
130        Constraint::MinLength(bound) => {
131            ("validation.min_length", Some(text(bound)), text(bound - 1))
132        }
133        Constraint::MaxLength(bound) => {
134            ("validation.max_length", Some(text(bound)), text(bound + 1))
135        }
136        Constraint::Min(bound) => (
137            "validation.min",
138            Some(Json::from(bound)),
139            Json::from(bound - 1),
140        ),
141        Constraint::Max(bound) => (
142            "validation.max",
143            Some(Json::from(bound)),
144            Json::from(bound + 1),
145        ),
146        Constraint::Unique => panic!("only the database holds `unique`: use `assert_unique`"),
147    }
148}
149
150pub async fn assert_value<V>(app: &TestApp)
151where
152    V: Fixture + Samples + Cast + Send + Serialize + DeserializeOwned + PartialEq + Debug,
153{
154    for accepted in V::ACCEPTED {
155        let parsed = serde_json::from_str::<V>(accepted);
156        assert!(parsed.is_ok(), "{accepted} is refused: {parsed:?}");
157    }
158    for refused in V::REFUSED {
159        let parsed = serde_json::from_str::<V>(refused);
160        assert!(parsed.is_err(), "{refused} is accepted: {parsed:?}");
161    }
162    for _ in 0..ROUND_TRIPS {
163        let value: V = fixture_value(app);
164        let json = serde_json::to_value(&value).expect("a value serializes to JSON");
165        let read: V = serde_json::from_value(json.clone())
166            .unwrap_or_else(|error| panic!("{json} does not read back: {error}"));
167        assert_eq!(read, value, "through JSON as {json}");
168        assert_eq!(
169            through_database(app, &value).await,
170            value,
171            "through the database"
172        );
173    }
174}
175
176async fn through_database<V: Cast + Send>(app: &TestApp, value: &V) -> V {
177    let mut params = Vec::new();
178    value.encode(&mut params);
179    let columns: Vec<String> = V::COLUMNS
180        .iter()
181        .enumerate()
182        .map(|(index, column)| format!("${}::{}", index + 1, sql_type(column.ty)))
183        .collect();
184    let sql = format!("SELECT {}", columns.join(", "));
185    let types = sofie_db::column_types(&[V::COLUMNS]);
186    let mut decoded = sofie_db::query_decoded(&app.postgres, &sql, &params, types, V::decode)
187        .await
188        .unwrap_or_else(|error| panic!("decoding what `{sql}` gave back: {error:?}"));
189    decoded.remove(0)
190}
191
192pub async fn assert_schema<A: Aggregate>(app: &TestApp) {
193    let mismatches = app
194        .db()
195        .schema()
196        .mismatches::<A>()
197        .await
198        .unwrap_or_else(|error| panic!("reading the table of {}: {error:?}", A::NAME));
199    let described: Vec<String> = mismatches.iter().map(ToString::to_string).collect();
200    assert!(
201        described.is_empty(),
202        "the table of {} disagrees with its schema:\n{}",
203        A::NAME,
204        described.join("\n")
205    );
206}
207
208/// `second`, in the same state as `first`, gives its fields to `first` and is deleted, freeing its unique values.
209pub async fn assert_store<A: Aggregate>(app: &TestApp, first: Id<A>, second: Id<A>) {
210    let read = app.db().read();
211    let loaded = A::find(&read, first)
212        .await
213        .expect("loading the first record");
214    let other = A::find(&read, second)
215        .await
216        .expect("loading the second record");
217    let table = format!("\"{}\".\"{}\"", A::MODULE, A::NAME);
218    app.query(
219        &format!("DELETE FROM {table} WHERE id = $1"),
220        &[SqlValue::Uuid(second.uuid())],
221    )
222    .await;
223    let mut changed = with_fields_of(&loaded, &other);
224    app.db()
225        .commit(&mut changed)
226        .await
227        .unwrap_or_else(|error| panic!("saving a {}: {error:?}", A::NAME));
228
229    let reloaded = A::find(&read, first)
230        .await
231        .expect("loading the saved record");
232    assert_eq!(
233        fields(&reloaded),
234        fields(&other),
235        "{} loads as saved",
236        A::NAME
237    );
238    assert_eq!(reloaded.meta().version(), loaded.meta().version() + 1);
239    let mut stale = with_fields_of(&loaded, &other);
240    let conflict = app.db().commit(&mut stale).await;
241    assert!(
242        matches!(conflict, Err(Error::Conflict)),
243        "a {} saved from a stale version: {conflict:?}",
244        A::NAME
245    );
246}
247
248pub async fn assert_unique<A>(app: &TestApp, field: &str)
249where
250    A: Aggregate + Create<Owner: FixtureOwner + Send, Input: InputFixture>,
251{
252    let value = fixture_input::<A::Input>(app)[field].clone();
253    let _first: A = app.fixture::<A>().with(field, &value).await;
254    let mut input = fixture_input::<A::Input>(app);
255    input[field] = value;
256    let input = A::Input::from_json(input).expect("a fixture input reads");
257    let mut second = A::create(input, A::Owner::fixture_owner(app)).expect("the factory builds it");
258    match app.db().commit(&mut second).await {
259        Err(Error::Invalid(errors))
260            if errors
261                .iter()
262                .any(|error| error.field == field && error.key == "validation.unique") => {}
263        other => panic!("a second {} with the same `{field}`: {other:?}", A::NAME),
264    }
265}
266
267#[cfg(feature = "contracts")]
268pub async fn assert_lookup<A, D>(app: &TestApp, ids: &[Id<A>], exposed: &[&str])
269where
270    A: Aggregate + Serialize,
271    D: DeserializeOwned + Serialize + Send,
272{
273    use sofie_db::{Exposed, Lookup};
274
275    let uuids: Vec<_> = ids.iter().map(|id| id.uuid()).collect();
276    let found = Exposed::<A, D>::new(app.db().clone())
277        .get_many(&uuids)
278        .await
279        .expect("looking the records up");
280    assert_eq!(found.len(), ids.len(), "every {} is found", A::NAME);
281    let mut expected: Vec<&str> = exposed.to_vec();
282    expected.sort_unstable();
283    for dto in found {
284        let Json::Object(fields) = serde_json::to_value(dto).expect("a DTO serializes") else {
285            panic!("a DTO of {} is an object", A::NAME);
286        };
287        let names: Vec<&str> = fields.keys().map(String::as_str).collect();
288        assert_eq!(names, expected, "the fields a {} lookup answers", A::NAME);
289    }
290}
291
292fn fields<A: Aggregate>(record: &A) -> Vec<SqlValue> {
293    let mut fields = Vec::new();
294    record.encode(&mut fields);
295    fields
296}
297
298/// The children come back empty and unchanged, so saving the rebuilt record leaves the
299/// stored child rows alone.
300fn with_fields_of<A: Aggregate>(record: &A, other: &A) -> A {
301    let mut row = fields(other);
302    row.extend((0..sofie_db::child_tables::<A>()).map(|_| SqlValue::Json(Json::Array(Vec::new()))));
303    A::decode(record.meta().clone(), &mut RowReader::new(&row))
304        .unwrap_or_else(|error| panic!("rebuilding a {}: {error:?}", A::NAME))
305}
306
307fn sql_type(ty: SqlType) -> &'static str {
308    match ty {
309        SqlType::Bool => "boolean",
310        SqlType::Int => "bigint",
311        SqlType::Float => "double precision",
312        SqlType::Text => "text",
313        SqlType::Bytes => "bytea",
314        SqlType::Uuid => "uuid",
315        SqlType::Timestamp => "timestamptz",
316        SqlType::Date => "date",
317        SqlType::DateTime => "timestamp",
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn a_constraint_reads_as_its_fact() {
327        assert_eq!(describe(Constraint::MaxLength(120)), "max 120");
328        assert_eq!(describe(Constraint::Min(0)), "min 0");
329        assert_eq!(describe(Constraint::Required), "required");
330    }
331
332    #[test]
333    fn text_is_held_to_its_length_and_a_number_to_its_range() {
334        const NAME: FieldRules<String> =
335            FieldRules::new(&[Constraint::Required, Constraint::MaxLength(5)]);
336        const COUNT: FieldRules<i64> = FieldRules::new(&[Constraint::Min(1), Constraint::Max(9)]);
337
338        assert_boundary("name", NAME, "required");
339        assert_boundary("name", NAME, "max 5");
340        assert_boundary("count", COUNT, "min 1");
341        assert_boundary("count", COUNT, "max 9");
342    }
343
344    #[test]
345    #[should_panic(expected = "no longer keeps `max 6`")]
346    fn a_changed_bound_fails_the_test_of_the_old_one() {
347        const NAME: FieldRules<String> = FieldRules::new(&[Constraint::MaxLength(5)]);
348
349        assert_boundary("name", NAME, "max 6");
350    }
351}