ci: add test/clippy workflow and gate live/token tests

The docker workflow only builds/pushes; tests were a local responsibility.
Add .github/workflows/ci.yml with two layers:

- test: cargo fmt --check + cargo clippy --workspace --all-targets -D
  warnings + cargo test --workspace (fully offline, no secrets) on every
  push/PR, including forks.
- live: the #[ignore]d live-network tests plus the pixiv token-gated
  tests, run on schedule / manual dispatch / tag pushes only (fork PRs
  cannot read repository secrets), with PIXIV_REFRESH_TOKEN /
  TWITTER_AUTH_TOKEN injected and continue-on-error for flaky sites.

Test gating (documented in AGENTS.md):
- live-network tests now carry #[ignore = "live network: ..."] (twitter 3,
  bsky 2, pixiv bogus-token 1) and run via -- --ignored live.
- pixiv token tests early-return when PIXIV_REFRESH_TOKEN is absent or
  empty (an unset GitHub secret arrives as ""); test_fetch previously
  failed without a token.

Also fixes the three clippy assertions_on_constants warnings in send.rs
(required for -D warnings).
This commit is contained in:
2026-08-13 22:07:32 +08:00
parent bd43a12dee
commit 4a467641aa
7 changed files with 102 additions and 9 deletions
+64
View File
@@ -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
+3 -3
View File
@@ -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). - **~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. - 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`. - 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`. - 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.
@@ -416,6 +416,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
#[ignore = "live network: requires outbound HTTPS to public.api.bsky.app"]
async fn live_fetch_with_photos() { async fn live_fetch_with_photos() {
let fetched = let fetched =
fetch_from_url("https://bsky.app/profile/asagi0398.bsky.social/post/3mqkhrq5w6k2m") fetch_from_url("https://bsky.app/profile/asagi0398.bsky.social/post/3mqkhrq5w6k2m")
@@ -429,6 +430,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
#[ignore = "live network: requires outbound HTTPS to public.api.bsky.app"]
async fn live_fetch_smoke() { async fn live_fetch_smoke() {
let fetched = let fetched =
fetch_from_url("https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224") fetch_from_url("https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224")
+7 -1
View File
@@ -422,7 +422,13 @@ mod tests {
async fn download_media_pixiv_original_with_referer() { async fn download_media_pixiv_original_with_referer() {
// Proves the Referer header is attached for i.pximg.net: a header-less // Proves the Referer header is attached for i.pximg.net: a header-less
// GET to a pixiv original URL is rejected with 403. // 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"); eprintln!("skipping: no PIXIV_REFRESH_TOKEN");
return; return;
} }
+16 -1
View File
@@ -392,16 +392,31 @@ mod tests {
use super::*; use super::*;
use dotenv::dotenv; 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] #[tokio::test]
async fn test_fetch() { async fn test_fetch() {
dotenv().ok(); dotenv().ok();
if !require_pixiv_token() {
eprintln!("skipping: no PIXIV_REFRESH_TOKEN");
return;
}
let result = fetch(126839080).await; let result = fetch(126839080).await;
assert!(result.is_ok()); assert!(result.is_ok());
println!("{:#?}", result); println!("{:#?}", result);
} }
#[tokio::test] #[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(); dotenv().ok();
// A bogus token must surface as Api error (invalid_grant), not panic. // A bogus token must surface as Api error (invalid_grant), not panic.
let client = PixivAPI::new("bogus_token_for_testing".to_string()); let client = PixivAPI::new("bogus_token_for_testing".to_string());
@@ -573,18 +573,21 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
async fn live_fetch_with_photos() { async fn live_fetch_with_photos() {
let fetched = fetch("861627479294746624").await.unwrap(); let fetched = fetch("861627479294746624").await.unwrap();
assert_eq!(fetched.media.len(), 4); assert_eq!(fetched.media.len(), 4);
} }
#[tokio::test] #[tokio::test]
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
async fn live_fetch_text_only() { async fn live_fetch_text_only() {
let fetched = fetch("1992471125734142256").await.unwrap(); let fetched = fetch("1992471125734142256").await.unwrap();
assert!(fetched.media.is_empty()); assert!(fetched.media.is_empty());
} }
#[tokio::test] #[tokio::test]
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
async fn live_fetch_deleted_tweet_is_not_found() { async fn live_fetch_deleted_tweet_is_not_found() {
// Deleted tweet: the syndication endpoint answers with errors. // Deleted tweet: the syndication endpoint answers with errors.
let result = fetch("0").await; let result = fetch("0").await;
+7 -4
View File
@@ -155,7 +155,9 @@ impl Task {
Task::SendMediaSequence { media_batches, .. } => { Task::SendMediaSequence { media_batches, .. } => {
media_batches.iter().flatten().collect() 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(), Task::ForwardMessages { .. } => Vec::new(),
} }
} }
@@ -1333,9 +1335,10 @@ mod tests {
#[test] #[test]
fn oversized_photo_boundary() { fn oversized_photo_boundary() {
// The empirical Telegram limit: sum 10000 passes, 10001 fails. // The empirical Telegram limit: sum 10000 passes, 10001 fails.
assert!(crate::photo::PHOTO_MAX_DIMENSION_SUM == 10000); // Const-block asserts so clippy's assertions_on_constants stays quiet.
assert!(6100 + 3900 <= crate::photo::PHOTO_MAX_DIMENSION_SUM); const { assert!(crate::photo::PHOTO_MAX_DIMENSION_SUM == 10000) };
assert!(6300 + 3730 > crate::photo::PHOTO_MAX_DIMENSION_SUM); const { assert!(6100 + 3900 <= crate::photo::PHOTO_MAX_DIMENSION_SUM) };
const { assert!(6300 + 3730 > crate::photo::PHOTO_MAX_DIMENSION_SUM) };
} }
#[test] #[test]