1use clap::Parser;
2use graphql_parser::schema::{
3 parse_schema, Definition, Document, Field, ObjectType, Type, TypeDefinition,
4};
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7
8const INTROSPECTION_QUERY: &str = r#"
9{ __schema { types {
10 kind name
11 fields(includeDeprecated: true) {
12 name type { kind name ofType { kind name ofType { kind name ofType { kind name } } } }
13 }
14} } }
15"#;
16
17#[derive(Parser)]
22pub struct SchemaCheck {
23 #[arg(short, long, conflicts_with = "live_url")]
26 pub source: Option<PathBuf>,
27 #[arg(short, long, conflicts_with = "source")]
32 pub live_url: Option<String>,
33 #[arg(short, long)]
35 pub consumer: PathBuf,
36}
37
38pub async fn schema_check(cmd: SchemaCheck) -> anyhow::Result<()> {
39 let consumer_sdl = std::fs::read_to_string(&cmd.consumer)?;
40
41 let (source_sdl, source_label) = match (&cmd.source, &cmd.live_url) {
42 (Some(path), None) => (std::fs::read_to_string(path)?, "source".to_string()),
43 (None, Some(url)) => {
44 let sdl = fetch_live_entities_as_sdl(url).await?;
45 (sdl, format!("live ({url})"))
46 }
47 _ => {
48 return Err(anyhow::anyhow!(
49 "exactly one of --source or --live-url must be provided"
50 ));
51 }
52 };
53
54 match check(&source_sdl, &consumer_sdl) {
55 Ok(count) => {
56 println!("schema check ok: {count} entities verified against {source_label}");
57 Ok(())
58 }
59 Err(errors) => {
60 let mut msg = format!("schema check failed with {} mismatches:", errors.len());
61 for e in &errors {
62 msg.push_str("\n - ");
63 msg.push_str(e);
64 }
65 if cmd.live_url.is_some() {
66 msg.push_str(
67 "\n\nLive introspection-derived entity SDL (copy into consumer file):\n",
68 );
69 msg.push_str(&source_sdl);
70 }
71 Err(anyhow::anyhow!(msg))
72 }
73 }
74}
75
76async fn fetch_live_entities_as_sdl(url: &str) -> anyhow::Result<String> {
81 #[cfg(not(target_family = "wasm"))]
86 let client = reqwest::Client::builder()
87 .connect_timeout(std::time::Duration::from_secs(10))
88 .timeout(std::time::Duration::from_secs(30))
89 .build()?;
90 #[cfg(target_family = "wasm")]
91 let client = reqwest::Client::new();
92
93 let body = serde_json::json!({ "query": INTROSPECTION_QUERY });
94 let resp: serde_json::Value = client
95 .post(url)
96 .json(&body)
97 .send()
98 .await?
99 .error_for_status()?
100 .json()
101 .await?;
102 if let Some(errors) = resp.get("errors") {
103 return Err(anyhow::anyhow!("introspection errors: {errors}"));
104 }
105 let types = resp
106 .pointer("/data/__schema/types")
107 .and_then(|t| t.as_array())
108 .ok_or_else(|| anyhow::anyhow!("introspection response missing /data/__schema/types"))?;
109
110 let mut sdl = String::new();
111 for t in types {
112 let kind = t.get("kind").and_then(|v| v.as_str()).unwrap_or("");
113 let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("");
114 if kind != "OBJECT" || !is_entity_object(name) {
115 continue;
116 }
117 sdl.push_str(&format!("type {name} @entity {{\n"));
118 if let Some(fields) = t.get("fields").and_then(|f| f.as_array()) {
119 for f in fields {
120 let fname = f.get("name").and_then(|v| v.as_str()).unwrap_or("");
121 let ftype = render_type(f.get("type").unwrap_or(&serde_json::Value::Null));
122 sdl.push_str(&format!(" {fname}: {ftype}\n"));
123 }
124 }
125 sdl.push_str("}\n\n");
126 }
127 Ok(sdl)
128}
129
130fn is_entity_object(name: &str) -> bool {
134 !name.is_empty()
135 && !name.starts_with('_')
136 && name != "Query"
137 && name != "Subscription"
138 && !name.ends_with("_filter")
139 && !name.ends_with("_orderBy")
140}
141
142fn render_type(t: &serde_json::Value) -> String {
144 let kind = t.get("kind").and_then(|v| v.as_str()).unwrap_or("");
145 let name = t.get("name").and_then(|v| v.as_str());
146 let of_type = t.get("ofType");
147 match kind {
148 "NON_NULL" => format!(
149 "{}!",
150 render_type(of_type.unwrap_or(&serde_json::Value::Null))
151 ),
152 "LIST" => format!(
153 "[{}]",
154 render_type(of_type.unwrap_or(&serde_json::Value::Null))
155 ),
156 _ => name.unwrap_or("Unknown").to_string(),
157 }
158}
159
160fn check(source_sdl: &str, consumer_sdl: &str) -> Result<usize, Vec<String>> {
161 let source_doc: Document<String> =
162 parse_schema(source_sdl).map_err(|e| vec![format!("parse source: {e}")])?;
163 let consumer_doc: Document<String> =
164 parse_schema(consumer_sdl).map_err(|e| vec![format!("parse consumer: {e}")])?;
165
166 let source_entities = entities(&source_doc);
167 let consumer_field_index = build_field_index(&consumer_doc);
168
169 if source_entities.is_empty() {
170 return Err(vec![
171 "source schema has no `@entity` types; check that --source/--live-url \
172 points at a subgraph SDL or live introspection endpoint"
173 .to_string(),
174 ]);
175 }
176
177 let mut errors = Vec::new();
178
179 for entity in &source_entities {
180 match consumer_field_index.get(entity.name.as_str()) {
181 None => errors.push(format!(
182 "entity `{}` is missing from consumer schema",
183 entity.name
184 )),
185 Some(consumer_fields) => {
186 for field in &entity.fields {
187 match consumer_fields.get(field.name.as_str()) {
188 None => errors.push(format!(
189 "field `{}.{}` is missing from consumer schema",
190 entity.name, field.name
191 )),
192 Some(consumer_field) => {
193 if !type_equal(&field.field_type, &consumer_field.field_type) {
194 errors.push(format!(
195 "field `{}.{}` type mismatch: source `{}` vs consumer `{}`",
196 entity.name,
197 field.name,
198 type_to_string(&field.field_type),
199 type_to_string(&consumer_field.field_type),
200 ));
201 }
202 }
203 }
204 }
205 }
206 }
207 }
208
209 if errors.is_empty() {
210 Ok(source_entities.len())
211 } else {
212 Err(errors)
213 }
214}
215
216fn entities<'a>(doc: &'a Document<'a, String>) -> Vec<&'a ObjectType<'a, String>> {
217 doc.definitions
218 .iter()
219 .filter_map(|def| {
220 if let Definition::TypeDefinition(TypeDefinition::Object(obj)) = def {
221 if obj.directives.iter().any(|d| d.name == "entity") {
222 return Some(obj);
223 }
224 }
225 None
226 })
227 .collect()
228}
229
230fn build_field_index<'a>(
234 doc: &'a Document<'a, String>,
235) -> BTreeMap<&'a str, BTreeMap<&'a str, &'a Field<'a, String>>> {
236 doc.definitions
237 .iter()
238 .filter_map(|def| {
239 if let Definition::TypeDefinition(TypeDefinition::Object(obj)) = def {
240 let fields: BTreeMap<&str, &Field<'_, String>> =
241 obj.fields.iter().map(|f| (f.name.as_str(), f)).collect();
242 Some((obj.name.as_str(), fields))
243 } else {
244 None
245 }
246 })
247 .collect()
248}
249
250fn type_equal(a: &Type<'_, String>, b: &Type<'_, String>) -> bool {
251 match (a, b) {
252 (Type::NamedType(an), Type::NamedType(bn)) => an == bn,
253 (Type::ListType(ai), Type::ListType(bi)) => type_equal(ai, bi),
254 (Type::NonNullType(ai), Type::NonNullType(bi)) => type_equal(ai, bi),
255 _ => false,
256 }
257}
258
259fn type_to_string(t: &Type<'_, String>) -> String {
260 match t {
261 Type::NamedType(n) => n.clone(),
262 Type::ListType(inner) => format!("[{}]", type_to_string(inner)),
263 Type::NonNullType(inner) => format!("{}!", type_to_string(inner)),
264 }
265}
266
267#[cfg(all(test, not(target_family = "wasm")))]
268mod tests {
269 use super::*;
270
271 const SOURCE_OK: &str = r#"
272 type MetaBoard @entity {
273 id: Bytes!
274 address: Bytes!
275 nextMetaId: BigInt!
276 }
277 type MetaV1 @entity {
278 id: ID!
279 sender: Bytes!
280 subject: Bytes!
281 }
282 "#;
283
284 const CONSUMER_OK: &str = r#"
285 type MetaBoard {
286 id: Bytes!
287 address: Bytes!
288 nextMetaId: BigInt!
289 }
290 type MetaV1 {
291 id: ID!
292 sender: Bytes!
293 subject: Bytes!
294 }
295 "#;
296
297 #[test]
298 fn matching_schemas_pass() {
299 let n = check(SOURCE_OK, CONSUMER_OK).unwrap();
300 assert_eq!(n, 2);
301 }
302
303 #[test]
304 fn missing_entity_is_reported() {
305 let consumer = r#"
306 type MetaBoard {
307 id: Bytes!
308 address: Bytes!
309 nextMetaId: BigInt!
310 }
311 "#;
312 let errs = check(SOURCE_OK, consumer).unwrap_err();
313 assert_eq!(errs.len(), 1);
314 assert!(errs[0].contains("entity `MetaV1` is missing"));
315 }
316
317 #[test]
318 fn missing_field_is_reported() {
319 let consumer = r#"
320 type MetaBoard {
321 id: Bytes!
322 address: Bytes!
323 nextMetaId: BigInt!
324 }
325 type MetaV1 {
326 id: ID!
327 sender: Bytes!
328 }
329 "#;
330 let errs = check(SOURCE_OK, consumer).unwrap_err();
331 assert_eq!(errs.len(), 1);
332 assert!(errs[0].contains("field `MetaV1.subject` is missing"));
333 }
334
335 #[test]
336 fn type_mismatch_is_reported() {
337 let consumer = r#"
338 type MetaBoard {
339 id: Bytes!
340 address: Bytes!
341 nextMetaId: BigInt!
342 }
343 type MetaV1 {
344 id: ID!
345 sender: Bytes!
346 subject: BigInt!
347 }
348 "#;
349 let errs = check(SOURCE_OK, consumer).unwrap_err();
350 assert_eq!(errs.len(), 1);
351 assert!(errs[0].contains("`MetaV1.subject` type mismatch"));
352 assert!(errs[0].contains("source `Bytes!`"));
353 assert!(errs[0].contains("consumer `BigInt!`"));
354 }
355
356 #[test]
357 fn deployed_subgraph_drift_is_caught() {
358 let source = r#"
364 type MetaBoard @entity {
365 id: Bytes!
366 address: Bytes!
367 nextMetaId: BigInt!
368 }
369 type MetaV1 @entity {
370 id: ID!
371 transaction: Transaction!
372 metaBoard: MetaBoard!
373 sender: Bytes!
374 subject: Bytes!
375 metaHash: Bytes!
376 meta: Bytes!
377 }
378 type Transaction @entity(immutable: true) {
379 id: Bytes!
380 timestamp: BigInt!
381 blockNumber: BigInt!
382 from: Bytes!
383 }
384 "#;
385 let consumer = r#"
386 type MetaBoard {
387 id: Bytes!
388 address: Bytes!
389 nextMetaId: BigInt!
390 }
391 type MetaV1 {
392 id: ID!
393 metaBoard: MetaBoard!
394 sender: Bytes!
395 subject: BigInt!
396 metaHash: Bytes!
397 meta: Bytes!
398 }
399 "#;
400 let errs = check(source, consumer).unwrap_err();
401 assert!(errs
402 .iter()
403 .any(|e| e.contains("entity `Transaction` is missing")));
404 assert!(errs
405 .iter()
406 .any(|e| e.contains("field `MetaV1.transaction` is missing")));
407 assert!(errs
408 .iter()
409 .any(|e| e.contains("`MetaV1.subject` type mismatch")));
410 }
411
412 #[test]
413 fn source_with_no_entities_is_an_error() {
414 let source = "scalar Bytes";
417 let consumer = "type Whatever { x: Int }";
418 let errs = check(source, consumer).unwrap_err();
419 assert_eq!(errs.len(), 1);
420 assert!(errs[0].contains("no `@entity` types"));
421 }
422
423 #[test]
424 fn non_object_definitions_in_source_are_ignored() {
425 let source = r#"
428 scalar Bytes
429 enum Direction { ASC DESC }
430 type NotAnEntity {
431 noisefield: Int
432 }
433 type MetaBoard @entity {
434 id: Bytes!
435 }
436 "#;
437 let consumer = r#"
438 type MetaBoard {
439 id: Bytes!
440 }
441 "#;
442 let n = check(source, consumer).unwrap();
443 assert_eq!(n, 1, "only the @entity-tagged type should be verified");
444 }
445
446 #[test]
447 fn entity_directive_with_arguments_is_detected() {
448 let source = r#"
450 type Transaction @entity(immutable: true) {
451 id: Bytes!
452 }
453 "#;
454 let consumer = r#"
455 type Transaction {
456 id: Bytes!
457 }
458 "#;
459 let n = check(source, consumer).unwrap();
460 assert_eq!(n, 1);
461 }
462
463 #[test]
464 fn consumer_extras_are_ignored() {
465 let consumer = r#"
469 type MetaBoard {
470 id: Bytes!
471 address: Bytes!
472 nextMetaId: BigInt!
473 extraField: String
474 }
475 type MetaV1 {
476 id: ID!
477 sender: Bytes!
478 subject: Bytes!
479 }
480 input MetaBoard_filter {
481 id: Bytes
482 }
483 enum MetaBoard_orderBy { id address }
484 "#;
485 let n = check(SOURCE_OK, consumer).unwrap();
486 assert_eq!(n, 2);
487 }
488
489 #[test]
490 fn consumer_field_with_arguments_matches_when_return_type_matches() {
491 let source = r#"
494 type MetaBoard @entity {
495 id: Bytes!
496 metas: [MetaV1!]
497 }
498 type MetaV1 @entity {
499 id: ID!
500 }
501 "#;
502 let consumer = r#"
503 type MetaBoard {
504 id: Bytes!
505 metas(skip: Int = 0, first: Int = 100): [MetaV1!]
506 }
507 type MetaV1 {
508 id: ID!
509 }
510 "#;
511 let n = check(source, consumer).unwrap();
512 assert_eq!(n, 2);
513 }
514
515 #[test]
516 fn nullability_mismatch_is_reported() {
517 let source = r#"
520 type MetaBoard @entity {
521 id: Bytes!
522 }
523 "#;
524 let consumer = r#"
525 type MetaBoard {
526 id: Bytes
527 }
528 "#;
529 let errs = check(source, consumer).unwrap_err();
530 assert_eq!(errs.len(), 1);
531 assert!(errs[0].contains("source `Bytes!`"));
532 assert!(errs[0].contains("consumer `Bytes`"));
533 }
534
535 #[test]
536 fn list_vs_scalar_mismatch_is_reported() {
537 let source = r#"
538 type MetaBoard @entity {
539 metas: [MetaV1!]
540 }
541 type MetaV1 @entity {
542 id: ID!
543 }
544 "#;
545 let consumer = r#"
546 type MetaBoard {
547 metas: MetaV1
548 }
549 type MetaV1 {
550 id: ID!
551 }
552 "#;
553 let errs = check(source, consumer).unwrap_err();
554 assert!(errs
555 .iter()
556 .any(|e| e.contains("`MetaBoard.metas` type mismatch")));
557 assert!(errs.iter().any(|e| e.contains("source `[MetaV1!]`")));
558 }
559
560 #[test]
561 fn nested_wrapper_types_compare_recursively() {
562 let source = r#"
564 type MetaBoard @entity {
565 tags: [Bytes!]!
566 }
567 "#;
568 let ok_consumer = r#"
569 type MetaBoard {
570 tags: [Bytes!]!
571 }
572 "#;
573 let n = check(source, ok_consumer).unwrap();
574 assert_eq!(n, 1);
575
576 let bad_consumer = r#"
577 type MetaBoard {
578 tags: [Bytes!]
579 }
580 "#;
581 let errs = check(source, bad_consumer).unwrap_err();
582 assert_eq!(errs.len(), 1);
583 assert!(errs[0].contains("source `[Bytes!]!`"));
584 assert!(errs[0].contains("consumer `[Bytes!]`"));
585 }
586
587 #[test]
588 fn multiple_errors_are_all_reported() {
589 let source = r#"
591 type MetaBoard @entity {
592 id: Bytes!
593 address: Bytes!
594 nextMetaId: BigInt!
595 }
596 type MetaV1 @entity {
597 id: ID!
598 sender: Bytes!
599 subject: Bytes!
600 }
601 type Transaction @entity {
602 id: Bytes!
603 }
604 "#;
605 let consumer = r#"
606 type MetaBoard {
607 id: Bytes!
608 address: BigInt!
609 }
610 type MetaV1 {
611 id: ID!
612 sender: Bytes!
613 }
614 "#;
615 let errs = check(source, consumer).unwrap_err();
616 assert_eq!(errs.len(), 4, "errors were: {:?}", errs);
619 }
620
621 #[test]
622 fn unparseable_source_is_reported() {
623 let errs = check("type Broken @entity {", CONSUMER_OK).unwrap_err();
624 assert_eq!(errs.len(), 1);
625 assert!(errs[0].starts_with("parse source:"));
626 }
627
628 #[test]
629 fn unparseable_consumer_is_reported() {
630 let errs = check(SOURCE_OK, "type Broken {").unwrap_err();
631 assert_eq!(errs.len(), 1);
632 assert!(errs[0].starts_with("parse consumer:"));
633 }
634
635 #[test]
636 fn consumer_with_no_objects_reports_every_source_entity_missing() {
637 let errs = check(SOURCE_OK, "scalar Whatever").unwrap_err();
640 assert_eq!(errs.len(), 2);
642 assert!(errs
643 .iter()
644 .any(|e| e.contains("entity `MetaBoard` is missing")));
645 assert!(errs
646 .iter()
647 .any(|e| e.contains("entity `MetaV1` is missing")));
648 }
649
650 fn parse(sdl: &str) -> Document<'_, String> {
653 parse_schema(sdl).unwrap()
654 }
655
656 #[test]
657 fn entities_returns_only_entity_directive_objects() {
658 let doc = parse(
659 r#"
660 type WithEntity @entity { id: ID! }
661 type WithEntityArgs @entity(immutable: true) { id: ID! }
662 type Plain { id: ID! }
663 type WithOtherDirective @other { id: ID! }
664 scalar S
665 enum E { A B }
666 "#,
667 );
668 let names: Vec<&str> = entities(&doc).iter().map(|o| o.name.as_str()).collect();
669 assert_eq!(names, vec!["WithEntity", "WithEntityArgs"]);
670 }
671
672 #[test]
673 fn build_field_index_returns_field_maps_keyed_by_object_name() {
674 let doc = parse(
675 r#"
676 type A { x: Int y: String }
677 type B @entity { y: Int }
678 scalar S
679 enum E { X }
680 input I { z: Int }
681 "#,
682 );
683 let m = build_field_index(&doc);
684 let mut names: Vec<&str> = m.keys().copied().collect();
685 names.sort();
686 assert_eq!(names, vec!["A", "B"]);
687 let mut a_fields: Vec<&str> = m["A"].keys().copied().collect();
688 a_fields.sort();
689 assert_eq!(a_fields, vec!["x", "y"]);
690 assert_eq!(m["B"].keys().copied().collect::<Vec<_>>(), vec!["y"]);
691 }
692
693 fn named(s: &str) -> Type<'static, String> {
694 Type::NamedType(s.to_string())
695 }
696 fn nn(t: Type<'static, String>) -> Type<'static, String> {
697 Type::NonNullType(Box::new(t))
698 }
699 fn list(t: Type<'static, String>) -> Type<'static, String> {
700 Type::ListType(Box::new(t))
701 }
702
703 #[test]
704 fn type_equal_named_named() {
705 assert!(type_equal(&named("Bytes"), &named("Bytes")));
706 assert!(!type_equal(&named("Bytes"), &named("BigInt")));
707 }
708
709 #[test]
710 fn type_equal_distinguishes_wrappers() {
711 assert!(!type_equal(&named("Bytes"), &nn(named("Bytes"))));
712 assert!(!type_equal(&named("Bytes"), &list(named("Bytes"))));
713 assert!(!type_equal(&nn(named("Bytes")), &list(named("Bytes"))));
714 }
715
716 #[test]
717 fn type_equal_recurses_through_nested_wrappers() {
718 let a = nn(list(nn(named("Bytes"))));
719 let b = nn(list(nn(named("Bytes"))));
720 assert!(type_equal(&a, &b));
721 let c = nn(list(named("Bytes")));
722 assert!(!type_equal(&a, &c));
723 }
724
725 #[test]
726 fn type_to_string_renders_sdl_syntax() {
727 assert_eq!(type_to_string(&named("Bytes")), "Bytes");
728 assert_eq!(type_to_string(&nn(named("Bytes"))), "Bytes!");
729 assert_eq!(type_to_string(&list(named("X"))), "[X]");
730 assert_eq!(type_to_string(&nn(list(nn(named("X"))))), "[X!]!");
731 }
732
733 #[test]
734 fn is_entity_object_skips_derivative_and_internal_types() {
735 assert!(is_entity_object("MetaBoard"));
736 assert!(is_entity_object("Transaction"));
737 assert!(!is_entity_object(""));
738 assert!(!is_entity_object("_Meta_"));
739 assert!(!is_entity_object("Query"));
740 assert!(!is_entity_object("Subscription"));
741 assert!(!is_entity_object("MetaV1_filter"));
742 assert!(!is_entity_object("MetaV1_orderBy"));
743 }
744
745 #[test]
746 fn render_type_unwraps_introspection_typeref_recursively() {
747 let nested = serde_json::json!({
749 "kind": "NON_NULL",
750 "name": null,
751 "ofType": {
752 "kind": "LIST",
753 "name": null,
754 "ofType": {
755 "kind": "NON_NULL",
756 "name": null,
757 "ofType": { "kind": "SCALAR", "name": "Bytes", "ofType": null }
758 }
759 }
760 });
761 assert_eq!(render_type(&nested), "[Bytes!]!");
762 }
763
764 #[test]
765 fn render_type_handles_plain_named_type() {
766 let scalar = serde_json::json!({ "kind": "SCALAR", "name": "BigInt", "ofType": null });
767 assert_eq!(render_type(&scalar), "BigInt");
768 }
769
770 #[test]
771 fn render_type_falls_back_to_unknown_for_missing_name() {
772 let bad = serde_json::json!({ "kind": "SCALAR", "name": null, "ofType": null });
773 assert_eq!(render_type(&bad), "Unknown");
774 }
775
776 #[tokio::test]
779 async fn fetch_live_entities_filters_to_entity_object_types() {
780 use httpmock::Method::POST;
781 use httpmock::MockServer;
782
783 let server = MockServer::start_async().await;
784 let _mock = server
785 .mock_async(|when, then| {
786 when.method(POST).path("/");
787 then.status(200).json_body(serde_json::json!({
788 "data": { "__schema": { "types": [
789 { "kind": "OBJECT", "name": "MetaBoard", "fields": [
790 { "name": "id", "type": { "kind": "NON_NULL", "name": null,
791 "ofType": { "kind": "SCALAR", "name": "Bytes", "ofType": null } } }
792 ] },
793 { "kind": "OBJECT", "name": "MetaV1_filter", "fields": [
794 { "name": "id", "type": { "kind": "SCALAR", "name": "ID", "ofType": null } }
795 ] },
796 { "kind": "OBJECT", "name": "Query", "fields": [] },
797 { "kind": "OBJECT", "name": "_Meta_", "fields": [] },
798 { "kind": "SCALAR", "name": "Bytes", "fields": null }
799 ] } }
800 }));
801 })
802 .await;
803
804 let sdl = fetch_live_entities_as_sdl(&server.url("/")).await.unwrap();
805 assert!(sdl.contains("type MetaBoard @entity"));
806 assert!(sdl.contains("id: Bytes!"));
807 assert!(!sdl.contains("MetaV1_filter"));
808 assert!(!sdl.contains("Query"));
809 assert!(!sdl.contains("_Meta_"));
810 }
811
812 #[tokio::test]
813 async fn fetch_live_entities_propagates_graphql_errors() {
814 use httpmock::Method::POST;
815 use httpmock::MockServer;
816
817 let server = MockServer::start_async().await;
818 let _mock = server
819 .mock_async(|when, then| {
820 when.method(POST).path("/");
821 then.status(200).json_body(serde_json::json!({
822 "errors": [{ "message": "introspection disabled" }]
823 }));
824 })
825 .await;
826
827 let err = fetch_live_entities_as_sdl(&server.url("/"))
828 .await
829 .unwrap_err();
830 assert!(err.to_string().contains("introspection errors"));
831 assert!(err.to_string().contains("introspection disabled"));
832 }
833
834 #[tokio::test]
835 async fn fetch_live_entities_errors_on_malformed_response() {
836 use httpmock::Method::POST;
837 use httpmock::MockServer;
838
839 let server = MockServer::start_async().await;
840 let _mock = server
841 .mock_async(|when, then| {
842 when.method(POST).path("/");
843 then.status(200)
844 .json_body(serde_json::json!({ "data": {} }));
845 })
846 .await;
847
848 let err = fetch_live_entities_as_sdl(&server.url("/"))
849 .await
850 .unwrap_err();
851 assert!(err.to_string().contains("missing /data/__schema/types"));
852 }
853
854 #[tokio::test]
855 async fn fetch_live_entities_errors_on_http_failure() {
856 use httpmock::Method::POST;
857 use httpmock::MockServer;
858
859 let server = MockServer::start_async().await;
860 let _mock = server
861 .mock_async(|when, then| {
862 when.method(POST).path("/");
863 then.status(500);
864 })
865 .await;
866
867 let err = fetch_live_entities_as_sdl(&server.url("/"))
868 .await
869 .unwrap_err();
870 assert!(err.to_string().contains("500"));
872 }
873
874 #[tokio::test]
877 async fn schema_check_reads_files_and_succeeds_on_match() {
878 use std::io::Write;
879 let mut src = tempfile::NamedTempFile::new().unwrap();
880 src.write_all(SOURCE_OK.as_bytes()).unwrap();
881 let mut con = tempfile::NamedTempFile::new().unwrap();
882 con.write_all(CONSUMER_OK.as_bytes()).unwrap();
883
884 schema_check(SchemaCheck {
885 source: Some(src.path().into()),
886 live_url: None,
887 consumer: con.path().into(),
888 })
889 .await
890 .unwrap();
891 }
892
893 #[tokio::test]
894 async fn schema_check_rejects_neither_source_nor_live_url() {
895 let mut con = tempfile::NamedTempFile::new().unwrap();
896 std::io::Write::write_all(&mut con, CONSUMER_OK.as_bytes()).unwrap();
897
898 let err = schema_check(SchemaCheck {
899 source: None,
900 live_url: None,
901 consumer: con.path().into(),
902 })
903 .await
904 .unwrap_err();
905 assert!(err
906 .to_string()
907 .contains("exactly one of --source or --live-url"));
908 }
909
910 #[tokio::test]
911 async fn schema_check_failure_includes_live_sdl_in_error() {
912 use httpmock::Method::POST;
913 use httpmock::MockServer;
914 use std::io::Write;
915
916 let server = MockServer::start_async().await;
919 let _mock = server
920 .mock_async(|when, then| {
921 when.method(POST).path("/");
922 then.status(200).json_body(serde_json::json!({
923 "data": { "__schema": { "types": [
924 { "kind": "OBJECT", "name": "MetaBoard", "fields": [
925 { "name": "id", "type": { "kind": "NON_NULL", "name": null,
926 "ofType": { "kind": "SCALAR", "name": "Bytes", "ofType": null } } }
927 ] }
928 ] } }
929 }));
930 })
931 .await;
932
933 let mut con = tempfile::NamedTempFile::new().unwrap();
934 con.write_all(b"scalar X").unwrap();
935
936 let err = schema_check(SchemaCheck {
937 source: None,
938 live_url: Some(server.url("/")),
939 consumer: con.path().into(),
940 })
941 .await
942 .unwrap_err();
943 let msg = err.to_string();
944 assert!(msg.contains("entity `MetaBoard` is missing"));
945 assert!(msg.contains("Live introspection-derived entity SDL"));
946 assert!(msg.contains("type MetaBoard @entity"));
947 }
948}