switchback-codec-pb 0.0.1-0.dev.3

Binary codec for the switchback IR in the switchback framework.
Documentation
//! Round-trip integration tests for [`ProtobufCodec`].

use std::path::PathBuf;

use buffa::Message;
use static_assertions::assert_impl_all;
use switchback_codec_pb::{DEFAULT_SWITCHBACK_FILENAME, ProtobufCodec, WIRE_VERSION, convert};
use switchback_traits::{
    Anchor, Companion, Document, EntityBody, EntityRef, ExtensionBody, ExternalUrl, Group, GroupId,
    GroupRef, IntraLink, LinkTarget, ManualContract, Module, ModuleId, OperationBody, ParameterRef,
    RefKind, Reference, ReferenceManual, ResponseRef, SchemaBody, Source, SourceRef, SpecVersion,
    StoredEntity, SwitchbackCodec, SyncSwitchbackCodec,
};

fn fixture_manual() -> ReferenceManual {
    let schema_ref = Reference {
        target: EntityRef {
            module: "Acme".into(),
            group: "acme.user.v1".into(),
            category: "schema".into(),
            name: "User".into(),
        },
        kind: RefKind::Internal,
    };

    ReferenceManual {
        switchback_version: WIRE_VERSION.into(),
        title: "Acme API".into(),
        sources: vec![Document {
            source_ref: SourceRef {
                uri: "repo://openapi.yaml".into(),
                commit: "abc123".into(),
                content_hash: "deadbeef".into(),
            },
            media_type: "application/yaml".into(),
            content: b"openapi: 3.1.0\n".to_vec(),
        }],
        modules: vec![Module {
            id: ModuleId::from("Acme"),
            title: "Acme".into(),
            overview: "Overview text.".into(),
            contracts: vec![ManualContract {
                family: "openapi".into(),
                version: SpecVersion::from("3.1.0"),
                groups: vec![Group {
                    id: GroupId::from("acme.user.v1"),
                    dir: "acme/user/v1".into(),
                    title: "User API".into(),
                    overview: Some("Group overview.".into()),
                    source: Some(Source {
                        file: "repo://openapi.yaml".into(),
                        span: None,
                    }),
                    entities: vec![
                        StoredEntity {
                            name: "getUser".into(),
                            category: "operation".into(),
                            title: "Get user".into(),
                            doc: Some("Fetch a user by id.".into()),
                            source: None,
                            refs: vec![schema_ref.clone()],
                            intra_links: vec![
                                IntraLink {
                                    anchor: Anchor {
                                        field: "doc".into(),
                                        byte_start: 0,
                                        byte_end: 4,
                                    },
                                    target: LinkTarget::Entity(EntityRef {
                                        module: "Acme".into(),
                                        group: "acme.user.v1".into(),
                                        category: "schema".into(),
                                        name: "User".into(),
                                    }),
                                    raw: "[User]".into(),
                                },
                                IntraLink {
                                    anchor: Anchor {
                                        field: "doc".into(),
                                        byte_start: 10,
                                        byte_end: 20,
                                    },
                                    target: LinkTarget::Group(GroupRef {
                                        module: "Acme".into(),
                                        group: "acme.user.v1".into(),
                                    }),
                                    raw: "user group".into(),
                                },
                                IntraLink {
                                    anchor: Anchor {
                                        field: "doc".into(),
                                        byte_start: 30,
                                        byte_end: 40,
                                    },
                                    target: LinkTarget::External(ExternalUrl {
                                        url: "https://example.com".into(),
                                    }),
                                    raw: "example".into(),
                                },
                            ],
                            body: EntityBody::Operation(OperationBody {
                                signature: "**GET** `/users/{id}`".into(),
                                fence_language: "yaml".into(),
                                fence_body: "operationId: getUser\n".into(),
                                parameters: vec![ParameterRef {
                                    name: "id".into(),
                                    location: "path".into(),
                                    required: true,
                                    schema_ref: schema_ref.clone(),
                                    type_label: "string".into(),
                                    description: String::new(),
                                    protocols: Vec::new(),
                                }],
                                responses: vec![ResponseRef {
                                    status: "200".into(),
                                    severity: switchback_traits::ResponseSeverity::Success,
                                    media_type: "application/json".into(),
                                    schema_ref: schema_ref.clone(),
                                    description: "OK".into(),
                                    protocols: Vec::new(),
                                }],
                                request_body: None,
                                protocols: Vec::new(),
                            }),
                        },
                        StoredEntity {
                            name: "User".into(),
                            category: "schema".into(),
                            title: "User".into(),
                            doc: None,
                            source: None,
                            refs: vec![],
                            intra_links: vec![],
                            body: EntityBody::Schema(SchemaBody {
                                fence_language: "json".into(),
                                fence_body: r#"{"type":"object"}"#.into(),
                                payload_format: "json-schema".into(),
                                properties: vec![],
                            }),
                        },
                        StoredEntity {
                            name: "Webhook".into(),
                            category: "extension".into(),
                            title: "Webhook".into(),
                            doc: None,
                            source: None,
                            refs: vec![],
                            intra_links: vec![],
                            body: EntityBody::Extension(ExtensionBody {
                                extension_type: "asyncapi.webhook".into(),
                                payload: br#"{"hook":true}"#.to_vec(),
                                fence_language: Some("json".into()),
                                fence_body: Some("{}".into()),
                            }),
                        },
                    ],
                    source_path: PathBuf::from("openapi/acme/user/v1"),
                }],
                companions: vec![Companion {
                    output_name: "acme.user.v1.overview.md".into(),
                    bytes: b"# Overview".to_vec(),
                    media_type: "text/markdown".into(),
                    title: "Overview".into(),
                    source_dir: "acme/user/v1".into(),
                    stem: "overview".into(),
                }],
                protocols: Vec::new(),
            }],
        }],
    }
}

#[test]
fn protobuf_codec_is_send_sync() {
    assert_impl_all!(ProtobufCodec: Send, Sync, SwitchbackCodec, SyncSwitchbackCodec);
}

#[test]
fn default_filename_is_switchback_binpb() {
    assert_eq!(DEFAULT_SWITCHBACK_FILENAME, "switchback.binpb");
}

#[test]
fn sync_roundtrip_preserves_manual() {
    let manual = fixture_manual();
    let codec = ProtobufCodec;

    let bytes = SyncSwitchbackCodec::serialize(&codec, &manual).expect("serialize");
    assert!(!bytes.is_empty());

    let round_trip = SyncSwitchbackCodec::deserialize(&codec, &bytes).expect("deserialize");
    assert_eq!(round_trip.switchback_version, WIRE_VERSION);
    assert_eq!(round_trip.title, manual.title);
    assert_eq!(round_trip.sources, manual.sources);
    assert_eq!(round_trip.modules.len(), 1);
    assert_eq!(
        round_trip.modules[0].contracts[0].groups[0].entities.len(),
        3
    );
    assert_eq!(
        round_trip.modules[0].contracts[0].companions,
        manual.modules[0].contracts[0].companions
    );
}

#[tokio::test]
async fn async_roundtrip_preserves_manual() {
    let manual = fixture_manual();
    let codec = ProtobufCodec;

    let bytes = SwitchbackCodec::serialize(&codec, &manual)
        .await
        .expect("serialize");
    let round_trip = SwitchbackCodec::deserialize(&codec, &bytes)
        .await
        .expect("deserialize");
    assert_eq!(round_trip.title, manual.title);
}

#[test]
fn serialize_rejects_unresolved_intra_link() {
    let mut manual = fixture_manual();
    manual.modules[0].contracts[0].groups[0].entities[0]
        .intra_links
        .push(IntraLink {
            anchor: Anchor {
                field: "doc".into(),
                byte_start: 0,
                byte_end: 1,
            },
            target: LinkTarget::Unresolved,
            raw: "?".into(),
        });

    let err = SyncSwitchbackCodec::serialize(&ProtobufCodec, &manual)
        .expect_err("unresolved link should fail");
    assert!(err.to_string().contains("unresolved"));
}

#[test]
fn deserialize_rejects_incompatible_version() {
    let manual = fixture_manual();
    let mut proto = convert::to_proto(&manual).expect("to_proto");
    proto.switchback_version = "v9".into();
    let bad_bytes = proto.encode_to_vec();

    let err = SyncSwitchbackCodec::deserialize(&ProtobufCodec, &bad_bytes)
        .expect_err("incompatible version");
    assert!(err.to_string().contains("switchback_version"));
}

#[test]
fn source_path_is_not_round_tripped() {
    let manual = fixture_manual();
    assert!(
        !manual.modules[0].contracts[0].groups[0]
            .source_path
            .as_os_str()
            .is_empty()
    );

    let round_trip = SyncSwitchbackCodec::deserialize(
        &ProtobufCodec,
        &SyncSwitchbackCodec::serialize(&ProtobufCodec, &manual).unwrap(),
    )
    .unwrap();
    assert!(
        round_trip.modules[0].contracts[0].groups[0]
            .source_path
            .as_os_str()
            .is_empty()
    );
}