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, E::TYPE).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 + 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 assert_round_trips::<V>(app).await;
163}
164
165pub async fn assert_round_trips<V>(app: &TestApp)
167where
168 V: Fixture + Cast + Send + Serialize + DeserializeOwned,
169{
170 for _ in 0..ROUND_TRIPS {
171 let value: V = fixture_value(app);
172 let json = serde_json::to_value(&value).expect("a value serializes to JSON");
173 let read: V = serde_json::from_value(json.clone())
174 .unwrap_or_else(|error| panic!("{json} does not read back: {error}"));
175 let again = serde_json::to_value(&read).expect("a value serializes to JSON");
176 assert_eq!(again, json, "through JSON");
177 let stored = through_database(app, &value).await;
178 assert_eq!(
179 encoded(&stored),
180 encoded(&value),
181 "through the database as {json}"
182 );
183 }
184}
185
186fn encoded<V: Cast>(value: &V) -> Vec<SqlValue> {
187 let mut params = Vec::new();
188 value.encode(&mut params);
189 params
190}
191
192async fn through_database<V: Cast + Send>(app: &TestApp, value: &V) -> V {
193 let mut params = Vec::new();
194 value.encode(&mut params);
195 let columns: Vec<String> = V::COLUMNS
196 .iter()
197 .enumerate()
198 .map(|(index, column)| format!("${}::{}", index + 1, sql_type(column.ty)))
199 .collect();
200 let sql = format!("SELECT {}", columns.join(", "));
201 let types = sofie_db::column_types(&[V::COLUMNS]);
202 let mut decoded = sofie_db::query_decoded(&app.postgres, &sql, ¶ms, types, V::decode)
203 .await
204 .unwrap_or_else(|error| panic!("decoding what `{sql}` gave back: {error:?}"));
205 decoded.remove(0)
206}
207
208pub async fn assert_schema<A: Aggregate>(app: &TestApp) {
209 let mismatches = app
210 .db()
211 .schema()
212 .mismatches::<A>()
213 .await
214 .unwrap_or_else(|error| panic!("reading the table of {}: {error:?}", A::NAME));
215 let described: Vec<String> = mismatches.iter().map(ToString::to_string).collect();
216 assert!(
217 described.is_empty(),
218 "the table of {} disagrees with its schema:\n{}",
219 A::NAME,
220 described.join("\n")
221 );
222}
223
224pub async fn assert_store<A: Aggregate>(app: &TestApp, first: Id<A>, second: Id<A>) {
226 let read = app.db().read();
227 let loaded = A::find(&read, first)
228 .await
229 .expect("loading the first record");
230 let other = A::find(&read, second)
231 .await
232 .expect("loading the second record");
233 let table = format!("\"{}\".\"{}\"", A::MODULE, A::NAME);
234 app.query(
235 &format!("DELETE FROM {table} WHERE id = $1"),
236 &[SqlValue::Uuid(second.uuid())],
237 )
238 .await;
239 let mut changed = with_fields_of(&loaded, &other);
240 app.db()
241 .commit(&mut changed)
242 .await
243 .unwrap_or_else(|error| panic!("saving a {}: {error:?}", A::NAME));
244
245 let reloaded = A::find(&read, first)
246 .await
247 .expect("loading the saved record");
248 assert_eq!(
249 fields(&reloaded),
250 fields(&other),
251 "{} loads as saved",
252 A::NAME
253 );
254 assert_eq!(reloaded.meta().version(), loaded.meta().version() + 1);
255 let mut stale = with_fields_of(&loaded, &other);
256 let conflict = app.db().commit(&mut stale).await;
257 assert!(
258 matches!(conflict, Err(Error::Conflict)),
259 "a {} saved from a stale version: {conflict:?}",
260 A::NAME
261 );
262}
263
264pub async fn assert_unique<A>(app: &TestApp, field: &str)
265where
266 A: Aggregate + Create<Owner: FixtureOwner + Send, Input: InputFixture>,
267{
268 let value = fixture_input::<A::Input>(app)[field].clone();
269 let _first: A = app.fixture::<A>().with(field, &value).await;
270 let mut input = fixture_input::<A::Input>(app);
271 input[field] = value;
272 let input = A::Input::from_json(input).expect("a fixture input reads");
273 let mut second = A::create(input, A::Owner::fixture_owner(app)).expect("the factory builds it");
274 match app.db().commit(&mut second).await {
275 Err(Error::Invalid(errors))
276 if errors
277 .iter()
278 .any(|error| error.field == field && error.key == "validation.unique") => {}
279 other => panic!("a second {} with the same `{field}`: {other:?}", A::NAME),
280 }
281}
282
283#[cfg(feature = "contracts")]
284pub async fn assert_lookup<A, D>(app: &TestApp, ids: &[Id<A>], exposed: &[&str])
285where
286 A: Aggregate + Serialize,
287 D: DeserializeOwned + Serialize + Send,
288{
289 use sofie_db::{Exposed, Lookup};
290
291 let uuids: Vec<_> = ids.iter().map(|id| id.uuid()).collect();
292 let found = Exposed::<A, D>::new(app.db().clone())
293 .get_many(&uuids)
294 .await
295 .expect("looking the records up");
296 assert_eq!(found.len(), ids.len(), "every {} is found", A::NAME);
297 let mut expected: Vec<&str> = exposed.to_vec();
298 expected.sort_unstable();
299 for dto in found {
300 let Json::Object(fields) = serde_json::to_value(dto).expect("a DTO serializes") else {
301 panic!("a DTO of {} is an object", A::NAME);
302 };
303 let names: Vec<&str> = fields.keys().map(String::as_str).collect();
304 assert_eq!(names, expected, "the fields a {} lookup answers", A::NAME);
305 }
306}
307
308fn fields<A: Aggregate>(record: &A) -> Vec<SqlValue> {
309 let mut fields = Vec::new();
310 record.encode(&mut fields);
311 fields
312}
313
314fn with_fields_of<A: Aggregate>(record: &A, other: &A) -> A {
317 let mut row = fields(other);
318 row.extend((0..sofie_db::child_tables::<A>()).map(|_| SqlValue::Json(Json::Array(Vec::new()))));
319 A::decode(record.meta().clone(), &mut RowReader::new(&row))
320 .unwrap_or_else(|error| panic!("rebuilding a {}: {error:?}", A::NAME))
321}
322
323fn sql_type(ty: SqlType) -> &'static str {
324 match ty {
325 SqlType::Bool => "boolean",
326 SqlType::Int => "bigint",
327 SqlType::Float => "double precision",
328 SqlType::Text => "text",
329 SqlType::Bytes => "bytea",
330 SqlType::Uuid => "uuid",
331 SqlType::Timestamp => "timestamptz",
332 SqlType::Date => "date",
333 SqlType::DateTime => "timestamp",
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn a_constraint_reads_as_its_fact() {
343 assert_eq!(describe(Constraint::MaxLength(120)), "max 120");
344 assert_eq!(describe(Constraint::Min(0)), "min 0");
345 assert_eq!(describe(Constraint::Required), "required");
346 }
347
348 #[test]
349 fn text_is_held_to_its_length_and_a_number_to_its_range() {
350 const NAME: FieldRules<String> =
351 FieldRules::new(&[Constraint::Required, Constraint::MaxLength(5)]);
352 const COUNT: FieldRules<i64> = FieldRules::new(&[Constraint::Min(1), Constraint::Max(9)]);
353
354 assert_boundary("name", NAME, "required");
355 assert_boundary("name", NAME, "max 5");
356 assert_boundary("count", COUNT, "min 1");
357 assert_boundary("count", COUNT, "max 9");
358 }
359
360 #[test]
361 #[should_panic(expected = "no longer keeps `max 6`")]
362 fn a_changed_bound_fails_the_test_of_the_old_one() {
363 const NAME: FieldRules<String> = FieldRules::new(&[Constraint::MaxLength(5)]);
364
365 assert_boundary("name", NAME, "max 6");
366 }
367}