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#[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, ¶ms, 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
208pub 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
298fn 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}