From b910e77460409193080f56d089823d7eb3c4858e Mon Sep 17 00:00:00 2001 From: Alberto Moretti Date: Sat, 13 Jun 2026 11:20:03 +0200 Subject: [PATCH] ci: add nightly external contract tests --- .github/workflows/external-contracts.yml | 30 +++++++++++++ README.md | 8 ++++ tests/external_contract.rs | 56 ++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 .github/workflows/external-contracts.yml create mode 100644 tests/external_contract.rs diff --git a/.github/workflows/external-contracts.yml b/.github/workflows/external-contracts.yml new file mode 100644 index 0000000..074fc2c --- /dev/null +++ b/.github/workflows/external-contracts.yml @@ -0,0 +1,30 @@ +name: External contracts + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_PROFILE_TEST_DEBUG: line-tables-only + CARGO_INCREMENTAL: 0 + +jobs: + external-contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + # These tests intentionally call real third-party APIs. Unit/integration + # tests mock providers, but a nightly contract check catches external API + # drift that would otherwise only appear at runtime. + - name: Run external contract tests + run: cargo test --locked --test external_contract -- --ignored --test-threads=1 diff --git a/README.md b/README.md index b0d96d5..616a4d1 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,14 @@ cargo test --locked The crate denies unsafe code and enables strict Clippy groups: `all`, `pedantic`, `nursery` and `cargo`, with only dependency-version noise allowed. +## External contract checks + +Normal tests mock third-party APIs. A scheduled GitHub Actions workflow runs ignored contract tests nightly against the real PokéAPI and FunTranslations APIs to catch provider contract drift early: + +```bash +cargo test --locked --test external_contract -- --ignored --test-threads=1 +``` + ## Docker multi-arch build ```bash diff --git a/tests/external_contract.rs b/tests/external_contract.rs new file mode 100644 index 0000000..480e150 --- /dev/null +++ b/tests/external_contract.rs @@ -0,0 +1,56 @@ +use pokedex_api::{ + clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient}, + domain::TranslationKind, +}; +use std::time::Duration; + +const POKEAPI_BASE_URL: &str = "https://pokeapi.co/api/v2"; +const FUN_TRANSLATIONS_BASE_URL: &str = "https://api.funtranslations.mercxry.me/v1"; +const CONTRACT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +#[ignore = "nightly external contract test: calls the real PokéAPI"] +async fn pokeapi_species_contract_still_matches_what_we_parse() { + let client = + PokeApiClient::new(POKEAPI_BASE_URL, CONTRACT_TIMEOUT).expect("client should build"); + + let pokemon = client + .get_pokemon_info("mewtwo") + .await + .expect("PokéAPI species contract should still parse"); + + assert_eq!(pokemon.name, "mewtwo"); + assert_eq!(pokemon.habitat, "rare"); + assert!(pokemon.is_legendary); + assert!(!pokemon.description.trim().is_empty()); +} + +#[tokio::test] +#[ignore = "nightly external contract test: calls the real FunTranslations API"] +async fn funtranslations_yoda_contract_still_matches_what_we_parse() { + let client = TranslationClient::new(FUN_TRANSLATIONS_BASE_URL, CONTRACT_TIMEOUT) + .expect("client should build"); + + let translated = client + .translate(TranslationKind::Yoda, "It was created by science.") + .await + .expect("FunTranslations Yoda contract should still parse"); + + assert!(!translated.trim().is_empty()); + assert_ne!(translated, "It was created by science."); +} + +#[tokio::test] +#[ignore = "nightly external contract test: calls the real FunTranslations API"] +async fn funtranslations_shakespeare_contract_still_matches_what_we_parse() { + let client = TranslationClient::new(FUN_TRANSLATIONS_BASE_URL, CONTRACT_TIMEOUT) + .expect("client should build"); + + let translated = client + .translate(TranslationKind::Shakespeare, "You have a great friend.") + .await + .expect("FunTranslations Shakespeare contract should still parse"); + + assert!(!translated.trim().is_empty()); + assert_ne!(translated, "You have a great friend."); +}