max / makenotwork
| 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 HashSet; |
| 24 | use fs; |
| 25 | use ; |
| 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: & = & |
| 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: & = & |
| 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 | |
| 94 | |
| 95 | let mut money = Vecnew; |
| 96 | let mut data = Vecnew; |
| 97 | let subjects = declared_contract_subjects; |
| 98 | |
| 99 | for path in rs_files |
| 100 | let rel = path.to_string_lossy.replace; |
| 101 | let in_money = MONEY.iter.any; |
| 102 | let in_data = USER_DATA.iter.any; |
| 103 | if !in_money && !in_data |
| 104 | continue; |
| 105 | |
| 106 | if has_test || sibling_tests_file || subjects.contains |
| 107 | |
| 108 | continue; |
| 109 | |
| 110 | if in_money else .push; |
| 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 | |
| 143 | let Ok = read_to_string else |
| 144 | return false; |
| 145 | ; |
| 146 | text.contains || text.contains || text.contains |
| 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 | |
| 158 | let in_own_dir = path |
| 159 | .parent |
| 160 | .is_some_and; |
| 161 | let in_module_dir = path |
| 162 | .file_stem |
| 163 | .zip |
| 164 | .is_some_and; |
| 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 | |
| 181 | let mut subjects = new; |
| 182 | for path in rs_files |
| 183 | let Ok = read_to_string else |
| 184 | continue; |
| 185 | ; |
| 186 | let header: String = text |
| 187 | .lines |
| 188 | .take_while |
| 189 | . |
| 190 | .join; |
| 191 | if !header.contains |
| 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.step_by |
| 202 | let path_shaped = chunk.contains |
| 203 | && !chunk.ends_with |
| 204 | && chunk |
| 205 | .chars |
| 206 | .all; |
| 207 | if path_shaped |
| 208 | subjects.insert; |
| 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 | |
| 219 | rel.trim_start_matches |
| 220 | .trim_end_matches |
| 221 | .replace |
| 222 | .trim_end_matches |
| 223 | .to_string |
| 224 | |
| 225 | |
| 226 | |
| 227 | let mut out = Vecnew; |
| 228 | let mut stack = vec!; |
| 229 | while let Some = stack.pop |
| 230 | let Ok = read_dir else |
| 231 | continue; |
| 232 | ; |
| 233 | for entry in entries.flatten |
| 234 | let p = entry.path; |
| 235 | if p.is_dir |
| 236 | stack.push; |
| 237 | else if p.extension.is_some_and |
| 238 | out.push; |
| 239 | |
| 240 | |
| 241 | |
| 242 | out.sort; |
| 243 | out |
| 244 | |
| 245 |