1use core::fmt;
56
57use crate::transport::{
58 CredentialsToken, OcpiError, OcpiRequest, Page, PageQuery, Quirks, RequestIds, StatusCode,
59};
60use crate::types::{DateTime, Url, Validate};
61use crate::v2_3_0::versions::{Version, VersionDetails};
62use crate::{InterfaceRole, ModuleId, VersionNumber};
63
64use super::http::Transport;
65
66#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
68#[non_exhaustive]
69pub enum Outcome {
70 Pass,
72 Warn,
74 Fail,
76 Skipped,
78}
79
80impl Outcome {
81 #[must_use]
83 pub const fn glyph(self) -> char {
84 match self {
85 Self::Pass => '+',
86 Self::Warn => '!',
87 Self::Fail => 'x',
88 Self::Skipped => '-',
89 }
90 }
91}
92
93impl fmt::Display for Outcome {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 f.write_str(match self {
96 Self::Pass => "PASS",
97 Self::Warn => "WARN",
98 Self::Fail => "FAIL",
99 Self::Skipped => "SKIP",
100 })
101 }
102}
103
104#[derive(Clone, Debug)]
106pub struct Check {
107 pub id: &'static str,
109 pub title: String,
111 pub outcome: Outcome,
113 pub detail: String,
115 pub spec: &'static str,
117}
118
119impl Check {
120 fn new(
121 id: &'static str,
122 title: impl Into<String>,
123 outcome: Outcome,
124 detail: impl Into<String>,
125 spec: &'static str,
126 ) -> Self {
127 Self { id, title: title.into(), outcome, detail: detail.into(), spec }
128 }
129}
130
131#[derive(Clone, Debug, Default)]
133pub struct Report {
134 pub checks: Vec<Check>,
136 pub version: Option<VersionNumber>,
138}
139
140impl Report {
141 #[must_use]
143 pub fn count(&self, outcome: Outcome) -> usize {
144 self.checks.iter().filter(|c| c.outcome == outcome).count()
145 }
146
147 #[must_use]
149 pub fn has_failures(&self) -> bool {
150 self.count(Outcome::Fail) > 0
151 }
152
153 pub fn failures(&self) -> impl Iterator<Item = &Check> {
155 self.checks.iter().filter(|c| c.outcome == Outcome::Fail)
156 }
157
158 fn push(&mut self, check: Check) {
159 self.checks.push(check);
160 }
161
162 fn pass(&mut self, id: &'static str, title: &str, detail: impl Into<String>, spec: &'static str) {
163 self.push(Check::new(id, title, Outcome::Pass, detail, spec));
164 }
165
166 fn fail(&mut self, id: &'static str, title: &str, detail: impl Into<String>, spec: &'static str) {
167 self.push(Check::new(id, title, Outcome::Fail, detail, spec));
168 }
169
170 fn warn(&mut self, id: &'static str, title: &str, detail: impl Into<String>, spec: &'static str) {
171 self.push(Check::new(id, title, Outcome::Warn, detail, spec));
172 }
173
174 fn skip(&mut self, id: &'static str, title: &str, detail: impl Into<String>, spec: &'static str) {
175 self.push(Check::new(id, title, Outcome::Skipped, detail, spec));
176 }
177
178 fn assert(
180 &mut self,
181 id: &'static str,
182 title: &str,
183 ok: bool,
184 detail: impl Into<String>,
185 spec: &'static str,
186 ) {
187 let outcome = if ok { Outcome::Pass } else { Outcome::Fail };
188 self.push(Check::new(id, title, outcome, detail, spec));
189 }
190}
191
192impl fmt::Display for Report {
193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 for check in &self.checks {
195 writeln!(f, "[{}] {:<10} {}", check.outcome.glyph(), check.id, check.title)?;
196 if !check.detail.is_empty() {
197 writeln!(f, " {}", check.detail)?;
198 }
199 if check.outcome == Outcome::Fail || check.outcome == Outcome::Warn {
200 writeln!(f, " spec: {}", check.spec)?;
201 }
202 }
203 writeln!(
204 f,
205 "\n{} passed, {} failed, {} warnings, {} skipped",
206 self.count(Outcome::Pass),
207 self.count(Outcome::Fail),
208 self.count(Outcome::Warn),
209 self.count(Outcome::Skipped),
210 )
211 }
212}
213
214const PULLABLE: &[ModuleId] =
219 &[ModuleId::Locations, ModuleId::Sessions, ModuleId::Cdrs, ModuleId::Tariffs, ModuleId::Tokens];
220
221#[derive(Clone, Debug)]
223pub struct Conformance {
224 versions_url: Url,
225 token: CredentialsToken,
226 quirks: Quirks,
227 page_limit: u64,
228 max_clock_skew: core::time::Duration,
229 check_auth: bool,
230}
231
232impl Conformance {
233 #[must_use]
238 pub fn new(versions_url: Url, token: CredentialsToken) -> Self {
239 Self {
240 versions_url,
241 token,
242 quirks: Quirks::default(),
243 page_limit: 10,
244 max_clock_skew: core::time::Duration::from_secs(300),
245 check_auth: true,
246 }
247 }
248
249 #[must_use]
251 pub fn with_quirks(mut self, quirks: Quirks) -> Self {
252 self.quirks = quirks;
253 self
254 }
255
256 #[must_use]
258 pub const fn with_page_limit(mut self, limit: u64) -> Self {
259 self.page_limit = limit;
260 self
261 }
262
263 #[must_use]
265 pub const fn with_max_clock_skew(mut self, skew: core::time::Duration) -> Self {
266 self.max_clock_skew = skew;
267 self
268 }
269
270 #[must_use]
275 pub const fn with_auth_checks(mut self, check: bool) -> Self {
276 self.check_auth = check;
277 self
278 }
279
280 pub async fn run(&self, transport: &Transport) -> Report {
285 let mut report = Report::default();
286
287 let Some(versions) = self.check_versions(transport, &mut report).await else {
288 return report;
289 };
290 let Some((version, details)) = self.check_details(transport, &mut report, &versions).await else {
291 return report;
292 };
293 report.version = Some(version.clone());
294
295 Self::check_endpoints(&mut report, &version, &details);
296 if self.check_auth {
297 self.check_authentication(transport, &mut report, &details).await;
298 }
299 self.check_modules(transport, &mut report, &details).await;
300
301 report
302 }
303
304 async fn check_versions(&self, transport: &Transport, report: &mut Report) -> Option<Vec<Version>> {
306 const SPEC: &str = "2.3.0 §version_information_endpoint";
307 let request = OcpiRequest::new(http::Method::GET, self.versions_url.clone(), ModuleId::Versions)
308 .with_ids(RequestIds::generate());
309
310 let (envelope, headers) =
311 match transport.send_with_headers::<Vec<Version>>(&request, &self.token, &self.quirks).await {
312 Ok(pair) => pair,
313 Err(e) => {
314 report.fail("versions.get", "GET /versions answers", e.to_string(), SPEC);
315 return None;
316 }
317 };
318
319 report.assert(
320 "versions.status",
321 "GET /versions returns status_code 1000",
322 envelope.status_code == StatusCode::SUCCESS,
323 format!("got {}", envelope.status_code),
324 "2.3.0 §status_codes_1xxx_success",
325 );
326
327 Self::check_echoed_ids(report, &request.ids, &headers);
328 self.check_timestamp(report, envelope.timestamp);
329
330 let Some(versions) = envelope.data else {
331 report.fail(
332 "versions.data",
333 "GET /versions carries a data field",
334 "the envelope has no `data`, so no version could be read",
335 SPEC,
336 );
337 return None;
338 };
339
340 report.assert(
341 "versions.nonempty",
342 "at least one version is offered",
343 !versions.is_empty(),
344 format!("{} offered", versions.len()),
345 SPEC,
346 );
347
348 let mut numbers: Vec<String> = versions.iter().map(|v| v.version.to_string()).collect();
349 numbers.sort();
350 let unique = {
351 let mut d = numbers.clone();
352 d.dedup();
353 d.len() == numbers.len()
354 };
355 report.assert("versions.unique", "each version is listed once", unique, numbers.join(", "), SPEC);
356
357 for version in &versions {
358 if let Err(e) = transport.url_policy().check(&version.url) {
359 report.fail(
360 "versions.url",
361 "every advertised version URL is usable",
362 format!("{}: {e}", version.version),
363 "2.3.0 §types_url_type",
364 );
365 }
366 }
367
368 let common: Vec<&Version> = versions.iter().filter(|v| v.version.is_supported()).collect();
369 if common.is_empty() {
370 report.fail(
371 "versions.common",
372 "the peer offers a version this build speaks",
373 format!(
374 "peer offers {}; this build speaks {}",
375 numbers.join(", "),
376 VersionNumber::supported().iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
377 ),
378 SPEC,
379 );
380 return None;
381 }
382 report.pass(
383 "versions.common",
384 "the peer offers a version this build speaks",
385 common.iter().map(|v| v.version.to_string()).collect::<Vec<_>>().join(", "),
386 SPEC,
387 );
388
389 Some(versions)
390 }
391
392 async fn check_details(
394 &self,
395 transport: &Transport,
396 report: &mut Report,
397 versions: &[Version],
398 ) -> Option<(VersionNumber, VersionDetails)> {
399 const SPEC: &str = "2.3.0 §version_information_endpoint_version_details";
400
401 let best = versions
402 .iter()
403 .filter(|v| v.version.is_supported())
404 .max_by(|a, b| a.version.cmp_by_release(&b.version))?;
405
406 let request = OcpiRequest::new(http::Method::GET, best.url.clone(), ModuleId::Versions)
407 .with_ids(RequestIds::generate());
408 let (envelope, headers) =
409 match transport.send_with_headers::<VersionDetails>(&request, &self.token, &self.quirks).await {
410 Ok(pair) => pair,
411 Err(e) => {
412 report.fail(
413 "details.get",
414 "the version-details endpoint answers",
415 format!("{}: {e}", best.url.as_str()),
416 SPEC,
417 );
418 return None;
419 }
420 };
421
422 Self::check_echoed_ids(report, &request.ids, &headers);
423
424 let Some(details) = envelope.data else {
425 report.fail("details.data", "version details carry a data field", "no `data`", SPEC);
426 return None;
427 };
428
429 report.assert(
430 "details.version",
431 "the details name the version they were fetched for",
432 details.version == best.version,
433 format!("asked for {}, got {}", best.version, details.version),
434 SPEC,
435 );
436
437 Some((best.version.clone(), details))
438 }
439
440 fn check_endpoints(report: &mut Report, version: &VersionNumber, details: &VersionDetails) {
442 const SPEC: &str = "2.3.0 §version_information_endpoint_endpoint_class";
443
444 report.assert(
445 "endpoints.nonempty",
446 "the version details list at least one endpoint",
447 !details.endpoints.is_empty(),
448 format!("{} listed", details.endpoints.len()),
449 SPEC,
450 );
451
452 report.assert(
453 "endpoints.credentials",
454 "the credentials module is offered",
455 details.credentials_url().is_some(),
456 "every implementation must have a credentials endpoint",
457 "2.3.0 §credentials_credentials_module",
458 );
459
460 let mut pairs: Vec<(String, InterfaceRole)> =
461 details.endpoints.iter().map(|e| (e.identifier.to_string(), e.role)).collect();
462 pairs.sort();
463 let duplicate = pairs.windows(2).find(|w| w[0] == w[1]).map(|w| w[0].clone());
464 report.assert(
465 "endpoints.unique",
466 "no module and role pair is listed twice",
467 duplicate.is_none(),
468 duplicate.map_or_else(String::new, |(m, r)| format!("{m}/{r} appears more than once")),
469 SPEC,
470 );
471
472 for endpoint in &details.endpoints {
473 if !endpoint.identifier.exists_in(version) {
474 report.warn(
475 "endpoints.known",
476 "every advertised module exists in this version",
477 format!("`{}` is not a module of OCPI {version}", endpoint.identifier),
478 "2.3.0 §version_information_endpoint_moduleid_enum",
479 );
480 }
481 if let Err(e) = endpoint.url.parse() {
482 report.fail(
483 "endpoints.absolute",
484 "every endpoint URL is absolute",
485 format!("{}/{}: {e}", endpoint.identifier, endpoint.role),
486 "2.3.0 §types_url_type",
487 );
488 }
489 }
490
491 if let Err(violations) = details.validate() {
492 for v in &violations {
493 report.warn(
494 "details.conform",
495 "the version details conform",
496 format!("{}: {}", v.pointer, v.message),
497 SPEC,
498 );
499 }
500 }
501 }
502
503 async fn check_authentication(
505 &self,
506 transport: &Transport,
507 report: &mut Report,
508 details: &VersionDetails,
509 ) {
510 const SPEC: &str = "2.3.0 §transport_and_format_authorization_header";
511
512 let Some((module, url)) = PULLABLE
513 .iter()
514 .find_map(|m| details.url(m, InterfaceRole::Sender).map(|u| (m.clone(), u.clone())))
515 else {
516 report.skip(
517 "auth.unauthenticated",
518 "an unauthenticated request is refused",
519 "the peer offers no Sender interface to try it on",
520 SPEC,
521 );
522 return;
523 };
524
525 for (id, title, token) in [
526 (
527 "auth.empty",
528 "a request with an empty token is refused with 401",
529 CredentialsToken::new_lenient(String::new()),
530 ),
531 (
532 "auth.wrong",
533 "a request with a token that is not ours is refused with 401",
534 CredentialsToken::new_lenient("ocpi-kit-conformance-not-a-real-token"),
535 ),
536 ] {
537 let request = OcpiRequest::new(http::Method::GET, url.clone(), module.clone())
538 .with_ids(RequestIds::generate());
539 let outcome = transport.send::<serde_json::Value>(&request, &token, &self.quirks).await;
540 match outcome {
541 Err(OcpiError::Unauthorized(_)) => {
542 report.pass(id, title, "401, as required", SPEC);
543 }
544 Err(OcpiError::NotFound(_)) => {
545 report.pass(id, title, "404 — the peer will not confirm the endpoint exists", SPEC);
547 }
548 Ok(_) => report.fail(
549 id,
550 title,
551 "the peer answered 200 to an unauthenticated request, exposing its data",
552 SPEC,
553 ),
554 Err(other) => report.warn(id, title, format!("expected 401, got {other}"), SPEC),
555 }
556 }
557 }
558
559 async fn check_modules(&self, transport: &Transport, report: &mut Report, details: &VersionDetails) {
561 for module in PULLABLE {
562 let Some(url) = details.url(module, InterfaceRole::Sender) else {
563 report.skip(
564 "module.page",
565 &format!("{module} Sender returns a decodable page"),
566 "not offered by this peer",
567 "2.3.0 §transport_and_format_pagination",
568 );
569 continue;
570 };
571 self.check_one_module(transport, report, module, url).await;
572 }
573 }
574
575 async fn check_one_module(
576 &self,
577 transport: &Transport,
578 report: &mut Report,
579 module: &ModuleId,
580 url: &Url,
581 ) {
582 const SPEC: &str = "2.3.0 §transport_and_format_pagination";
583 let query = PageQuery::new().with_limit(self.page_limit);
584 let request = OcpiRequest::new(http::Method::GET, query.apply_to(url), module.clone())
585 .with_ids(RequestIds::generate());
586
587 let page = match transport.send_page::<serde_json::Value>(&request, &self.token, &self.quirks).await {
590 Ok(page) => page,
591 Err(e) => {
592 report.fail(
593 "module.page",
594 &format!("{module} Sender returns a decodable page"),
595 e.to_string(),
596 SPEC,
597 );
598 return;
599 }
600 };
601
602 report.pass(
603 "module.page",
604 &format!("{module} Sender returns a decodable page"),
605 format!("{} object(s)", page.items.len()),
606 SPEC,
607 );
608
609 let count = page.items.len() as u64;
610 report.assert(
611 "module.limit",
612 &format!("{module} honours the requested limit"),
613 count <= self.page_limit,
614 format!("asked for at most {}, got {count}", self.page_limit),
615 SPEC,
616 );
617
618 match page.meta.limit {
619 Some(limit) => {
620 report.assert(
621 "module.xlimit",
622 &format!("{module} reports X-Limit consistently"),
623 count <= limit,
624 format!("X-Limit: {limit}, body carried {count}"),
625 SPEC,
626 );
627 }
628 None => report.warn(
629 "module.xlimit",
630 &format!("{module} sends an X-Limit header"),
631 "absent, so a client cannot tell whether its limit was reduced",
632 SPEC,
633 ),
634 }
635
636 match page.meta.total_count {
637 Some(total) => {
638 let expects_next = total > count;
639 report.assert(
640 "module.link",
641 &format!("{module} sends Link: rel=\"next\" exactly when there is more"),
642 expects_next == page.meta.next.is_some(),
643 format!(
644 "X-Total-Count: {total}, this page: {count}, next link: {}",
645 page.meta.next.as_ref().map_or("absent", |_| "present")
646 ),
647 SPEC,
648 );
649 }
650 None => report.warn(
651 "module.total",
652 &format!("{module} sends an X-Total-Count header"),
653 "absent, so a client cannot size the crawl",
654 SPEC,
655 ),
656 }
657
658 Self::check_objects(report, module, &page.items);
659 self.check_offset(transport, report, module, url, &page).await;
660 self.check_date_from(transport, report, module, url, &page).await;
661 }
662
663 async fn check_offset(
673 &self,
674 transport: &Transport,
675 report: &mut Report,
676 module: &ModuleId,
677 url: &Url,
678 first: &Page<serde_json::Value>,
679 ) {
680 const SPEC: &str = "2.3.0 §transport_and_format_pagination";
681 let title = format!("{module} applies the offset parameter");
682 if first.items.len() < 2 {
683 report.skip("module.offset", &title, "fewer than two objects to distinguish", SPEC);
684 return;
685 }
686 let query = PageQuery::new().with_offset(1).with_limit(1);
687 let request = OcpiRequest::new(http::Method::GET, query.apply_to(url), module.clone())
688 .with_ids(RequestIds::generate());
689 match transport.send_page::<serde_json::Value>(&request, &self.token, &self.quirks).await {
690 Err(e) => report.fail("module.offset", &title, e.to_string(), SPEC),
691 Ok(second) => match second.items.first() {
692 None => report.warn(
693 "module.offset",
694 &title,
695 "offset=1&limit=1 returned nothing, although the unfiltered page had at least two objects",
696 SPEC,
697 ),
698 Some(item) => report.assert(
699 "module.offset",
700 &title,
701 *item == first.items[1],
702 if *item == first.items[0] {
703 "offset=1 returned the object at offset 0; a crawl over this endpoint would never terminate"
704 .to_owned()
705 } else {
706 "offset=1 returned an object, though not the second of the first page — acceptable if the set changed between the two requests"
707 .to_owned()
708 },
709 SPEC,
710 ),
711 },
712 }
713 }
714
715 async fn check_date_from(
724 &self,
725 transport: &Transport,
726 report: &mut Report,
727 module: &ModuleId,
728 url: &Url,
729 first: &Page<serde_json::Value>,
730 ) {
731 const SPEC: &str = "2.3.0 §transport_and_format_pagination";
732 let title = format!("{module} applies the date_from filter");
733 let Some(newest) = first.items.iter().filter_map(last_updated).max() else {
734 report.skip("module.date_from", &title, "no object carried a usable last_updated", SPEC);
735 return;
736 };
737 let Ok(after) = DateTime::from_unix_timestamp(newest.unix_timestamp() + 1) else {
743 report.skip("module.date_from", &title, "the newest timestamp is at the end of time", SPEC);
744 return;
745 };
746 let query = PageQuery::since(after).with_limit(self.page_limit);
747 let request = OcpiRequest::new(http::Method::GET, query.apply_to(url), module.clone())
748 .with_ids(RequestIds::generate());
749 match transport.send_page::<serde_json::Value>(&request, &self.token, &self.quirks).await {
750 Err(e) => report.fail("module.date_from", &title, e.to_string(), SPEC),
751 Ok(filtered) => {
752 let stale = filtered.items.iter().filter_map(last_updated).filter(|t| *t < after).count();
753 let total = filtered.items.len();
754 report.assert(
755 "module.date_from",
756 &title,
757 stale == 0,
758 if stale == 0 {
759 format!("asked for last_updated >= {after}, got {total} object(s), none older")
760 } else {
761 format!(
762 "asked for last_updated >= {after}, got {stale} of {total} object(s) older \
763 than that; a peer that ignores date_from turns every incremental pull \
764 into a full one"
765 )
766 },
767 SPEC,
768 );
769 }
770 }
771 }
772
773 fn check_objects(report: &mut Report, module: &ModuleId, items: &[serde_json::Value]) {
775 macro_rules! typed {
776 ($ty:ty) => {{
777 let mut decoded = 0usize;
778 let mut problems: Vec<String> = Vec::new();
779 for (i, raw) in items.iter().enumerate() {
780 match serde_json::from_value::<$ty>(raw.clone()) {
781 Ok(object) => {
782 decoded += 1;
783 if let Err(violations) = object.validate() {
784 for v in violations.iter() {
785 problems.push(format!("[{i}]{}: {}", v.pointer, v.message));
786 }
787 }
788 }
789 Err(e) => problems.push(format!("[{i}] does not decode: {e}")),
790 }
791 }
792 (decoded, problems)
793 }};
794 }
795
796 let (decoded, problems) = match module {
797 ModuleId::Locations => typed!(crate::v2_3_0::locations::Location),
798 ModuleId::Sessions => typed!(crate::v2_3_0::sessions::Session),
799 ModuleId::Cdrs => typed!(crate::v2_3_0::cdrs::Cdr),
800 ModuleId::Tariffs => typed!(crate::v2_3_0::tariffs::Tariff),
801 ModuleId::Tokens => typed!(crate::v2_3_0::tokens::Token),
802 _ => return,
803 };
804
805 if problems.is_empty() {
806 report.pass(
807 "module.objects",
808 &format!("{module} objects conform"),
809 format!("{decoded} checked"),
810 "2.3.0 §types_types",
811 );
812 return;
813 }
814 let shown = problems.iter().take(10).cloned().collect::<Vec<_>>().join("; ");
816 let suffix =
817 if problems.len() > 10 { format!(" (and {} more)", problems.len() - 10) } else { String::new() };
818 report.warn(
819 "module.objects",
820 &format!("{module} objects conform"),
821 format!("{shown}{suffix}"),
822 "2.3.0 §types_types",
823 );
824 }
825
826 fn check_echoed_ids(report: &mut Report, sent: &RequestIds, headers: &http::HeaderMap) {
827 const SPEC: &str = "2.3.0 §transport_and_format_request_id";
828 let got = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or_default().to_owned();
829
830 let request_id = got("x-request-id");
831 report.assert(
832 "headers.request_id",
833 "X-Request-ID is echoed",
834 request_id == sent.request_id.as_str(),
835 if request_id.is_empty() {
836 "absent from the response".to_owned()
837 } else {
838 format!("sent {}, got back {request_id}", sent.request_id)
839 },
840 SPEC,
841 );
842
843 let correlation_id = got("x-correlation-id");
844 report.assert(
845 "headers.correlation_id",
846 "X-Correlation-ID is echoed",
847 correlation_id == sent.correlation_id.as_str(),
848 if correlation_id.is_empty() {
849 "absent from the response".to_owned()
850 } else {
851 format!("sent {}, got back {correlation_id}", sent.correlation_id)
852 },
853 SPEC,
854 );
855 }
856
857 fn check_timestamp(&self, report: &mut Report, timestamp: DateTime) {
858 const SPEC: &str = "2.3.0 §transport_and_format_response_format";
859 let now = DateTime::now();
860 let skew = (now.unix_timestamp() - timestamp.unix_timestamp()).unsigned_abs();
861 let allowed = self.max_clock_skew.as_secs();
862 report.assert(
863 "headers.timestamp",
864 "the response timestamp is close to ours",
865 skew <= allowed,
866 format!("peer says {timestamp}, we say {now} — {skew}s apart, tolerance {allowed}s"),
867 SPEC,
868 );
869 }
870}
871
872fn last_updated(object: &serde_json::Value) -> Option<DateTime> {
876 object.get("last_updated")?.as_str()?.parse().ok()
877}
878
879#[cfg(test)]
880mod tests {
881 use super::*;
882
883 fn check(outcome: Outcome) -> Check {
884 Check::new("t", "t", outcome, "", "spec")
885 }
886
887 #[test]
888 fn a_report_counts_by_outcome() {
889 let report = Report {
890 checks: vec![
891 check(Outcome::Pass),
892 check(Outcome::Pass),
893 check(Outcome::Fail),
894 check(Outcome::Warn),
895 check(Outcome::Skipped),
896 ],
897 version: None,
898 };
899 assert_eq!(report.count(Outcome::Pass), 2);
900 assert_eq!(report.count(Outcome::Fail), 1);
901 assert!(report.has_failures());
902 assert_eq!(report.failures().count(), 1);
903 }
904
905 #[test]
906 fn a_clean_report_has_no_failures() {
907 let report = Report { checks: vec![check(Outcome::Pass), check(Outcome::Warn)], version: None };
908 assert!(!report.has_failures(), "a warning is not a failure");
909 }
910
911 #[test]
912 fn the_summary_line_names_every_outcome() {
913 let report = Report { checks: vec![check(Outcome::Pass), check(Outcome::Fail)], version: None };
914 let text = report.to_string();
915 assert!(text.contains("1 passed"), "{text}");
916 assert!(text.contains("1 failed"), "{text}");
917 }
918
919 #[test]
920 fn only_read_only_modules_are_pulled() {
921 for module in PULLABLE {
924 assert!(
925 matches!(
926 module,
927 ModuleId::Locations
928 | ModuleId::Sessions
929 | ModuleId::Cdrs
930 | ModuleId::Tariffs
931 | ModuleId::Tokens
932 ),
933 "{module} is not a read-only Sender list endpoint"
934 );
935 }
936 }
937
938 #[test]
939 fn outcomes_order_worst_last_for_sorting() {
940 assert!(Outcome::Pass < Outcome::Warn);
941 assert!(Outcome::Warn < Outcome::Fail);
942 }
943}