Wave 5: Schemas command, sync command, network policy, test fixtures (bd-x15, bd-3f4, bd-1cv, bd-lx6)

- Implement schemas command with list/show modes, regex filtering, ref expansion
- Implement sync command with conditional fetch, content hash diffing, dry-run
- Add NetworkPolicy enum (Auto/Offline/OnlineOnly) with env var + CLI flag resolution
- Integrate network policy into AsyncHttpClient and fetch command
- Create test fixtures (petstore.json/yaml, minimal.json) and integration test helpers
- Fix clippy lints: derivable_impls, len_zero, borrow-after-move, deprecated API
- 192 tests passing (179 unit + 13 integration), all quality gates green
This commit is contained in:
teernisse
2026-02-12 14:36:22 -05:00
parent faa6281790
commit 346fef9135
11 changed files with 2051 additions and 31 deletions

92
tests/fixtures/minimal.json vendored Normal file
View File

@@ -0,0 +1,92 @@
{
"openapi": "3.0.3",
"info": {
"title": "Minimal API",
"version": "0.1.0"
},
"paths": {
"/items": {
"get": {
"operationId": "listItems",
"summary": "List items",
"responses": {
"200": {
"description": "A list of items",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ItemList" }
}
}
}
}
},
"post": {
"operationId": "createItem",
"summary": "Create item",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Item" }
}
}
},
"responses": {
"201": {
"description": "Item created",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Item" }
}
}
}
}
}
},
"/items/{id}": {
"get": {
"operationId": "getItem",
"summary": "Get item by ID",
"parameters": [
{
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "A single item",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Item" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"Item": {
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer", "format": "int64" },
"name": { "type": "string" }
}
},
"ItemList": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": { "$ref": "#/components/schemas/Item" }
},
"total": { "type": "integer" }
}
}
}
}
}

144
tests/fixtures/petstore.yaml vendored Normal file
View File

@@ -0,0 +1,144 @@
openapi: "3.0.3"
info:
title: Petstore
version: "1.0.0"
description: A minimal Petstore API for testing.
security:
- api_key: []
tags:
- name: pets
description: Pet operations
- name: store
description: Store operations
paths:
/pets:
get:
operationId: listPets
summary: List all pets
tags:
- pets
parameters:
- name: limit
in: query
required: false
description: Maximum number of items to return
- name: offset
in: query
required: false
description: Pagination offset
responses:
"200":
description: A list of pets
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Pet"
post:
operationId: createPet
summary: Create a pet
tags:
- pets
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NewPet"
responses:
"201":
description: Pet created
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
/pets/{petId}:
parameters:
- name: petId
in: path
required: true
description: The ID of the pet
get:
operationId: showPetById
summary: Get a pet by ID
tags:
- pets
responses:
"200":
description: A single pet
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
"404":
description: Pet not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
delete:
operationId: deletePet
summary: Delete a pet
tags:
- pets
deprecated: true
responses:
"204":
description: Pet deleted
/store/inventory:
get:
operationId: getInventory
summary: Get store inventory
tags:
- store
security: []
responses:
"200":
description: Inventory counts
content:
application/json:
schema:
type: object
additionalProperties:
type: integer
components:
schemas:
Pet:
type: object
required:
- id
- name
properties:
id:
type: integer
format: int64
name:
type: string
tag:
type: string
NewPet:
type: object
required:
- name
properties:
name:
type: string
tag:
type: string
Error:
type: object
required:
- code
- message
properties:
code:
type: integer
format: int32
message:
type: string
securitySchemes:
api_key:
type: apiKey
name: X-API-Key
in: header

134
tests/helpers/mod.rs Normal file
View File

@@ -0,0 +1,134 @@
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use tempfile::TempDir;
/// An isolated test environment with its own home, cache, and config directories.
///
/// Setting `SWAGGER_CLI_HOME` on each command invocation ensures tests never
/// touch the real user cache or config.
pub struct TestEnv {
// Kept alive for RAII cleanup; not read directly.
_temp_dir: TempDir,
pub home_dir: PathBuf,
pub cache_dir: PathBuf,
pub config_dir: PathBuf,
}
impl TestEnv {
/// Creates a fresh isolated test environment backed by a temporary directory.
pub fn new() -> Self {
let temp_dir = TempDir::new().expect("failed to create temp dir");
let home_dir = temp_dir.path().join("swagger-cli-home");
let cache_dir = home_dir.join("cache");
let config_dir = home_dir.join("config");
std::fs::create_dir_all(&cache_dir).expect("failed to create cache dir");
std::fs::create_dir_all(&config_dir).expect("failed to create config dir");
Self {
_temp_dir: temp_dir,
home_dir,
cache_dir,
config_dir,
}
}
}
/// Run `swagger-cli` with the given arguments inside the provided test environment.
///
/// `SWAGGER_CLI_HOME` is set so all reads/writes go to the temp directory.
#[allow(deprecated)]
pub fn run_cmd(env: &TestEnv, args: &[&str]) -> assert_cmd::assert::Assert {
Command::cargo_bin("swagger-cli")
.expect("binary not found -- run `cargo build` first")
.env("SWAGGER_CLI_HOME", &env.home_dir)
.args(args)
.assert()
}
/// Fetch a fixture file into the test environment's cache under the given alias.
///
/// Runs: `swagger-cli fetch <fixture_path> --alias <alias>`
pub fn fetch_fixture(env: &TestEnv, fixture_name: &str, alias: &str) {
let path = fixture_path(fixture_name);
assert!(
path.exists(),
"fixture file does not exist: {}",
path.display()
);
let path_str = path.to_str().expect("fixture path is not valid UTF-8");
run_cmd(env, &["fetch", path_str, "--alias", alias]).success();
}
/// Parse robot-mode JSON output from command stdout bytes.
///
/// The robot envelope has the shape: `{"ok": true, "data": {...}, "meta": {...}}`
pub fn parse_robot_json(output: &[u8]) -> serde_json::Value {
let text = std::str::from_utf8(output).expect("stdout is not valid UTF-8");
serde_json::from_str(text.trim()).expect("stdout is not valid JSON")
}
/// Return the absolute path to a test fixture file by name.
///
/// Fixtures live in `tests/fixtures/` relative to the project root.
pub fn fixture_path(name: &str) -> PathBuf {
let manifest_dir =
std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set -- run via cargo");
Path::new(&manifest_dir)
.join("tests")
.join("fixtures")
.join(name)
}
// ---------------------------------------------------------------------------
// Self-tests for the helpers
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_env_creates_directories() {
let env = TestEnv::new();
assert!(env.home_dir.exists());
assert!(env.cache_dir.exists());
assert!(env.config_dir.exists());
}
#[test]
fn test_fixture_path_exists() {
let path = fixture_path("petstore.json");
assert!(path.exists(), "petstore.json fixture missing");
assert!(path.is_absolute());
}
#[test]
fn test_fixture_path_yaml() {
let path = fixture_path("petstore.yaml");
assert!(path.exists(), "petstore.yaml fixture missing");
}
#[test]
fn test_fixture_path_minimal() {
let path = fixture_path("minimal.json");
assert!(path.exists(), "minimal.json fixture missing");
}
#[test]
fn test_parse_robot_json_valid() {
let input = br#"{"ok":true,"data":{"x":1},"meta":{}}"#;
let val = parse_robot_json(input);
assert_eq!(val["ok"], true);
assert_eq!(val["data"]["x"], 1);
}
#[test]
#[should_panic(expected = "not valid JSON")]
fn test_parse_robot_json_invalid() {
parse_robot_json(b"not json");
}
}

106
tests/integration_test.rs Normal file
View File

@@ -0,0 +1,106 @@
mod helpers;
#[test]
fn test_petstore_json_is_valid() {
let path = helpers::fixture_path("petstore.json");
let content = std::fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(json.get("openapi").is_some());
assert!(json.get("paths").is_some());
assert!(json.get("components").is_some());
}
#[test]
fn test_petstore_yaml_is_valid() {
let path = helpers::fixture_path("petstore.yaml");
let content = std::fs::read_to_string(&path).unwrap();
let yaml: serde_json::Value = serde_yaml::from_str(&content).unwrap();
assert!(yaml.get("openapi").is_some());
assert!(yaml.get("paths").is_some());
assert!(yaml.get("components").is_some());
}
#[test]
fn test_minimal_json_is_valid() {
let path = helpers::fixture_path("minimal.json");
let content = std::fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json["openapi"], "3.0.3");
assert_eq!(json["info"]["title"], "Minimal API");
let paths = json["paths"].as_object().unwrap();
assert_eq!(paths.len(), 2); // /items and /items/{id}
// Count total operations: GET /items, POST /items, GET /items/{id} = 3
let items_ops = json["paths"]["/items"].as_object().unwrap();
let item_by_id_ops = json["paths"]["/items/{id}"].as_object().unwrap();
let op_count = items_ops.len() + item_by_id_ops.len();
// /items has get+post (2), /items/{id} has get+parameters (but parameters isn't an op)
assert!(op_count >= 3);
let schemas = json["components"]["schemas"].as_object().unwrap();
assert_eq!(schemas.len(), 2); // Item and ItemList
}
#[test]
fn test_petstore_yaml_matches_json_structure() {
let json_path = helpers::fixture_path("petstore.json");
let yaml_path = helpers::fixture_path("petstore.yaml");
let json_content = std::fs::read_to_string(&json_path).unwrap();
let yaml_content = std::fs::read_to_string(&yaml_path).unwrap();
let json_val: serde_json::Value = serde_json::from_str(&json_content).unwrap();
let yaml_val: serde_json::Value = serde_yaml::from_str(&yaml_content).unwrap();
// Both should have the same top-level keys
assert_eq!(json_val["openapi"], yaml_val["openapi"]);
assert_eq!(json_val["info"]["title"], yaml_val["info"]["title"]);
assert_eq!(json_val["info"]["version"], yaml_val["info"]["version"]);
// Same number of paths
let json_paths = json_val["paths"].as_object().unwrap();
let yaml_paths = yaml_val["paths"].as_object().unwrap();
assert_eq!(json_paths.len(), yaml_paths.len());
// Same schemas
let json_schemas = json_val["components"]["schemas"].as_object().unwrap();
let yaml_schemas = yaml_val["components"]["schemas"].as_object().unwrap();
assert_eq!(json_schemas.len(), yaml_schemas.len());
}
#[test]
fn test_fetch_and_list_fixture() {
let env = helpers::TestEnv::new();
helpers::fetch_fixture(&env, "petstore.json", "petstore");
let assert = helpers::run_cmd(&env, &["list", "petstore", "--robot"]).success();
let json = helpers::parse_robot_json(&assert.get_output().stdout);
assert_eq!(json["ok"], true);
assert!(!json["data"]["endpoints"].as_array().unwrap().is_empty());
assert!(json["data"]["total"].as_u64().unwrap() > 0);
}
#[test]
fn test_fetch_yaml_fixture() {
let env = helpers::TestEnv::new();
helpers::fetch_fixture(&env, "petstore.yaml", "petstore-yaml");
let assert = helpers::run_cmd(&env, &["list", "petstore-yaml", "--robot"]).success();
let json = helpers::parse_robot_json(&assert.get_output().stdout);
assert_eq!(json["ok"], true);
}
#[test]
fn test_fetch_minimal_fixture() {
let env = helpers::TestEnv::new();
helpers::fetch_fixture(&env, "minimal.json", "minimal");
let assert = helpers::run_cmd(&env, &["list", "minimal", "--robot"]).success();
let json = helpers::parse_robot_json(&assert.get_output().stdout);
assert_eq!(json["ok"], true);
assert_eq!(json["data"]["total"], 3);
}