Skip to main content

max / makenotwork

9.7 KB · 245 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 original 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.
28 ///
29 /// A contract file's header must name the file it covers, not only its
30 /// directory module, or the subject credit stops at the module and the file
31 /// reads as untested.
32 ///
33 /// Lower it when you cover one. Never raise it: a new untested file in these
34 /// areas is the thing this seal exists to refuse. If you genuinely need to add
35 /// one, the honest move is to write the test, not to bump the constant.
36 ///
37 /// # 5 is the floor, and it is not a to-do
38 ///
39 /// Every file left is declaration-only: `mod` and `pub use`, a route table, or
40 /// serde wire types. None holds behaviour a test could observe that is not
41 /// already observed by testing the thing it declares, and a test written to
42 /// move this number would be theatre inside a seal whose only value is that it
43 /// is dumb and honest.
44 ///
45 /// - `routes/stripe/mod.rs` and `db/synckit/mod.rs`, the original two.
46 /// - `routes/synckit/mod.rs` (31 lines, doc comment and eleven `mod` lines),
47 /// `routes/synckit/routes.rs` (the router: one function, 94 `.route(` calls
48 /// and nothing else) and `routes/synckit/wire.rs` (six serde types shared by
49 /// `sync` and `groups`). These three are what the 2026-09-04 decomposition
50 /// left behind when `routes/synckit/mod.rs` was cut into eleven files; the
51 /// handlers that carried the behaviour went to `sync.rs`, `keys.rs`,
52 /// `billing.rs` and `blobs.rs`, which carry their tests with them.
53 ///
54 /// So the rise from 2 to 5 is not new untested behaviour, which is the thing
55 /// this seal exists to refuse. It is one covered file becoming one covered set
56 /// plus three declarations. Raising the number for any other reason is the
57 /// dishonest move: write the test instead.
58 ///
59 /// Excluding declaration-only files by rule was considered and declined twice.
60 /// The rule is one sentence long ("how many files in these areas contain no
61 /// test"), and a second concept bolted on to spare five files costs more than
62 /// it saves; a floor that says why is more honest than a rule with a carve-out
63 /// in it. So: do not drive this to 0, and do not treat 5 as work outstanding.
64 const UNTESTED_HIGH_WATER: usize = 5;
65
66 /// Anything that moves money or decides what someone is entitled to.
67 const MONEY: &[&str] = &[
68 "src/payments/",
69 "src/routes/stripe/",
70 "src/pricing",
71 "src/tier_prices.rs",
72 "src/synckit_billing.rs",
73 "src/db/transactions/",
74 "src/db/subscriptions.rs",
75 "src/db/promo_codes.rs",
76 "src/db/fan_plus.rs",
77 "src/db/creator_tiers/",
78 "src/helpers/billing.rs",
79 ];
80
81 /// Anything that stores, serves or synchronises a creator's own data.
82 const USER_DATA: &[&str] = &[
83 "src/storage.rs",
84 "src/routes/storage/",
85 "src/db/items/",
86 "src/db/users.rs",
87 "src/db/synckit/",
88 "src/routes/synckit/",
89 "src/import/",
90 "src/db/pending_",
91 ];
92
93 #[test]
94 fn untested_money_and_data_files_do_not_increase() {
95 let mut money = Vec::new();
96 let mut data = Vec::new();
97 let subjects = declared_contract_subjects();
98
99 for path in rs_files(Path::new("src")) {
100 let rel = path.to_string_lossy().replace('\\', "/");
101 let in_money = MONEY.iter().any(|p| rel.starts_with(p));
102 let in_data = USER_DATA.iter().any(|p| rel.starts_with(p));
103 if !in_money && !in_data {
104 continue;
105 }
106 if has_test(&path) || sibling_tests_file(&path) || subjects.contains(&module_path_of(&rel))
107 {
108 continue;
109 }
110 if in_money { &mut money } else { &mut data }.push(rel);
111 }
112
113 money.sort();
114 data.sort();
115 let total = money.len() + data.len();
116
117 assert!(
118 total <= UNTESTED_HIGH_WATER,
119 "untested money/data files rose from {UNTESTED_HIGH_WATER} to {total} \
120 (money {}, data {}).\n\
121 A new file on these paths needs a test before it lands. If the logic is \
122 async and database-bound, that is a contract test in \
123 `tests/workflows/db_*.rs` whose header reads \"contract tests for \
124 `your::module`\", not a unit test.\n\
125 Money: {money:#?}\nUser data: {data:#?}",
126 money.len(),
127 data.len(),
128 );
129
130 assert_eq!(
131 total, UNTESTED_HIGH_WATER,
132 "untested money/data files fell to {total}. Lower UNTESTED_HIGH_WATER to {total}.",
133 );
134 }
135
136 /// Whether the file carries any test of its own.
137 ///
138 /// Matches the attribute rather than `#[cfg(test)]`, because a `#[cfg(test)]`
139 /// block is not a test. `db/subscriptions.rs` held 811 lines of subscription
140 /// logic behind a `#[cfg(test)]` that contained one test-only constructor and
141 /// no test, and counting modules instead of tests is what hid it.
142 fn has_test(path: &Path) -> bool {
143 let Ok(text) = fs::read_to_string(path) else {
144 return false;
145 };
146 text.contains("#[test]") || text.contains("#[tokio::test") || text.contains("#[sqlx::test")
147 }
148
149 /// Whether the file has a `tests.rs` covering it.
150 ///
151 /// Two shapes count. A file inside a module directory is covered by that
152 /// directory's `tests.rs`, which is how `db/creator_tiers/` keeps its 25 tests.
153 /// A file that is itself a module, `db/promo_codes.rs`, is covered by
154 /// `db/promo_codes/tests.rs`, the sibling form a `mod tests;` declaration
155 /// points at. Without both, the seal would report well-covered files as
156 /// untested and the number would stop meaning anything.
157 fn sibling_tests_file(path: &Path) -> bool {
158 let in_own_dir = path
159 .parent()
160 .is_some_and(|dir| dir.join("tests.rs").exists());
161 let in_module_dir = path
162 .file_stem()
163 .zip(path.parent())
164 .is_some_and(|(stem, dir)| dir.join(stem).join("tests.rs").exists());
165 in_own_dir || in_module_dir
166 }
167
168 /// Every module named as the subject of a contract-test file under `tests/`.
169 ///
170 /// The convention is a doc header reading "contract tests for `db::foo::bar`",
171 /// which twenty-odd files in `tests/workflows/` already follow. Crediting it
172 /// closes a hole that made this seal contradict its own advice: the failure
173 /// message tells you a database-bound module wants a contract test in `tests/`
174 /// rather than a unit test, and then the count refused to see the file you
175 /// wrote.
176 ///
177 /// Deliberately as dumb as the rest of the seal: a declared subject, not an
178 /// inferred one. A test file that does not say what it covers is not credited,
179 /// which keeps the rule cheap to state and hard to satisfy by accident.
180 fn declared_contract_subjects() -> HashSet<String> {
181 let mut subjects = HashSet::new();
182 for path in rs_files(Path::new("tests")) {
183 let Ok(text) = fs::read_to_string(&path) else {
184 continue;
185 };
186 let header: String = text
187 .lines()
188 .take_while(|l| l.starts_with("//!") || l.trim().is_empty())
189 .collect::<Vec<_>>()
190 .join(" ");
191 if !header.contains("contract tests for") {
192 continue;
193 }
194 // Every backticked path in the header, so a file covering several
195 // modules ("`db::tips`, `db::license_keys`, `db::pending_refunds`")
196 // credits all of them.
197 //
198 // Underscores are part of a module name, not a separator: without them
199 // `db::pending_refunds` reads as prose and goes uncredited, which is
200 // how the first cut of this seal landed on 35 instead of 34.
201 for chunk in header.split('`').skip(1).step_by(2) {
202 let path_shaped = chunk.contains("::")
203 && !chunk.ends_with(':')
204 && chunk
205 .chars()
206 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == ':');
207 if path_shaped {
208 subjects.insert(chunk.to_string());
209 }
210 }
211 }
212 subjects
213 }
214
215 /// The module path a source file defines, as a contract-test header would spell
216 /// it: `src/db/synckit/invitations.rs` -> `db::synckit::invitations`, and a
217 /// `mod.rs` names its directory rather than itself.
218 fn module_path_of(rel: &str) -> String {
219 rel.trim_start_matches("src/")
220 .trim_end_matches(".rs")
221 .replace('/', "::")
222 .trim_end_matches("::mod")
223 .to_string()
224 }
225
226 fn rs_files(dir: &Path) -> Vec<PathBuf> {
227 let mut out = Vec::new();
228 let mut stack = vec![dir.to_path_buf()];
229 while let Some(d) = stack.pop() {
230 let Ok(entries) = fs::read_dir(&d) else {
231 continue;
232 };
233 for entry in entries.flatten() {
234 let p = entry.path();
235 if p.is_dir() {
236 stack.push(p);
237 } else if p.extension().is_some_and(|e| e == "rs") {
238 out.push(p);
239 }
240 }
241 }
242 out.sort();
243 out
244 }
245