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 once_cell::sync::Lazy;
6use std::collections::BTreeMap;
7use std::path::PathBuf;
8
9/// Type-ref levels the introspection query expands. GraphQL puts no bound on
10/// wrapper chains, so every depth truncates something; 8 is the depth the
11/// reference introspection query settled on.
12const TYPE_REF_DEPTH: usize = 8;
13
14static INTROSPECTION_QUERY: Lazy<String> = Lazy::new(|| {
15    let mut type_ref = "kind name".to_string();
16    for _ in 1..TYPE_REF_DEPTH {
17        type_ref = format!("kind name ofType {{ {type_ref} }}");
18    }
19    format!(
20        "{{ __schema {{ types {{ kind name fields(includeDeprecated: true) \
21         {{ name type {{ {type_ref} }} }} }} }} }}"
22    )
23});
24
25/// Compare entity types in a subgraph schema against a consumer's snapshot
26/// of the deployed introspection schema. Used in deploy CI to fail early
27/// when the consumer's snapshot has drifted from what is about to be
28/// deployed.
29#[derive(Parser)]
30pub struct SchemaCheck {
31    /// Path to the source subgraph schema (with `@entity` directives).
32    /// Mutually exclusive with --live-url.
33    #[arg(short, long, conflicts_with = "live_url")]
34    pub source: Option<PathBuf>,
35    /// URL of a live deployed subgraph endpoint. Introspection is fetched
36    /// and used as the source of truth in place of --source. The full
37    /// introspection-derived SDL of the entity types is printed on
38    /// failure so the consumer snapshot can be regenerated.
39    #[arg(short, long, conflicts_with = "source")]
40    pub live_url: Option<String>,
41    /// Path to the consumer's snapshot of the deployed introspection schema.
42    #[arg(short, long)]
43    pub consumer: PathBuf,
44}
45
46pub async fn schema_check(cmd: SchemaCheck) -> anyhow::Result<()> {
47    let consumer_sdl = std::fs::read_to_string(&cmd.consumer)?;
48
49    let (source_sdl, source_label) = match (&cmd.source, &cmd.live_url) {
50        (Some(path), None) => (std::fs::read_to_string(path)?, "source".to_string()),
51        (None, Some(url)) => {
52            let sdl = fetch_live_entities_as_sdl(url).await?;
53            (sdl, format!("live ({url})"))
54        }
55        _ => {
56            return Err(anyhow::anyhow!(
57                "exactly one of --source or --live-url must be provided"
58            ));
59        }
60    };
61
62    match check(&source_sdl, &consumer_sdl) {
63        Ok(count) => {
64            println!("schema check ok: {count} entities verified against {source_label}");
65            Ok(())
66        }
67        Err(errors) => {
68            let mut msg = format!("schema check failed with {} mismatches:", errors.len());
69            for e in &errors {
70                msg.push_str("\n  - ");
71                msg.push_str(e);
72            }
73            if cmd.live_url.is_some() {
74                msg.push_str(
75                    "\n\nLive introspection-derived entity SDL (copy into consumer file):\n",
76                );
77                msg.push_str(&source_sdl);
78            }
79            Err(anyhow::anyhow!(msg))
80        }
81    }
82}
83
84/// POST a GraphQL introspection query to `url` and reduce the response to
85/// a synthetic SDL document containing only entity Object types and their
86/// fields. The synthetic SDL re-tags each type with `@entity` so the
87/// existing `entities` filter picks them up.
88async fn fetch_live_entities_as_sdl(url: &str) -> anyhow::Result<String> {
89    // Bound the request so a slow or hung Goldsky endpoint can't wedge
90    // the deploy job indefinitely. reqwest's wasm impl uses the browser
91    // fetch API and doesn't expose ClientBuilder timing methods, so the
92    // bound is native-only. The CLI binary never runs under wasm.
93    #[cfg(not(target_family = "wasm"))]
94    let client = reqwest::Client::builder()
95        .connect_timeout(std::time::Duration::from_secs(10))
96        .timeout(std::time::Duration::from_secs(30))
97        .build()?;
98    #[cfg(target_family = "wasm")]
99    let client = reqwest::Client::new();
100
101    let body = serde_json::json!({ "query": INTROSPECTION_QUERY.as_str() });
102    let resp: serde_json::Value = client
103        .post(url)
104        .json(&body)
105        .send()
106        .await?
107        .error_for_status()?
108        .json()
109        .await?;
110    if let Some(errors) = resp.get("errors") {
111        return Err(anyhow::anyhow!("introspection errors: {errors}"));
112    }
113    let types = resp
114        .pointer("/data/__schema/types")
115        .and_then(|t| t.as_array())
116        .ok_or_else(|| anyhow::anyhow!("introspection response missing /data/__schema/types"))?;
117
118    let mut sdl = String::new();
119    for t in types {
120        let kind = t.get("kind").and_then(|v| v.as_str()).unwrap_or("");
121        let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("");
122        if kind != "OBJECT" || !is_entity_object(name) {
123            continue;
124        }
125        sdl.push_str(&format!("type {name} @entity {{\n"));
126        if let Some(fields) = t.get("fields").and_then(|f| f.as_array()) {
127            for f in fields {
128                let fname = f.get("name").and_then(|v| v.as_str()).unwrap_or("");
129                let ftype = render_type(f.get("type").unwrap_or(&serde_json::Value::Null))
130                    .map_err(|e| anyhow::anyhow!("field `{name}.{fname}`: {e}"))?;
131                sdl.push_str(&format!("  {fname}: {ftype}\n"));
132            }
133        }
134        sdl.push_str("}\n\n");
135    }
136    Ok(sdl)
137}
138
139/// Filter for "entity-shaped" Object types in a Graph Protocol introspection
140/// response: skip auto-generated derivative types (filter, orderBy, Query,
141/// Subscription, _Meta_, _Block_, etc.) and any name with a leading underscore.
142fn is_entity_object(name: &str) -> bool {
143    !name.is_empty()
144        && !name.starts_with('_')
145        && name != "Query"
146        && name != "Subscription"
147        && !name.ends_with("_filter")
148        && !name.ends_with("_orderBy")
149}
150
151/// Render an introspection type-ref into SDL syntax (`Bytes!`, `[Foo!]!`).
152/// A ref the response does not carry in full is an error, not a placeholder:
153/// a placeholder is compared against the consumer snapshot as if it were the
154/// deployed type.
155fn render_type(t: &serde_json::Value) -> anyhow::Result<String> {
156    let kind = t.get("kind").and_then(|v| v.as_str()).unwrap_or("");
157    match kind {
158        "NON_NULL" => Ok(format!("{}!", render_type(of_type(t, kind)?)?)),
159        "LIST" => Ok(format!("[{}]", render_type(of_type(t, kind)?)?)),
160        _ => t
161            .get("name")
162            .and_then(|v| v.as_str())
163            .map(str::to_string)
164            .ok_or_else(|| anyhow::anyhow!("type-ref of kind `{kind}` has no name")),
165    }
166}
167
168fn of_type<'a>(t: &'a serde_json::Value, kind: &str) -> anyhow::Result<&'a serde_json::Value> {
169    t.get("ofType").filter(|v| !v.is_null()).ok_or_else(|| {
170        anyhow::anyhow!(
171            "`{kind}` wrapper has no `ofType`: the type nests deeper than the \
172             {TYPE_REF_DEPTH} levels the introspection query resolves"
173        )
174    })
175}
176
177fn check(source_sdl: &str, consumer_sdl: &str) -> Result<usize, Vec<String>> {
178    let source_doc: Document<String> =
179        parse_schema(source_sdl).map_err(|e| vec![format!("parse source: {e}")])?;
180    let consumer_doc: Document<String> =
181        parse_schema(consumer_sdl).map_err(|e| vec![format!("parse consumer: {e}")])?;
182
183    let source_entities = entities(&source_doc);
184    let consumer_field_index = build_field_index(&consumer_doc);
185
186    if source_entities.is_empty() {
187        return Err(vec![
188            "source schema has no `@entity` types; check that --source/--live-url \
189             points at a subgraph SDL or live introspection endpoint"
190                .to_string(),
191        ]);
192    }
193
194    let mut errors = Vec::new();
195
196    for entity in &source_entities {
197        match consumer_field_index.get(entity.name.as_str()) {
198            None => errors.push(format!(
199                "entity `{}` is missing from consumer schema",
200                entity.name
201            )),
202            Some(consumer_fields) => {
203                for field in &entity.fields {
204                    match consumer_fields.get(field.name.as_str()) {
205                        None => errors.push(format!(
206                            "field `{}.{}` is missing from consumer schema",
207                            entity.name, field.name
208                        )),
209                        Some(consumer_field) => {
210                            if !type_equal(&field.field_type, &consumer_field.field_type) {
211                                errors.push(format!(
212                                    "field `{}.{}` type mismatch: source `{}` vs consumer `{}`",
213                                    entity.name,
214                                    field.name,
215                                    type_to_string(&field.field_type),
216                                    type_to_string(&consumer_field.field_type),
217                                ));
218                            }
219                        }
220                    }
221                }
222            }
223        }
224    }
225
226    if errors.is_empty() {
227        Ok(source_entities.len())
228    } else {
229        Err(errors)
230    }
231}
232
233fn entities<'a>(doc: &'a Document<'a, String>) -> Vec<&'a ObjectType<'a, String>> {
234    doc.definitions
235        .iter()
236        .filter_map(|def| {
237            if let Definition::TypeDefinition(TypeDefinition::Object(obj)) = def {
238                if obj.directives.iter().any(|d| d.name == "entity") {
239                    return Some(obj);
240                }
241            }
242            None
243        })
244        .collect()
245}
246
247/// Build a name → (field-name → field) lookup over every Object type in
248/// the document, so the per-entity field map isn't reconstructed inside
249/// the comparison loop.
250fn build_field_index<'a>(
251    doc: &'a Document<'a, String>,
252) -> BTreeMap<&'a str, BTreeMap<&'a str, &'a Field<'a, String>>> {
253    doc.definitions
254        .iter()
255        .filter_map(|def| {
256            if let Definition::TypeDefinition(TypeDefinition::Object(obj)) = def {
257                let fields: BTreeMap<&str, &Field<'_, String>> =
258                    obj.fields.iter().map(|f| (f.name.as_str(), f)).collect();
259                Some((obj.name.as_str(), fields))
260            } else {
261                None
262            }
263        })
264        .collect()
265}
266
267fn type_equal(a: &Type<'_, String>, b: &Type<'_, String>) -> bool {
268    match (a, b) {
269        (Type::NamedType(an), Type::NamedType(bn)) => an == bn,
270        (Type::ListType(ai), Type::ListType(bi)) => type_equal(ai, bi),
271        (Type::NonNullType(ai), Type::NonNullType(bi)) => type_equal(ai, bi),
272        _ => false,
273    }
274}
275
276fn type_to_string(t: &Type<'_, String>) -> String {
277    match t {
278        Type::NamedType(n) => n.clone(),
279        Type::ListType(inner) => format!("[{}]", type_to_string(inner)),
280        Type::NonNullType(inner) => format!("{}!", type_to_string(inner)),
281    }
282}
283
284#[cfg(all(test, not(target_family = "wasm")))]
285mod tests {
286    use super::*;
287
288    const SOURCE_OK: &str = r#"
289        type MetaBoard @entity {
290            id: Bytes!
291            address: Bytes!
292            nextMetaId: BigInt!
293        }
294        type MetaV1 @entity {
295            id: ID!
296            sender: Bytes!
297            subject: Bytes!
298        }
299    "#;
300
301    const CONSUMER_OK: &str = r#"
302        type MetaBoard {
303          id: Bytes!
304          address: Bytes!
305          nextMetaId: BigInt!
306        }
307        type MetaV1 {
308          id: ID!
309          sender: Bytes!
310          subject: Bytes!
311        }
312    "#;
313
314    #[test]
315    fn matching_schemas_pass() {
316        let n = check(SOURCE_OK, CONSUMER_OK).unwrap();
317        assert_eq!(n, 2);
318    }
319
320    #[test]
321    fn missing_entity_is_reported() {
322        let consumer = r#"
323            type MetaBoard {
324              id: Bytes!
325              address: Bytes!
326              nextMetaId: BigInt!
327            }
328        "#;
329        let errs = check(SOURCE_OK, consumer).unwrap_err();
330        assert_eq!(errs.len(), 1);
331        assert!(errs[0].contains("entity `MetaV1` is missing"));
332    }
333
334    #[test]
335    fn missing_field_is_reported() {
336        let consumer = r#"
337            type MetaBoard {
338              id: Bytes!
339              address: Bytes!
340              nextMetaId: BigInt!
341            }
342            type MetaV1 {
343              id: ID!
344              sender: Bytes!
345            }
346        "#;
347        let errs = check(SOURCE_OK, consumer).unwrap_err();
348        assert_eq!(errs.len(), 1);
349        assert!(errs[0].contains("field `MetaV1.subject` is missing"));
350    }
351
352    #[test]
353    fn type_mismatch_is_reported() {
354        let consumer = r#"
355            type MetaBoard {
356              id: Bytes!
357              address: Bytes!
358              nextMetaId: BigInt!
359            }
360            type MetaV1 {
361              id: ID!
362              sender: Bytes!
363              subject: BigInt!
364            }
365        "#;
366        let errs = check(SOURCE_OK, consumer).unwrap_err();
367        assert_eq!(errs.len(), 1);
368        assert!(errs[0].contains("`MetaV1.subject` type mismatch"));
369        assert!(errs[0].contains("source `Bytes!`"));
370        assert!(errs[0].contains("consumer `BigInt!`"));
371    }
372
373    #[test]
374    fn deployed_subgraph_drift_is_caught() {
375        // Mirrors the actual divergence between subgraph/schema.graphql
376        // (source) and crates/metaboard/src/schema/metaboard.graphql
377        // (consumer) at the time this subcommand was added: missing
378        // `Transaction` entity and `MetaV1.transaction`, plus `subject`
379        // type mismatch (Bytes! vs BigInt!).
380        let source = r#"
381            type MetaBoard @entity {
382                id: Bytes!
383                address: Bytes!
384                nextMetaId: BigInt!
385            }
386            type MetaV1 @entity {
387                id: ID!
388                transaction: Transaction!
389                metaBoard: MetaBoard!
390                sender: Bytes!
391                subject: Bytes!
392                metaHash: Bytes!
393                meta: Bytes!
394            }
395            type Transaction @entity(immutable: true) {
396                id: Bytes!
397                timestamp: BigInt!
398                blockNumber: BigInt!
399                from: Bytes!
400            }
401        "#;
402        let consumer = r#"
403            type MetaBoard {
404              id: Bytes!
405              address: Bytes!
406              nextMetaId: BigInt!
407            }
408            type MetaV1 {
409              id: ID!
410              metaBoard: MetaBoard!
411              sender: Bytes!
412              subject: BigInt!
413              metaHash: Bytes!
414              meta: Bytes!
415            }
416        "#;
417        let errs = check(source, consumer).unwrap_err();
418        assert!(errs
419            .iter()
420            .any(|e| e.contains("entity `Transaction` is missing")));
421        assert!(errs
422            .iter()
423            .any(|e| e.contains("field `MetaV1.transaction` is missing")));
424        assert!(errs
425            .iter()
426            .any(|e| e.contains("`MetaV1.subject` type mismatch")));
427    }
428
429    #[test]
430    fn source_with_no_entities_is_an_error() {
431        // Silently passing with 0 entities verified would mask a
432        // misconfigured --source path or a non-subgraph SDL in CI.
433        let source = "scalar Bytes";
434        let consumer = "type Whatever { x: Int }";
435        let errs = check(source, consumer).unwrap_err();
436        assert_eq!(errs.len(), 1);
437        assert!(errs[0].contains("no `@entity` types"));
438    }
439
440    #[test]
441    fn non_object_definitions_in_source_are_ignored() {
442        // Enums, scalars, and Object types without `@entity` must not be
443        // treated as entities to check.
444        let source = r#"
445            scalar Bytes
446            enum Direction { ASC DESC }
447            type NotAnEntity {
448                noisefield: Int
449            }
450            type MetaBoard @entity {
451                id: Bytes!
452            }
453        "#;
454        let consumer = r#"
455            type MetaBoard {
456              id: Bytes!
457            }
458        "#;
459        let n = check(source, consumer).unwrap();
460        assert_eq!(n, 1, "only the @entity-tagged type should be verified");
461    }
462
463    #[test]
464    fn entity_directive_with_arguments_is_detected() {
465        // `@entity(immutable: true)` must still be picked up.
466        let source = r#"
467            type Transaction @entity(immutable: true) {
468                id: Bytes!
469            }
470        "#;
471        let consumer = r#"
472            type Transaction {
473              id: Bytes!
474            }
475        "#;
476        let n = check(source, consumer).unwrap();
477        assert_eq!(n, 1);
478    }
479
480    #[test]
481    fn consumer_extras_are_ignored() {
482        // The consumer schema is the introspected GraphQL service surface
483        // and contains derivative types (filters, orderBy) plus extra
484        // fields with arguments. Those must not cause errors.
485        let consumer = r#"
486            type MetaBoard {
487              id: Bytes!
488              address: Bytes!
489              nextMetaId: BigInt!
490              extraField: String
491            }
492            type MetaV1 {
493              id: ID!
494              sender: Bytes!
495              subject: Bytes!
496            }
497            input MetaBoard_filter {
498              id: Bytes
499            }
500            enum MetaBoard_orderBy { id address }
501        "#;
502        let n = check(SOURCE_OK, consumer).unwrap();
503        assert_eq!(n, 2);
504    }
505
506    #[test]
507    fn consumer_field_with_arguments_matches_when_return_type_matches() {
508        // Introspected derived fields look like `metas(skip: Int = 0, ...): [MetaV1!]`.
509        // We compare only the return type, not the args, so this must pass.
510        let source = r#"
511            type MetaBoard @entity {
512                id: Bytes!
513                metas: [MetaV1!]
514            }
515            type MetaV1 @entity {
516                id: ID!
517            }
518        "#;
519        let consumer = r#"
520            type MetaBoard {
521              id: Bytes!
522              metas(skip: Int = 0, first: Int = 100): [MetaV1!]
523            }
524            type MetaV1 {
525              id: ID!
526            }
527        "#;
528        let n = check(source, consumer).unwrap();
529        assert_eq!(n, 2);
530    }
531
532    #[test]
533    fn nullability_mismatch_is_reported() {
534        // `Bytes` (nullable) vs `Bytes!` (non-null) are distinct types
535        // and must be flagged.
536        let source = r#"
537            type MetaBoard @entity {
538                id: Bytes!
539            }
540        "#;
541        let consumer = r#"
542            type MetaBoard {
543              id: Bytes
544            }
545        "#;
546        let errs = check(source, consumer).unwrap_err();
547        assert_eq!(errs.len(), 1);
548        assert!(errs[0].contains("source `Bytes!`"));
549        assert!(errs[0].contains("consumer `Bytes`"));
550    }
551
552    #[test]
553    fn list_vs_scalar_mismatch_is_reported() {
554        let source = r#"
555            type MetaBoard @entity {
556                metas: [MetaV1!]
557            }
558            type MetaV1 @entity {
559                id: ID!
560            }
561        "#;
562        let consumer = r#"
563            type MetaBoard {
564              metas: MetaV1
565            }
566            type MetaV1 {
567              id: ID!
568            }
569        "#;
570        let errs = check(source, consumer).unwrap_err();
571        assert!(errs
572            .iter()
573            .any(|e| e.contains("`MetaBoard.metas` type mismatch")));
574        assert!(errs.iter().any(|e| e.contains("source `[MetaV1!]`")));
575    }
576
577    #[test]
578    fn nested_wrapper_types_compare_recursively() {
579        // `[Bytes!]!` must match `[Bytes!]!` exactly and differ from `[Bytes!]`.
580        let source = r#"
581            type MetaBoard @entity {
582                tags: [Bytes!]!
583            }
584        "#;
585        let ok_consumer = r#"
586            type MetaBoard {
587              tags: [Bytes!]!
588            }
589        "#;
590        let n = check(source, ok_consumer).unwrap();
591        assert_eq!(n, 1);
592
593        let bad_consumer = r#"
594            type MetaBoard {
595              tags: [Bytes!]
596            }
597        "#;
598        let errs = check(source, bad_consumer).unwrap_err();
599        assert_eq!(errs.len(), 1);
600        assert!(errs[0].contains("source `[Bytes!]!`"));
601        assert!(errs[0].contains("consumer `[Bytes!]`"));
602    }
603
604    #[test]
605    fn multiple_errors_are_all_reported() {
606        // One run should surface every problem, not stop at the first.
607        let source = r#"
608            type MetaBoard @entity {
609                id: Bytes!
610                address: Bytes!
611                nextMetaId: BigInt!
612            }
613            type MetaV1 @entity {
614                id: ID!
615                sender: Bytes!
616                subject: Bytes!
617            }
618            type Transaction @entity {
619                id: Bytes!
620            }
621        "#;
622        let consumer = r#"
623            type MetaBoard {
624              id: Bytes!
625              address: BigInt!
626            }
627            type MetaV1 {
628              id: ID!
629              sender: Bytes!
630            }
631        "#;
632        let errs = check(source, consumer).unwrap_err();
633        // MetaBoard.address mismatch + MetaBoard.nextMetaId missing
634        // + MetaV1.subject missing + Transaction entity missing = 4
635        assert_eq!(errs.len(), 4, "errors were: {:?}", errs);
636    }
637
638    #[test]
639    fn unparseable_source_is_reported() {
640        let errs = check("type Broken @entity {", CONSUMER_OK).unwrap_err();
641        assert_eq!(errs.len(), 1);
642        assert!(errs[0].starts_with("parse source:"));
643    }
644
645    #[test]
646    fn unparseable_consumer_is_reported() {
647        let errs = check(SOURCE_OK, "type Broken {").unwrap_err();
648        assert_eq!(errs.len(), 1);
649        assert!(errs[0].starts_with("parse consumer:"));
650    }
651
652    #[test]
653    fn consumer_with_no_objects_reports_every_source_entity_missing() {
654        // Use a valid-but-Object-free schema (graphql-parser rejects fully
655        // empty input as a parse error, which is its own test case).
656        let errs = check(SOURCE_OK, "scalar Whatever").unwrap_err();
657        // SOURCE_OK has 2 entities (MetaBoard, MetaV1).
658        assert_eq!(errs.len(), 2);
659        assert!(errs
660            .iter()
661            .any(|e| e.contains("entity `MetaBoard` is missing")));
662        assert!(errs
663            .iter()
664            .any(|e| e.contains("entity `MetaV1` is missing")));
665    }
666
667    // ---------- helper-function unit tests ----------
668
669    fn parse(sdl: &str) -> Document<'_, String> {
670        parse_schema(sdl).unwrap()
671    }
672
673    #[test]
674    fn entities_returns_only_entity_directive_objects() {
675        let doc = parse(
676            r#"
677            type WithEntity @entity { id: ID! }
678            type WithEntityArgs @entity(immutable: true) { id: ID! }
679            type Plain { id: ID! }
680            type WithOtherDirective @other { id: ID! }
681            scalar S
682            enum E { A B }
683            "#,
684        );
685        let names: Vec<&str> = entities(&doc).iter().map(|o| o.name.as_str()).collect();
686        assert_eq!(names, vec!["WithEntity", "WithEntityArgs"]);
687    }
688
689    #[test]
690    fn build_field_index_returns_field_maps_keyed_by_object_name() {
691        let doc = parse(
692            r#"
693            type A { x: Int y: String }
694            type B @entity { y: Int }
695            scalar S
696            enum E { X }
697            input I { z: Int }
698            "#,
699        );
700        let m = build_field_index(&doc);
701        let mut names: Vec<&str> = m.keys().copied().collect();
702        names.sort();
703        assert_eq!(names, vec!["A", "B"]);
704        let mut a_fields: Vec<&str> = m["A"].keys().copied().collect();
705        a_fields.sort();
706        assert_eq!(a_fields, vec!["x", "y"]);
707        assert_eq!(m["B"].keys().copied().collect::<Vec<_>>(), vec!["y"]);
708    }
709
710    fn named(s: &str) -> Type<'static, String> {
711        Type::NamedType(s.to_string())
712    }
713    fn nn(t: Type<'static, String>) -> Type<'static, String> {
714        Type::NonNullType(Box::new(t))
715    }
716    fn list(t: Type<'static, String>) -> Type<'static, String> {
717        Type::ListType(Box::new(t))
718    }
719
720    #[test]
721    fn type_equal_named_named() {
722        assert!(type_equal(&named("Bytes"), &named("Bytes")));
723        assert!(!type_equal(&named("Bytes"), &named("BigInt")));
724    }
725
726    #[test]
727    fn type_equal_distinguishes_wrappers() {
728        assert!(!type_equal(&named("Bytes"), &nn(named("Bytes"))));
729        assert!(!type_equal(&named("Bytes"), &list(named("Bytes"))));
730        assert!(!type_equal(&nn(named("Bytes")), &list(named("Bytes"))));
731    }
732
733    #[test]
734    fn type_equal_recurses_through_nested_wrappers() {
735        let a = nn(list(nn(named("Bytes"))));
736        let b = nn(list(nn(named("Bytes"))));
737        assert!(type_equal(&a, &b));
738        let c = nn(list(named("Bytes")));
739        assert!(!type_equal(&a, &c));
740    }
741
742    #[test]
743    fn type_to_string_renders_sdl_syntax() {
744        assert_eq!(type_to_string(&named("Bytes")), "Bytes");
745        assert_eq!(type_to_string(&nn(named("Bytes"))), "Bytes!");
746        assert_eq!(type_to_string(&list(named("X"))), "[X]");
747        assert_eq!(type_to_string(&nn(list(nn(named("X"))))), "[X!]!");
748    }
749
750    #[test]
751    fn is_entity_object_skips_derivative_and_internal_types() {
752        assert!(is_entity_object("MetaBoard"));
753        assert!(is_entity_object("Transaction"));
754        assert!(!is_entity_object(""));
755        assert!(!is_entity_object("_Meta_"));
756        assert!(!is_entity_object("Query"));
757        assert!(!is_entity_object("Subscription"));
758        assert!(!is_entity_object("MetaV1_filter"));
759        assert!(!is_entity_object("MetaV1_orderBy"));
760    }
761
762    #[test]
763    fn render_type_unwraps_introspection_typeref_recursively() {
764        // NON_NULL[LIST[NON_NULL[Bytes]]] → "[Bytes!]!"
765        let nested = serde_json::json!({
766            "kind": "NON_NULL",
767            "name": null,
768            "ofType": {
769                "kind": "LIST",
770                "name": null,
771                "ofType": {
772                    "kind": "NON_NULL",
773                    "name": null,
774                    "ofType": { "kind": "SCALAR", "name": "Bytes", "ofType": null }
775                }
776            }
777        });
778        assert_eq!(render_type(&nested).unwrap(), "[Bytes!]!");
779    }
780
781    #[test]
782    fn render_type_handles_plain_named_type() {
783        let scalar = serde_json::json!({ "kind": "SCALAR", "name": "BigInt", "ofType": null });
784        assert_eq!(render_type(&scalar).unwrap(), "BigInt");
785    }
786
787    #[test]
788    fn render_type_errors_on_missing_name() {
789        let bad = serde_json::json!({ "kind": "SCALAR", "name": null, "ofType": null });
790        let err = render_type(&bad).unwrap_err().to_string();
791        assert!(err.contains("kind `SCALAR` has no name"), "{err}");
792    }
793
794    #[test]
795    fn render_type_errors_on_a_truncated_wrapper_chain() {
796        let truncated = serde_json::json!({
797            "kind": "NON_NULL",
798            "name": null,
799            "ofType": { "kind": "LIST", "name": null, "ofType": null }
800        });
801        let err = render_type(&truncated).unwrap_err().to_string();
802        assert!(err.contains("`LIST` wrapper has no `ofType`"), "{err}");
803        assert!(err.contains(&TYPE_REF_DEPTH.to_string()), "{err}");
804        assert!(!err.contains("Unknown"), "{err}");
805    }
806
807    #[test]
808    fn render_type_round_trips_a_chain_at_the_query_depth() {
809        let mut t = serde_json::json!({ "kind": "SCALAR", "name": "Bytes", "ofType": null });
810        let mut expected = "Bytes".to_string();
811        for level in 1..TYPE_REF_DEPTH {
812            let kind = if level % 2 == 1 { "NON_NULL" } else { "LIST" };
813            t = serde_json::json!({ "kind": kind, "name": null, "ofType": t });
814            expected = if kind == "NON_NULL" {
815                format!("{expected}!")
816            } else {
817                format!("[{expected}]")
818            };
819        }
820        assert_eq!(render_type(&t).unwrap(), expected);
821    }
822
823    #[test]
824    fn introspection_query_is_valid_graphql_nested_to_the_declared_depth() {
825        graphql_parser::query::parse_query::<String>(&INTROSPECTION_QUERY).unwrap();
826        assert_eq!(
827            INTROSPECTION_QUERY.matches("ofType").count(),
828            TYPE_REF_DEPTH - 1
829        );
830        assert!(INTROSPECTION_QUERY.contains("fields(includeDeprecated: true)"));
831    }
832
833    // ---------- live-URL HTTP path ----------
834
835    #[tokio::test]
836    async fn fetch_live_entities_filters_to_entity_object_types() {
837        use httpmock::Method::POST;
838        use httpmock::MockServer;
839
840        let server = MockServer::start_async().await;
841        let _mock = server
842            .mock_async(|when, then| {
843                when.method(POST).path("/");
844                then.status(200).json_body(serde_json::json!({
845                "data": { "__schema": { "types": [
846                    { "kind": "OBJECT", "name": "MetaBoard", "fields": [
847                        { "name": "id", "type": { "kind": "NON_NULL", "name": null,
848                            "ofType": { "kind": "SCALAR", "name": "Bytes", "ofType": null } } }
849                    ] },
850                    { "kind": "OBJECT", "name": "MetaV1_filter", "fields": [
851                        { "name": "id", "type": { "kind": "SCALAR", "name": "ID", "ofType": null } }
852                    ] },
853                    { "kind": "OBJECT", "name": "Query", "fields": [] },
854                    { "kind": "OBJECT", "name": "_Meta_", "fields": [] },
855                    { "kind": "SCALAR", "name": "Bytes", "fields": null }
856                ] } }
857            }));
858            })
859            .await;
860
861        let sdl = fetch_live_entities_as_sdl(&server.url("/")).await.unwrap();
862        assert!(sdl.contains("type MetaBoard @entity"));
863        assert!(sdl.contains("id: Bytes!"));
864        assert!(!sdl.contains("MetaV1_filter"));
865        assert!(!sdl.contains("Query"));
866        assert!(!sdl.contains("_Meta_"));
867    }
868
869    #[tokio::test]
870    async fn fetch_live_entities_errors_on_a_truncated_type_ref() {
871        use httpmock::Method::POST;
872        use httpmock::MockServer;
873
874        let server = MockServer::start_async().await;
875        let _mock = server
876            .mock_async(|when, then| {
877                when.method(POST).path("/");
878                then.status(200).json_body(serde_json::json!({
879                    "data": { "__schema": { "types": [
880                        { "kind": "OBJECT", "name": "MetaBoard", "fields": [
881                            { "name": "metas", "type": { "kind": "NON_NULL", "name": null,
882                                "ofType": { "kind": "LIST", "name": null, "ofType": null } } }
883                        ] }
884                    ] } }
885                }));
886            })
887            .await;
888
889        let err = fetch_live_entities_as_sdl(&server.url("/"))
890            .await
891            .unwrap_err()
892            .to_string();
893        assert!(err.contains("field `MetaBoard.metas`"), "{err}");
894        assert!(err.contains("has no `ofType`"), "{err}");
895        assert!(!err.contains("Unknown"), "{err}");
896    }
897
898    #[tokio::test]
899    async fn fetch_live_entities_propagates_graphql_errors() {
900        use httpmock::Method::POST;
901        use httpmock::MockServer;
902
903        let server = MockServer::start_async().await;
904        let _mock = server
905            .mock_async(|when, then| {
906                when.method(POST).path("/");
907                then.status(200).json_body(serde_json::json!({
908                    "errors": [{ "message": "introspection disabled" }]
909                }));
910            })
911            .await;
912
913        let err = fetch_live_entities_as_sdl(&server.url("/"))
914            .await
915            .unwrap_err();
916        assert!(err.to_string().contains("introspection errors"));
917        assert!(err.to_string().contains("introspection disabled"));
918    }
919
920    #[tokio::test]
921    async fn fetch_live_entities_errors_on_malformed_response() {
922        use httpmock::Method::POST;
923        use httpmock::MockServer;
924
925        let server = MockServer::start_async().await;
926        let _mock = server
927            .mock_async(|when, then| {
928                when.method(POST).path("/");
929                then.status(200)
930                    .json_body(serde_json::json!({ "data": {} }));
931            })
932            .await;
933
934        let err = fetch_live_entities_as_sdl(&server.url("/"))
935            .await
936            .unwrap_err();
937        assert!(err.to_string().contains("missing /data/__schema/types"));
938    }
939
940    #[tokio::test]
941    async fn fetch_live_entities_errors_on_http_failure() {
942        use httpmock::Method::POST;
943        use httpmock::MockServer;
944
945        let server = MockServer::start_async().await;
946        let _mock = server
947            .mock_async(|when, then| {
948                when.method(POST).path("/");
949                then.status(500);
950            })
951            .await;
952
953        let err = fetch_live_entities_as_sdl(&server.url("/"))
954            .await
955            .unwrap_err();
956        // reqwest's error_for_status produces "500 Internal Server Error" text.
957        assert!(err.to_string().contains("500"));
958    }
959
960    // ---------- end-to-end CLI handler ----------
961
962    #[tokio::test]
963    async fn schema_check_reads_files_and_succeeds_on_match() {
964        use std::io::Write;
965        let mut src = tempfile::NamedTempFile::new().unwrap();
966        src.write_all(SOURCE_OK.as_bytes()).unwrap();
967        let mut con = tempfile::NamedTempFile::new().unwrap();
968        con.write_all(CONSUMER_OK.as_bytes()).unwrap();
969
970        schema_check(SchemaCheck {
971            source: Some(src.path().into()),
972            live_url: None,
973            consumer: con.path().into(),
974        })
975        .await
976        .unwrap();
977    }
978
979    #[tokio::test]
980    async fn schema_check_rejects_neither_source_nor_live_url() {
981        let mut con = tempfile::NamedTempFile::new().unwrap();
982        std::io::Write::write_all(&mut con, CONSUMER_OK.as_bytes()).unwrap();
983
984        let err = schema_check(SchemaCheck {
985            source: None,
986            live_url: None,
987            consumer: con.path().into(),
988        })
989        .await
990        .unwrap_err();
991        assert!(err
992            .to_string()
993            .contains("exactly one of --source or --live-url"));
994    }
995
996    #[tokio::test]
997    async fn schema_check_failure_includes_live_sdl_in_error() {
998        use httpmock::Method::POST;
999        use httpmock::MockServer;
1000        use std::io::Write;
1001
1002        // Live introspection returns a single MetaBoard entity; consumer
1003        // file omits it, so the error should include the live-derived SDL.
1004        let server = MockServer::start_async().await;
1005        let _mock = server
1006            .mock_async(|when, then| {
1007                when.method(POST).path("/");
1008                then.status(200).json_body(serde_json::json!({
1009                    "data": { "__schema": { "types": [
1010                        { "kind": "OBJECT", "name": "MetaBoard", "fields": [
1011                            { "name": "id", "type": { "kind": "NON_NULL", "name": null,
1012                                "ofType": { "kind": "SCALAR", "name": "Bytes", "ofType": null } } }
1013                        ] }
1014                    ] } }
1015                }));
1016            })
1017            .await;
1018
1019        let mut con = tempfile::NamedTempFile::new().unwrap();
1020        con.write_all(b"scalar X").unwrap();
1021
1022        let err = schema_check(SchemaCheck {
1023            source: None,
1024            live_url: Some(server.url("/")),
1025            consumer: con.path().into(),
1026        })
1027        .await
1028        .unwrap_err();
1029        let msg = err.to_string();
1030        assert!(msg.contains("entity `MetaBoard` is missing"));
1031        assert!(msg.contains("Live introspection-derived entity SDL"));
1032        assert!(msg.contains("type MetaBoard @entity"));
1033    }
1034}