diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..94a245b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +# Test/lint gate (offline, no secrets) on every push/PR, plus a live-network +# job that exercises the real source sites and the token-gated pixiv tests. +# +# Layering: +# test — fmt + clippy + the full offline unit suite. Runs on every push +# and PR, including forks (it needs no secrets). +# live — the #[ignore]d live-network tests plus the pixiv tests that are +# gated on PIXIV_REFRESH_TOKEN. Runs on schedule / manual dispatch +# / tag pushes only, because pull requests from forks cannot read +# repository secrets. continue-on-error keeps a flaky external site +# from blocking, while the run still records the outcome. +# +# Test gating convention (keep in sync with AGENTS.md "Testing & QA"): +# - pure unit tests: plain #[test] / #[tokio::test], always run. +# - live-network tests: #[ignore = "live network: ..."], only run here. +# - token-gated tests (pixiv): #[tokio::test] with an early return when +# PIXIV_REFRESH_TOKEN is absent or empty (empty = unset CI secret). + +on: + push: + branches: [master] + pull_request: + schedule: + # Weekly probe of the live endpoints, so external API changes surface. + - cron: '0 3 * * 1' + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + - name: Check formatting + run: cargo fmt --check + - name: Lint (deny warnings) + run: cargo clippy --workspace --all-targets -- -D warnings + - name: Run offline tests + run: cargo test --workspace + + live: + needs: test + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + continue-on-error: true + env: + PIXIV_REFRESH_TOKEN: ${{ secrets.PIXIV_REFRESH_TOKEN }} + TWITTER_AUTH_TOKEN: ${{ secrets.TWITTER_AUTH_TOKEN }} + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + # Full suite: with the secret present, the pixiv token-gated tests run; + # without it they skip themselves. Live tests stay #[ignore]d here. + - name: Run token-gated tests + run: cargo test --workspace + # The live-network tests, by the "live" name filter (all #[ignore]d). + - name: Run live-network tests + run: cargo test --workspace -- --ignored live diff --git a/AGENTS.md b/AGENTS.md index d6f556d..987dd63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,8 +93,8 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi - **~80 tests, all inline `#[cfg(test)] mod tests`** — no `tests/` integration directories. Framework: built-in Rust test + `#[tokio::test]` (dev-deps only in `x-media`: tokio macros/rt-multi-thread, dotenv). - No mocking framework anywhere (no mockito/wiremock/mockall). Conventions: pure-function units (regex parsing, serde round-trips, chunking, retry math) tested synchronously; async tests use real dependencies — file-backed SQLite via `tempfile` (`queue.rs::new_queue()` helper), live network fetches. -- Live-network tests exist in `site/twitter/interface.rs` (3), `site/bsky/interface.rs` (2), `site/pixiv/interface.rs`/`api.rs` (env-gated on `PIXIV_REFRESH_TOKEN`/dotenv, skip by early return). Run the full suite with `cargo test --workspace`. +- Live-network tests exist in `site/twitter/interface.rs` (3), `site/bsky/interface.rs` (2), `site/pixiv/interface.rs`/`api.rs`. Test gating convention (enforced by `.github/workflows/ci.yml`): pure unit tests always run; live-network tests carry `#[ignore = "live network: ..."]` (run via `cargo test --workspace -- --ignored live`); token-gated pixiv tests early-return when `PIXIV_REFRESH_TOKEN` is absent **or empty** (an unset GitHub secret arrives as `""` — `is_err()` alone would run them tokenless and fail). Run the full offline suite with `cargo test --workspace`. - Fixtures are inline `serde_json::json!` builder fns (`fixture()`, `thread_json()`, `illust_json()`), not files. The shared `CLIENT` sets `pool_max_idle_per_host(0)` under `#[cfg(test)]` to avoid cross-runtime `DispatchGone`. -- **CI runs no tests** — `.github/workflows/docker.yml` only builds/pushes the image; verification is a local responsibility. +- **CI** — `.github/workflows/ci.yml` runs `cargo fmt --check` + `cargo clippy --workspace --all-targets -- -D warnings` + `cargo test --workspace` (offline, no secrets, on every push/PR) and a `live` job (schedule/manual/tag only, `PIXIV_REFRESH_TOKEN`/`TWITTER_AUTH_TOKEN` from secrets, `continue-on-error`) for the `#[ignore]`d live + token tests. `.github/workflows/docker.yml` builds/pushes the image only. - Untested and hard to test without a mock seam: `handlers.rs` (depends directly on teloxide `Bot`); `main.rs`, `config.rs`, `state.rs`; `media.rs`, `lib.rs`, all `model.rs`. -- No coverage tracking, no lint gate in CI. +- No coverage tracking. diff --git a/crates/x-media/src/site/bsky/interface.rs b/crates/x-media/src/site/bsky/interface.rs index 6105ac1..41ff2ea 100644 --- a/crates/x-media/src/site/bsky/interface.rs +++ b/crates/x-media/src/site/bsky/interface.rs @@ -416,6 +416,7 @@ mod tests { } #[tokio::test] + #[ignore = "live network: requires outbound HTTPS to public.api.bsky.app"] async fn live_fetch_with_photos() { let fetched = fetch_from_url("https://bsky.app/profile/asagi0398.bsky.social/post/3mqkhrq5w6k2m") @@ -429,6 +430,7 @@ mod tests { } #[tokio::test] + #[ignore = "live network: requires outbound HTTPS to public.api.bsky.app"] async fn live_fetch_smoke() { let fetched = fetch_from_url("https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224") diff --git a/crates/x-media/src/site/mod.rs b/crates/x-media/src/site/mod.rs index 340b1bb..12d6714 100644 --- a/crates/x-media/src/site/mod.rs +++ b/crates/x-media/src/site/mod.rs @@ -422,7 +422,13 @@ mod tests { async fn download_media_pixiv_original_with_referer() { // Proves the Referer header is attached for i.pximg.net: a header-less // GET to a pixiv original URL is rejected with 403. - if std::env::var("PIXIV_REFRESH_TOKEN").is_err() { + // Empty-string check too: an unset CI secret arrives as "" (GitHub + // Actions), which would otherwise run the test tokenless and fail. + if std::env::var("PIXIV_REFRESH_TOKEN") + .ok() + .filter(|s| !s.is_empty()) + .is_none() + { eprintln!("skipping: no PIXIV_REFRESH_TOKEN"); return; } diff --git a/crates/x-media/src/site/pixiv/api.rs b/crates/x-media/src/site/pixiv/api.rs index b47dae3..eaada6d 100644 --- a/crates/x-media/src/site/pixiv/api.rs +++ b/crates/x-media/src/site/pixiv/api.rs @@ -392,16 +392,31 @@ mod tests { use super::*; use dotenv::dotenv; + /// Skips when `PIXIV_REFRESH_TOKEN` is absent or empty (CI without the + /// secret must stay green; GitHub Actions exposes an unset secret as an + /// empty string, so `is_err()` alone is not enough). + fn require_pixiv_token() -> bool { + std::env::var("PIXIV_REFRESH_TOKEN") + .ok() + .filter(|s| !s.is_empty()) + .is_some() + } + #[tokio::test] async fn test_fetch() { dotenv().ok(); + if !require_pixiv_token() { + eprintln!("skipping: no PIXIV_REFRESH_TOKEN"); + return; + } let result = fetch(126839080).await; assert!(result.is_ok()); println!("{:#?}", result); } #[tokio::test] - async fn validate_with_bogus_token_fails() { + #[ignore = "live network: requires outbound HTTPS to oauth.secure.pixiv.net"] + async fn live_validate_with_bogus_token_fails() { dotenv().ok(); // A bogus token must surface as Api error (invalid_grant), not panic. let client = PixivAPI::new("bogus_token_for_testing".to_string()); diff --git a/crates/x-media/src/site/twitter/interface.rs b/crates/x-media/src/site/twitter/interface.rs index 90b2829..f4bccf8 100644 --- a/crates/x-media/src/site/twitter/interface.rs +++ b/crates/x-media/src/site/twitter/interface.rs @@ -573,18 +573,21 @@ mod tests { } #[tokio::test] + #[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"] async fn live_fetch_with_photos() { let fetched = fetch("861627479294746624").await.unwrap(); assert_eq!(fetched.media.len(), 4); } #[tokio::test] + #[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"] async fn live_fetch_text_only() { let fetched = fetch("1992471125734142256").await.unwrap(); assert!(fetched.media.is_empty()); } #[tokio::test] + #[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"] async fn live_fetch_deleted_tweet_is_not_found() { // Deleted tweet: the syndication endpoint answers with errors. let result = fetch("0").await; diff --git a/crates/xmedia-bot/src/send.rs b/crates/xmedia-bot/src/send.rs index f162eb3..9706f5a 100644 --- a/crates/xmedia-bot/src/send.rs +++ b/crates/xmedia-bot/src/send.rs @@ -155,7 +155,9 @@ impl Task { Task::SendMediaSequence { media_batches, .. } => { media_batches.iter().flatten().collect() } - Task::SendAnimation { animation, .. } => std::slice::from_ref(animation).iter().collect(), + Task::SendAnimation { animation, .. } => { + std::slice::from_ref(animation).iter().collect() + } Task::ForwardMessages { .. } => Vec::new(), } } @@ -1333,9 +1335,10 @@ mod tests { #[test] fn oversized_photo_boundary() { // The empirical Telegram limit: sum 10000 passes, 10001 fails. - assert!(crate::photo::PHOTO_MAX_DIMENSION_SUM == 10000); - assert!(6100 + 3900 <= crate::photo::PHOTO_MAX_DIMENSION_SUM); - assert!(6300 + 3730 > crate::photo::PHOTO_MAX_DIMENSION_SUM); + // Const-block asserts so clippy's assertions_on_constants stays quiet. + const { assert!(crate::photo::PHOTO_MAX_DIMENSION_SUM == 10000) }; + const { assert!(6100 + 3900 <= crate::photo::PHOTO_MAX_DIMENSION_SUM) }; + const { assert!(6300 + 3730 > crate::photo::PHOTO_MAX_DIMENSION_SUM) }; } #[test]