rustrails_model/validations/
confirmation.rs1use rustrails_support::inflector::humanize;
2use serde_json::Value;
3
4use super::{Validator, ValidatorOptions};
5use crate::errors::{ErrorType, Errors};
6
7#[derive(Debug, Clone)]
9pub struct ConfirmationValidator {
10 confirmation_attribute: String,
11 case_sensitive: bool,
12 message: Option<String>,
13 pub(crate) options: ValidatorOptions,
14}
15
16impl ConfirmationValidator {
17 #[must_use]
19 pub fn new(confirmation_attribute: &str) -> Self {
20 Self {
21 confirmation_attribute: confirmation_attribute.to_owned(),
22 case_sensitive: true,
23 message: None,
24 options: ValidatorOptions::default(),
25 }
26 }
27
28 crate::validations::impl_common_validator_methods!();
29
30 #[must_use]
32 pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
33 self.case_sensitive = case_sensitive;
34 self
35 }
36
37 #[must_use]
39 pub fn message(mut self, message: impl Into<String>) -> Self {
40 self.message = Some(message.into());
41 self
42 }
43
44 fn error_message(&self, attribute: &str) -> String {
45 self.message
46 .clone()
47 .unwrap_or_else(|| format!("doesn't match {}", humanize(attribute)))
48 }
49
50 fn matches(&self, value: Option<&Value>, confirmation: &Value) -> bool {
51 match (value, confirmation) {
52 (Some(Value::String(left)), Value::String(right)) if !self.case_sensitive => {
53 left.to_lowercase() == right.to_lowercase()
54 }
55 (Some(left), right) => left == right,
56 (None, Value::Null) => true,
57 _ => false,
58 }
59 }
60}
61
62impl Validator for ConfirmationValidator {
63 fn validate(&self, _attribute: &str, _value: Option<&Value>, _errors: &mut Errors) {}
64
65 fn validate_with_attrs(
66 &self,
67 attribute: &str,
68 value: Option<&Value>,
69 attrs: &dyn Fn(&str) -> Option<Value>,
70 errors: &mut Errors,
71 ) {
72 let Some(confirmation) = attrs(&self.confirmation_attribute) else {
73 return;
74 };
75
76 if confirmation.is_null() {
77 return;
78 }
79
80 if !self.matches(value, &confirmation) {
81 errors.add(
82 &self.confirmation_attribute,
83 ErrorType::Confirmation,
84 self.error_message(attribute),
85 );
86 }
87 }
88
89 fn name(&self) -> &str {
90 "confirmation"
91 }
92
93 fn options(&self) -> &ValidatorOptions {
94 &self.options
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use std::collections::HashMap;
101
102 use serde_json::json;
103
104 use super::ConfirmationValidator;
105 use crate::{
106 errors::{ErrorType, Errors},
107 validations::{ValidationSet, Validator},
108 };
109
110 fn validate_confirmation(
111 validator: &ConfirmationValidator,
112 attribute: &str,
113 value: Option<&serde_json::Value>,
114 attrs: &HashMap<String, serde_json::Value>,
115 ) -> Errors {
116 let mut errors = Errors::new();
117 validator.validate_with_attrs(
118 attribute,
119 value,
120 &|name| attrs.get(name).cloned(),
121 &mut errors,
122 );
123 errors
124 }
125
126 #[test]
127 fn missing_confirmation_attribute_is_ignored() {
128 let validator = ConfirmationValidator::new("password_confirmation");
129 let attrs = HashMap::from([(String::from("password"), json!("secret"))]);
130 let mut errors = Errors::new();
131
132 validator.validate_with_attrs(
133 "password",
134 attrs.get("password"),
135 &|name| attrs.get(name).cloned(),
136 &mut errors,
137 );
138
139 assert!(errors.is_empty());
140 }
141
142 #[test]
143 fn mismatch_adds_error_on_confirmation_attribute() {
144 let validator = ConfirmationValidator::new("password_confirmation");
145 let attrs = HashMap::from([
146 (String::from("password"), json!("secret")),
147 (String::from("password_confirmation"), json!("other")),
148 ]);
149 let mut errors = Errors::new();
150
151 validator.validate_with_attrs(
152 "password",
153 attrs.get("password"),
154 &|name| attrs.get(name).cloned(),
155 &mut errors,
156 );
157
158 assert_eq!(
159 errors.on("password_confirmation")[0].error_type,
160 ErrorType::Confirmation
161 );
162 }
163
164 #[test]
165 fn matching_values_pass() {
166 let validator = ConfirmationValidator::new("email_confirmation");
167 let attrs = HashMap::from([
168 (String::from("email"), json!("alice@example.com")),
169 (
170 String::from("email_confirmation"),
171 json!("alice@example.com"),
172 ),
173 ]);
174 let mut errors = Errors::new();
175
176 validator.validate_with_attrs(
177 "email",
178 attrs.get("email"),
179 &|name| attrs.get(name).cloned(),
180 &mut errors,
181 );
182
183 assert!(errors.is_empty());
184 }
185
186 #[test]
187 fn case_insensitive_strings_can_match() {
188 let validator = ConfirmationValidator::new("email_confirmation").case_sensitive(false);
189 let attrs = HashMap::from([
190 (String::from("email"), json!("Alice@example.com")),
191 (
192 String::from("email_confirmation"),
193 json!("alice@example.com"),
194 ),
195 ]);
196 let mut errors = Errors::new();
197
198 validator.validate_with_attrs(
199 "email",
200 attrs.get("email"),
201 &|name| attrs.get(name).cloned(),
202 &mut errors,
203 );
204
205 assert!(errors.is_empty());
206 }
207
208 #[test]
209 fn validation_set_uses_sibling_attribute_lookup() {
210 let mut set = ValidationSet::new();
211 set.add(
212 "password",
213 ConfirmationValidator::new("password_confirmation"),
214 );
215 let attrs = HashMap::from([
216 (String::from("password"), json!("secret")),
217 (String::from("password_confirmation"), json!("other")),
218 ]);
219 let mut errors = Errors::new();
220
221 let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
222
223 assert_eq!(
224 errors.on("password_confirmation")[0].message,
225 "doesn't match Password"
226 );
227 }
228
229 #[test]
230 fn null_confirmation_is_ignored() {
231 let validator = ConfirmationValidator::new("password_confirmation");
232 let attrs = HashMap::from([
233 (String::from("password"), json!("secret")),
234 (String::from("password_confirmation"), json!(null)),
235 ]);
236
237 let errors = validate_confirmation(&validator, "password", attrs.get("password"), &attrs);
238
239 assert!(errors.is_empty());
240 }
241
242 #[test]
243 fn missing_primary_value_with_non_null_confirmation_fails() {
244 let validator = ConfirmationValidator::new("password_confirmation");
245 let attrs = HashMap::from([(String::from("password_confirmation"), json!("secret"))]);
246
247 let errors = validate_confirmation(&validator, "password", None, &attrs);
248
249 assert_eq!(
250 errors.on("password_confirmation")[0].error_type,
251 ErrorType::Confirmation
252 );
253 }
254
255 #[test]
256 fn custom_message_override_is_used() {
257 let validator = ConfirmationValidator::new("password_confirmation").message("must match");
258 let attrs = HashMap::from([
259 (String::from("password"), json!("secret")),
260 (String::from("password_confirmation"), json!("other")),
261 ]);
262
263 let errors = validate_confirmation(&validator, "password", attrs.get("password"), &attrs);
264
265 assert_eq!(errors.on("password_confirmation")[0].message, "must match");
266 }
267
268 #[test]
269 fn explicit_case_sensitive_matching_rejects_casing_difference() {
270 let validator = ConfirmationValidator::new("email_confirmation").case_sensitive(true);
271 let attrs = HashMap::from([
272 (String::from("email"), json!("Alice@example.com")),
273 (
274 String::from("email_confirmation"),
275 json!("alice@example.com"),
276 ),
277 ]);
278
279 let errors = validate_confirmation(&validator, "email", attrs.get("email"), &attrs);
280
281 assert_eq!(
282 errors.on("email_confirmation")[0].error_type,
283 ErrorType::Confirmation
284 );
285 }
286
287 #[test]
288 fn case_insensitive_matching_still_rejects_different_strings() {
289 let validator = ConfirmationValidator::new("email_confirmation").case_sensitive(false);
290 let attrs = HashMap::from([
291 (String::from("email"), json!("alice@example.com")),
292 (String::from("email_confirmation"), json!("bob@example.com")),
293 ]);
294
295 let errors = validate_confirmation(&validator, "email", attrs.get("email"), &attrs);
296
297 assert_eq!(
298 errors.on("email_confirmation")[0].error_type,
299 ErrorType::Confirmation
300 );
301 }
302
303 #[test]
304 fn matching_boolean_values_pass() {
305 let validator = ConfirmationValidator::new("published_confirmation");
306 let attrs = HashMap::from([
307 (String::from("published"), json!(true)),
308 (String::from("published_confirmation"), json!(true)),
309 ]);
310
311 let errors = validate_confirmation(&validator, "published", attrs.get("published"), &attrs);
312
313 assert!(errors.is_empty());
314 }
315
316 #[test]
317 fn mismatched_boolean_values_fail() {
318 let validator = ConfirmationValidator::new("published_confirmation");
319 let attrs = HashMap::from([
320 (String::from("published"), json!(true)),
321 (String::from("published_confirmation"), json!(false)),
322 ]);
323
324 let errors = validate_confirmation(&validator, "published", attrs.get("published"), &attrs);
325
326 assert_eq!(
327 errors.on("published_confirmation")[0].error_type,
328 ErrorType::Confirmation
329 );
330 }
331
332 #[test]
333 fn direct_validate_is_a_no_op() {
334 let validator = ConfirmationValidator::new("password_confirmation");
335 let mut errors = Errors::new();
336
337 validator.validate("password", Some(&json!("secret")), &mut errors);
338
339 assert!(errors.is_empty());
340 }
341
342 #[test]
343 fn validation_set_passes_when_confirmation_matches() {
344 let mut set = ValidationSet::new();
345 set.add(
346 "password",
347 ConfirmationValidator::new("password_confirmation"),
348 );
349 let attrs = HashMap::from([
350 (String::from("password"), json!("secret")),
351 (String::from("password_confirmation"), json!("secret")),
352 ]);
353 let mut errors = Errors::new();
354
355 let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
356
357 assert!(errors.is_empty());
358 }
359
360 #[test]
361 fn full_message_humanizes_confirmation_attribute() {
362 let validator = ConfirmationValidator::new("password_confirmation");
363 let attrs = HashMap::from([
364 (String::from("password"), json!("secret")),
365 (String::from("password_confirmation"), json!("other")),
366 ]);
367
368 let errors = validate_confirmation(&validator, "password", attrs.get("password"), &attrs);
369
370 assert_eq!(
371 errors.full_messages(),
372 vec!["Password confirmation doesn't match Password".to_string()],
373 );
374 }
375}