//! Coverage ratchet for the two areas where being wrong is most expensive: //! anything that moves money, and anything that holds a creator's data. //! //! The same idea as `test_hygiene.rs`, turned on the source rather than on the //! tests. It counts files in those areas that contain no test of any kind and //! fails when the number goes up. It cannot make anyone write a good test; it //! can stop a new payment handler or sync table from landing with none at all, //! which is how the current 34 accumulated, one reasonable-looking file at a //! time. //! //! Deliberately dumb. A file counts as covered if it holds a test attribute, if //! its directory has a sibling `tests.rs`, or if a file under `tests/` names it //! as the subject of a contract test. No coverage instrumentation, no AST walk: //! a rule cheap enough to state as //! "how many files look like this" is a rule that stays honest, and mutation //! testing (`.cargo/mutants.toml`) is where the harder question of whether the //! tests are any *good* gets asked. //! //! Method, tiers and the full file list: wiki `testing-posture`. //! //! Run with: cargo test --test untested_money_paths use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; /// Money and user-data files with no test at all: 34 on 2026-08-07, down from /// 41, which was down from 50 on 2026-08-04. /// /// The drop from 41 is not seven new tests. It is `declared_contract_subjects` /// below finally crediting the eight files whose tests live in a /// `tests/workflows/db_*.rs` contract file, which the count had been reporting /// as untested all along. The seal got more accurate, not looser. /// /// Lower it when you cover one. Never raise it: a new untested file in these /// areas is the thing this seal exists to refuse. If you genuinely need to add /// one, the honest move is to write the test, not to bump the constant. const UNTESTED_HIGH_WATER: usize = 34; /// Anything that moves money or decides what someone is entitled to. const MONEY: &[&str] = &[ "src/payments/", "src/routes/stripe/", "src/pricing", "src/tier_prices.rs", "src/synckit_billing.rs", "src/db/transactions/", "src/db/subscriptions.rs", "src/db/promo_codes.rs", "src/db/fan_plus.rs", "src/db/creator_tiers/", "src/helpers/billing.rs", ]; /// Anything that stores, serves or synchronises a creator's own data. const USER_DATA: &[&str] = &[ "src/storage.rs", "src/routes/storage/", "src/db/items/", "src/db/users.rs", "src/db/synckit/", "src/routes/synckit/", "src/import/", "src/db/pending_", ]; #[test] fn untested_money_and_data_files_do_not_increase() { let mut money = Vec::new(); let mut data = Vec::new(); let subjects = declared_contract_subjects(); for path in rs_files(Path::new("src")) { let rel = path.to_string_lossy().replace('\\', "/"); let in_money = MONEY.iter().any(|p| rel.starts_with(p)); let in_data = USER_DATA.iter().any(|p| rel.starts_with(p)); if !in_money && !in_data { continue; } if has_test(&path) || sibling_tests_file(&path) || subjects.contains(&module_path_of(&rel)) { continue; } if in_money { &mut money } else { &mut data }.push(rel); } money.sort(); data.sort(); let total = money.len() + data.len(); assert!( total <= UNTESTED_HIGH_WATER, "untested money/data files rose from {UNTESTED_HIGH_WATER} to {total} \ (money {}, data {}).\n\ A new file on these paths needs a test before it lands. If the logic is \ async and database-bound, that is a contract test in \ `tests/workflows/db_*.rs` whose header reads \"contract tests for \ `your::module`\", not a unit test.\n\ Money: {money:#?}\nUser data: {data:#?}", money.len(), data.len(), ); assert_eq!( total, UNTESTED_HIGH_WATER, "untested money/data files fell to {total}. Lower UNTESTED_HIGH_WATER to {total}.", ); } /// Whether the file carries any test of its own. /// /// Matches the attribute rather than `#[cfg(test)]`, because a `#[cfg(test)]` /// block is not a test. `db/subscriptions.rs` held 811 lines of subscription /// logic behind a `#[cfg(test)]` that contained one test-only constructor and /// no test, and counting modules instead of tests is what hid it. fn has_test(path: &Path) -> bool { let Ok(text) = fs::read_to_string(path) else { return false; }; text.contains("#[test]") || text.contains("#[tokio::test") || text.contains("#[sqlx::test") } /// Whether the file's own directory has a `tests.rs` covering it. /// /// `db/creator_tiers/` keeps its 25 tests in a sibling file, which is the only /// place in the crate that does. Without this, the seal would report three /// well-covered files as untested and the number would stop meaning anything. fn sibling_tests_file(path: &Path) -> bool { path.parent() .is_some_and(|dir| dir.join("tests.rs").exists()) } /// Every module named as the subject of a contract-test file under `tests/`. /// /// The convention is a doc header reading "contract tests for `db::foo::bar`", /// which twenty-odd files in `tests/workflows/` already follow. Crediting it /// closes a hole that made this seal contradict its own advice: the failure /// message tells you a database-bound module wants a contract test in `tests/` /// rather than a unit test, and then the count refused to see the file you /// wrote. `db/synckit/invitations.rs` landed on 2026-08-07 with eleven such /// tests and was still reported untested. /// /// Deliberately as dumb as the rest of the seal: a declared subject, not an /// inferred one. A test file that does not say what it covers is not credited, /// which keeps the rule cheap to state and hard to satisfy by accident. fn declared_contract_subjects() -> HashSet { let mut subjects = HashSet::new(); for path in rs_files(Path::new("tests")) { let Ok(text) = fs::read_to_string(&path) else { continue; }; let header: String = text .lines() .take_while(|l| l.starts_with("//!") || l.trim().is_empty()) .collect::>() .join(" "); if !header.contains("contract tests for") { continue; } // Every backticked path in the header, so a file covering several // modules ("`db::tips`, `db::license_keys`, `db::pending_refunds`") // credits all of them. // // Underscores are part of a module name, not a separator: without them // `db::pending_refunds` reads as prose and goes uncredited, which is // how the first cut of this seal landed on 35 instead of 34. for chunk in header.split('`').skip(1).step_by(2) { let path_shaped = chunk.contains("::") && !chunk.ends_with(':') && chunk .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == ':'); if path_shaped { subjects.insert(chunk.to_string()); } } } subjects } /// The module path a source file defines, as a contract-test header would spell /// it: `src/db/synckit/invitations.rs` -> `db::synckit::invitations`, and a /// `mod.rs` names its directory rather than itself. fn module_path_of(rel: &str) -> String { rel.trim_start_matches("src/") .trim_end_matches(".rs") .replace('/', "::") .trim_end_matches("::mod") .to_string() } fn rs_files(dir: &Path) -> Vec { let mut out = Vec::new(); let mut stack = vec![dir.to_path_buf()]; while let Some(d) = stack.pop() { let Ok(entries) = fs::read_dir(&d) else { continue; }; for entry in entries.flatten() { let p = entry.path(); if p.is_dir() { stack.push(p); } else if p.extension().is_some_and(|e| e == "rs") { out.push(p); } } } out.sort(); out }