Skip to main content

max / makenotwork

8.3 KB · 216 lines History Blame Raw
1 //! Coverage ratchet for the two areas where being wrong is most expensive:
2 //! anything that moves money, and anything that holds a creator's data.
3 //!
4 //! The same idea as `test_hygiene.rs`, turned on the source rather than on the
5 //! tests. It counts files in those areas that contain no test of any kind and
6 //! fails when the number goes up. It cannot make anyone write a good test; it
7 //! can stop a new payment handler or sync table from landing with none at all,
8 //! which is how the current 34 accumulated, one reasonable-looking file at a
9 //! time.
10 //!
11 //! Deliberately dumb. A file counts as covered if it holds a test attribute, if
12 //! its directory has a sibling `tests.rs`, or if a file under `tests/` names it
13 //! as the subject of a contract test. No coverage instrumentation, no AST walk:
14 //! a rule cheap enough to state as
15 //! "how many files look like this" is a rule that stays honest, and mutation
16 //! testing (`.cargo/mutants.toml`) is where the harder question of whether the
17 //! tests are any *good* gets asked.
18 //!
19 //! Method, tiers and the full file list: wiki `testing-posture`.
20 //!
21 //! Run with: cargo test --test untested_money_paths
22
23 use std::collections::HashSet;
24 use std::fs;
25 use std::path::{Path, PathBuf};
26
27 /// Money and user-data files with no test at all: 33 on 2026-08-23, down from
28 /// 34, which was down from 41 and from 50 on 2026-08-04.
29 ///
30 /// The 34 to 33 step is `db/transactions/purchases.rs`. Its contract tests were
31 /// in `tests/workflows/db_transactions_layer.rs` all along; that file's header
32 /// named only `db::transactions`, so the subject credit stopped at the
33 /// directory module and the file it actually tests read as untested.
34 ///
35 /// The drop from 41 is not seven new tests. It is `declared_contract_subjects`
36 /// below finally crediting the eight files whose tests live in a
37 /// `tests/workflows/db_*.rs` contract file, which the count had been reporting
38 /// as untested all along. The seal got more accurate, not looser.
39 ///
40 /// Lower it when you cover one. Never raise it: a new untested file in these
41 /// areas is the thing this seal exists to refuse. If you genuinely need to add
42 /// one, the honest move is to write the test, not to bump the constant.
43 const UNTESTED_HIGH_WATER: usize = 33;
44
45 /// Anything that moves money or decides what someone is entitled to.
46 const MONEY: &[&str] = &[
47 "src/payments/",
48 "src/routes/stripe/",
49 "src/pricing",
50 "src/tier_prices.rs",
51 "src/synckit_billing.rs",
52 "src/db/transactions/",
53 "src/db/subscriptions.rs",
54 "src/db/promo_codes.rs",
55 "src/db/fan_plus.rs",
56 "src/db/creator_tiers/",
57 "src/helpers/billing.rs",
58 ];
59
60 /// Anything that stores, serves or synchronises a creator's own data.
61 const USER_DATA: &[&str] = &[
62 "src/storage.rs",
63 "src/routes/storage/",
64 "src/db/items/",
65 "src/db/users.rs",
66 "src/db/synckit/",
67 "src/routes/synckit/",
68 "src/import/",
69 "src/db/pending_",
70 ];
71
72 #[test]
73 fn untested_money_and_data_files_do_not_increase() {
74 let mut money = Vec::new();
75 let mut data = Vec::new();
76 let subjects = declared_contract_subjects();
77
78 for path in rs_files(Path::new("src")) {
79 let rel = path.to_string_lossy().replace('\\', "/");
80 let in_money = MONEY.iter().any(|p| rel.starts_with(p));
81 let in_data = USER_DATA.iter().any(|p| rel.starts_with(p));
82 if !in_money && !in_data {
83 continue;
84 }
85 if has_test(&path) || sibling_tests_file(&path) || subjects.contains(&module_path_of(&rel))
86 {
87 continue;
88 }
89 if in_money { &mut money } else { &mut data }.push(rel);
90 }
91
92 money.sort();
93 data.sort();
94 let total = money.len() + data.len();
95
96 assert!(
97 total <= UNTESTED_HIGH_WATER,
98 "untested money/data files rose from {UNTESTED_HIGH_WATER} to {total} \
99 (money {}, data {}).\n\
100 A new file on these paths needs a test before it lands. If the logic is \
101 async and database-bound, that is a contract test in \
102 `tests/workflows/db_*.rs` whose header reads \"contract tests for \
103 `your::module`\", not a unit test.\n\
104 Money: {money:#?}\nUser data: {data:#?}",
105 money.len(),
106 data.len(),
107 );
108
109 assert_eq!(
110 total, UNTESTED_HIGH_WATER,
111 "untested money/data files fell to {total}. Lower UNTESTED_HIGH_WATER to {total}.",
112 );
113 }
114
115 /// Whether the file carries any test of its own.
116 ///
117 /// Matches the attribute rather than `#[cfg(test)]`, because a `#[cfg(test)]`
118 /// block is not a test. `db/subscriptions.rs` held 811 lines of subscription
119 /// logic behind a `#[cfg(test)]` that contained one test-only constructor and
120 /// no test, and counting modules instead of tests is what hid it.
121 fn has_test(path: &Path) -> bool {
122 let Ok(text) = fs::read_to_string(path) else {
123 return false;
124 };
125 text.contains("#[test]") || text.contains("#[tokio::test") || text.contains("#[sqlx::test")
126 }
127
128 /// Whether the file's own directory has a `tests.rs` covering it.
129 ///
130 /// `db/creator_tiers/` keeps its 25 tests in a sibling file, which is the only
131 /// place in the crate that does. Without this, the seal would report three
132 /// well-covered files as untested and the number would stop meaning anything.
133 fn sibling_tests_file(path: &Path) -> bool {
134 path.parent()
135 .is_some_and(|dir| dir.join("tests.rs").exists())
136 }
137
138 /// Every module named as the subject of a contract-test file under `tests/`.
139 ///
140 /// The convention is a doc header reading "contract tests for `db::foo::bar`",
141 /// which twenty-odd files in `tests/workflows/` already follow. Crediting it
142 /// closes a hole that made this seal contradict its own advice: the failure
143 /// message tells you a database-bound module wants a contract test in `tests/`
144 /// rather than a unit test, and then the count refused to see the file you
145 /// wrote. `db/synckit/invitations.rs` landed on 2026-08-07 with eleven such
146 /// tests and was still reported untested.
147 ///
148 /// Deliberately as dumb as the rest of the seal: a declared subject, not an
149 /// inferred one. A test file that does not say what it covers is not credited,
150 /// which keeps the rule cheap to state and hard to satisfy by accident.
151 fn declared_contract_subjects() -> HashSet<String> {
152 let mut subjects = HashSet::new();
153 for path in rs_files(Path::new("tests")) {
154 let Ok(text) = fs::read_to_string(&path) else {
155 continue;
156 };
157 let header: String = text
158 .lines()
159 .take_while(|l| l.starts_with("//!") || l.trim().is_empty())
160 .collect::<Vec<_>>()
161 .join(" ");
162 if !header.contains("contract tests for") {
163 continue;
164 }
165 // Every backticked path in the header, so a file covering several
166 // modules ("`db::tips`, `db::license_keys`, `db::pending_refunds`")
167 // credits all of them.
168 //
169 // Underscores are part of a module name, not a separator: without them
170 // `db::pending_refunds` reads as prose and goes uncredited, which is
171 // how the first cut of this seal landed on 35 instead of 34.
172 for chunk in header.split('`').skip(1).step_by(2) {
173 let path_shaped = chunk.contains("::")
174 && !chunk.ends_with(':')
175 && chunk
176 .chars()
177 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == ':');
178 if path_shaped {
179 subjects.insert(chunk.to_string());
180 }
181 }
182 }
183 subjects
184 }
185
186 /// The module path a source file defines, as a contract-test header would spell
187 /// it: `src/db/synckit/invitations.rs` -> `db::synckit::invitations`, and a
188 /// `mod.rs` names its directory rather than itself.
189 fn module_path_of(rel: &str) -> String {
190 rel.trim_start_matches("src/")
191 .trim_end_matches(".rs")
192 .replace('/', "::")
193 .trim_end_matches("::mod")
194 .to_string()
195 }
196
197 fn rs_files(dir: &Path) -> Vec<PathBuf> {
198 let mut out = Vec::new();
199 let mut stack = vec![dir.to_path_buf()];
200 while let Some(d) = stack.pop() {
201 let Ok(entries) = fs::read_dir(&d) else {
202 continue;
203 };
204 for entry in entries.flatten() {
205 let p = entry.path();
206 if p.is_dir() {
207 stack.push(p);
208 } else if p.extension().is_some_and(|e| e == "rs") {
209 out.push(p);
210 }
211 }
212 }
213 out.sort();
214 out
215 }
216