Skip to main content

rain_metadata/cli/
schema_check.rs

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/// Compare entity types in a subgraph schema against a consumer's snapshot
18/// of the deployed introspection schema. Used in deploy CI to fail early
19/// when the consumer's snapshot has drifted from what is about to be
20/// deployed.
21#[derive(Parser)]
22pub struct SchemaCheck {
23    /// Path to the source subgraph schema (with `@entity` directives).
24    /// Mutually exclusive with --live-url.
25    #[arg(short, long, conflicts_with = "live_url")]
26    pub source: Option<PathBuf>,
27    /// URL of a live deployed subgraph endpoint. Introspection is fetched
28    /// and used as the source of truth in place of --source. The full
29    /// introspection-derived SDL of the entity types is printed on
30    /// failure so the consumer snapshot can be regenerated.
31    #[arg(short, long, conflicts_with = "source")]
32    pub live_url: Option<String>,
33    /// Path to the consumer's snapshot of the deployed introspection schema.
34    #[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
76/// POST a GraphQL introspection query to `url` and reduce the response to
77/// a synthetic SDL document containing only entity Object types and their
78/// fields. The synthetic SDL re-tags each type with `@entity` so the
79/// existing `entities` filter picks them up.
80async fn fetch_live_entities_as_sdl(url: &str) -> anyhow::Result<String> {
81    // Bound the request so a slow or hung Goldsky endpoint can't wedge
82    // the deploy job indefinitely. reqwest's wasm impl uses the browser
83    // fetch API and doesn't expose ClientBuilder timing methods, so the
84    // bound is native-only. The CLI binary never runs under wasm.
85    #[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
130/// Filter for "entity-shaped" Object types in a Graph Protocol introspection
131/// response: skip auto-generated derivative types (filter, orderBy, Query,
132/// Subscription, _Meta_, _Block_, etc.) and any name with a leading underscore.
133fn 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
142/// Render an introspection type-ref into SDL syntax (`Bytes!`, `[Foo!]!`).
143fn 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
230/// Build a name → (field-name → field) lookup over every Object type in
231/// the document, so the per-entity field map isn't reconstructed inside
232/// the comparison loop.
233fn 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        // Mirrors the actual divergence between subgraph/schema.graphql
359        // (source) and crates/metaboard/src/schema/metaboard.graphql
360        // (consumer) at the time this subcommand was added: missing
361        // `Transaction` entity and `MetaV1.transaction`, plus `subject`
362        // type mismatch (Bytes! vs BigInt!).
363        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        // Silently passing with 0 entities verified would mask a
415        // misconfigured --source path or a non-subgraph SDL in CI.
416        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        // Enums, scalars, and Object types without `@entity` must not be
426        // treated as entities to check.
427        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        // `@entity(immutable: true)` must still be picked up.
449        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        // The consumer schema is the introspected GraphQL service surface
466        // and contains derivative types (filters, orderBy) plus extra
467        // fields with arguments. Those must not cause errors.
468        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        // Introspected derived fields look like `metas(skip: Int = 0, ...): [MetaV1!]`.
492        // We compare only the return type, not the args, so this must pass.
493        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        // `Bytes` (nullable) vs `Bytes!` (non-null) are distinct types
518        // and must be flagged.
519        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        // `[Bytes!]!` must match `[Bytes!]!` exactly and differ from `[Bytes!]`.
563        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        // One run should surface every problem, not stop at the first.
590        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        // MetaBoard.address mismatch + MetaBoard.nextMetaId missing
617        // + MetaV1.subject missing + Transaction entity missing = 4
618        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        // Use a valid-but-Object-free schema (graphql-parser rejects fully
638        // empty input as a parse error, which is its own test case).
639        let errs = check(SOURCE_OK, "scalar Whatever").unwrap_err();
640        // SOURCE_OK has 2 entities (MetaBoard, MetaV1).
641        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    // ---------- helper-function unit tests ----------
651
652    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        // NON_NULL[LIST[NON_NULL[Bytes]]] → "[Bytes!]!"
748        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    // ---------- live-URL HTTP path ----------
777
778    #[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        // reqwest's error_for_status produces "500 Internal Server Error" text.
871        assert!(err.to_string().contains("500"));
872    }
873
874    // ---------- end-to-end CLI handler ----------
875
876    #[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        // Live introspection returns a single MetaBoard entity; consumer
917        // file omits it, so the error should include the live-derived SDL.
918        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}