Skip to main content

max / makenotwork

Load the real docs in the test harness The harness built its DocLoader with `sections: vec![]` against `"."`, so under test the site had no doc pages at all. Two assertions written when the docs half of the sitemap and the search index were fixed had to be dropped, because neither could pass against an empty corpus. Move `build_doc_loader` and the assumptions loading it needs out of main.rs into `site_docs`, so startup, the MNW_CHECK_DOCS gate and the harness all build the loader from one definition rather than the harness keeping an imitation of it. The harness parses the tree once per test binary behind a OnceLock, which is what makes loading the real thing affordable. Restores both assertions, corpus-wide rather than by example: every slug in the search index is served, and every published doc appears in the sitemap. Checked by dropping one page from the sitemap loop, which fails the second test naming the page it lost. Costs about 30s on a 400s integration suite, most of it the per-doc request in the first test.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 15:01 UTC
Signed with PGP, not checked
Commit: f95c2b43fb3856c58e19b9b24dfb726170d7a6cc
Parent: b591ef0
6 files changed, +174 insertions, -64 deletions
@@ -53,6 +53,7 @@
53 53 pub mod scheduler;
54 54 pub mod security_signals;
55 55 pub mod seed;
56 + pub mod site_docs;
56 57 pub mod storage;
57 58 pub mod synckit_auth;
58 59 pub mod synckit_billing;
@@ -12,7 +12,6 @@
12 12 use tower_sessions_sqlx_store::PostgresStore;
13 13 use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt};
14 14
15 - use docengine::{DocLoader, DocLoaderConfig};
16 15 use makenotwork::config::Config;
17 16 use makenotwork::constants;
18 17 use makenotwork::email::{EmailClient, EmailConfig};
@@ -20,7 +19,6 @@
20 19 use makenotwork::scanning::ScanPipeline;
21 20 use makenotwork::storage::S3Client;
22 21 use makenotwork::{AppState, AppStorage, build_app};
23 - use mnw_assumptions::Assumptions;
24 22 use webauthn_rs::WebauthnBuilder;
25 23
26 24 #[tokio::main]
@@ -95,14 +93,14 @@
95 93 // (pre-existing collisions shouldn't red the pipeline). Prints a stable
96 94 // sentinel line, like MNW_CHECK_CONFIG.
97 95 if std::env::var("MNW_CHECK_DOCS").is_ok() {
98 - let assumptions = match load_assumptions() {
96 + let assumptions = match makenotwork::site_docs::load_assumptions() {
99 97 Ok(a) => a,
100 98 Err(e) => {
101 99 eprintln!("MNW_CHECK_DOCS: error: {e}");
102 100 std::process::exit(1);
103 101 }
104 102 };
105 - let docs = build_doc_loader(assumptions);
103 + let docs = makenotwork::site_docs::build_doc_loader(assumptions);
106 104 let broken = docs.broken_links();
107 105 for b in broken {
108 106 eprintln!(
@@ -386,12 +384,14 @@
386 384
387 385 // Load business assumptions (single source of truth for figures in the docs).
388 386 // Validation failure aborts startup, we don't want to serve stale numbers.
389 - let assumptions = load_assumptions().unwrap_or_else(|e| panic!("{e}"));
387 + let assumptions = makenotwork::site_docs::load_assumptions().unwrap_or_else(|e| panic!("{e}"));
390 388
391 389 // Load documentation pages from disk. The same builder backs the DB-free
392 390 // MNW_CHECK_DOCS integrity check above, so what boots and what is checked
393 391 // are the same corpus under the same config.
394 - let docs = std::sync::Arc::new(build_doc_loader(assumptions.clone()));
392 + let docs = std::sync::Arc::new(makenotwork::site_docs::build_doc_loader(
393 + assumptions.clone(),
394 + ));
395 395
396 396 // Initialize file scanning pipeline (optional). If scanning is configured,
397 397 // assert at least one AV layer is actually live, otherwise the FailOpen
@@ -499,8 +499,9 @@
499 499 let tier_prices = makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions);
500 500 tier_prices.clone().install_global();
501 501 let runway_config = makenotwork::tier_prices::RunwayConfig::from_assumptions(&assumptions);
502 - let pricing_comparison =
503 - makenotwork::pricing_comparison::PricingComparison::load(assumptions_path());
502 + let pricing_comparison = makenotwork::pricing_comparison::PricingComparison::load(
503 + makenotwork::site_docs::assumptions_path(),
504 + );
504 505
505 506 let state = AppState::build(makenotwork::AppStateParts {
506 507 db,
@@ -745,52 +746,6 @@
745 746
746 747 /// Filesystem path to the business-assumptions TOML, `ASSUMPTIONS_PATH` or its
747 748 /// default. Single source for the default so every reader agrees.
748 - fn assumptions_path() -> String {
749 - std::env::var("ASSUMPTIONS_PATH")
750 - .unwrap_or_else(|_| "docs/business/assumptions.toml".to_string())
751 - }
752 -
753 - /// Load and validate business assumptions. `Err` carries a human-readable
754 - /// message; normal startup turns it into a panic, the check modes into a
755 - /// sentinel line. Shared so the two paths validate identically.
756 - fn load_assumptions() -> Result<std::sync::Arc<Assumptions>, String> {
757 - let assumptions_path = assumptions_path();
758 - let a = Assumptions::load(&assumptions_path)
759 - .map_err(|e| format!("failed to load assumptions from {assumptions_path}: {e}"))?;
760 - a.validate()
761 - .map_err(|e| format!("assumptions validation failed:\n{e}"))?;
762 - tracing::info!(path = %assumptions_path, "assumptions loaded and validated");
763 - Ok(std::sync::Arc::new(a))
764 - }
765 -
766 - /// Build the documentation loader from disk with the production config.
767 - ///
768 - /// Shared by normal startup and the `MNW_CHECK_DOCS` integrity check so both
769 - /// see the exact same sections, link prefix, examples path, and assumption
770 - /// substitution, so the broken-link report can't drift from what is served.
771 - fn build_doc_loader(assumptions: std::sync::Arc<Assumptions>) -> DocLoader {
772 - let docs_path = std::env::var("DOCS_PATH").unwrap_or_else(|_| "site-docs/public".to_string());
773 - DocLoader::load(
774 - std::path::Path::new(&docs_path),
775 - &DocLoaderConfig {
776 - sections: vec![
777 - ("about".to_string(), "About".to_string()),
778 - ("guide".to_string(), "Guide".to_string()),
779 - ("developer".to_string(), "Developer".to_string()),
780 - ("legal".to_string(), "Legal".to_string()),
781 - ("support".to_string(), "Support".to_string()),
782 - ("tech".to_string(), "Tech".to_string()),
783 - ],
784 - link_prefix: "/docs".to_string(),
785 - unpublished_pattern: Some("unpublished/".to_string()),
786 - examples_path: Some(std::path::Path::new(&docs_path).join("../examples")),
787 - pre_process: Some(Box::new(move |md: &str| {
788 - assumptions.substitute(md).map_err(|e| e.to_string())
789 - })),
790 - },
791 - )
792 - }
793 -
794 749 /// Shutdown budget, reconciled so the background-pool drain can't be truncated
795 750 /// by the hard-exit deadline (audit Run 23 F4). The hard-exit timer arms at
796 751 /// signal receipt and must outlast the in-flight request drain PLUS the
@@ -23,6 +23,24 @@
23 23 }
24 24
25 25 use docengine::DocLoader;
26 +
27 + /// The real published docs, parsed once per test binary.
28 + ///
29 + /// The harness used to build a loader with `sections: vec![]` against `"."`, so
30 + /// under test the site had no doc pages at all and nothing could cover `/docs`
31 + /// or the docs half of the sitemap. Loading the real tree costs a parse, which
32 + /// is why it is shared rather than rebuilt per `TestHarness::new`, and it goes
33 + /// through `site_docs::build_doc_loader` so the config cannot drift from the
34 + /// one production uses.
35 + fn site_docs() -> Arc<DocLoader> {
36 + static DOCS: std::sync::OnceLock<Arc<DocLoader>> = std::sync::OnceLock::new();
37 + DOCS.get_or_init(|| {
38 + let assumptions = makenotwork::site_docs::load_assumptions()
39 + .expect("tests run from the crate root, where the assumptions file is");
40 + Arc::new(makenotwork::site_docs::build_doc_loader(assumptions))
41 + })
42 + .clone()
43 + }
26 44 use makenotwork::config::{
27 45 BuildConfig, Config, CreatorTierPricing, EmailWebhookConfig, IntegrationsConfig, ScanConfig,
28 46 StripeConfig,
@@ -462,16 +480,7 @@
462 480 },
463 481 stripe: opts.stripe_client,
464 482 email,
465 - docs: Arc::new(DocLoader::load(
466 - std::path::Path::new("."),
467 - &docengine::DocLoaderConfig {
468 - sections: vec![],
469 - link_prefix: "/docs".to_string(),
470 - unpublished_pattern: None,
471 - examples_path: None,
472 - pre_process: None,
473 - },
474 - )),
483 + docs: site_docs(),
475 484 tier_prices: {
476 485 // Install the process-global TierPrices so handler code paths
477 486 // that call CreatorTier::{price_cents,max_file_bytes,
@@ -47,6 +47,7 @@
47 47 mod db_users_layer;
48 48 mod db_webhook_events;
49 49 mod discover;
50 + mod docs_site;
50 51 mod embeds;
51 52 mod enum_drift;
52 53 mod exports;
@@ -1,0 +1,70 @@
1 + //! Loading the published documentation tree (`site-docs/public`).
2 + //!
3 + //! Lives in the library rather than in `main.rs` so the test harness builds the
4 + //! loader the same way production does. It used to be a private binary
5 + //! function, and the harness could only construct a `DocLoader` with
6 + //! `sections: vec![]`, so under test the site had no doc pages at all: nothing
7 + //! could cover `/docs`, and the two integration tests that would have proven
8 + //! the docs half of the sitemap and the search index had to be dropped.
9 + //!
10 + //! One definition means the harness cannot drift from the server the way an
11 + //! imitation of this config would.
12 +
13 + use docengine::{DocLoader, DocLoaderConfig};
14 + use mnw_assumptions::Assumptions;
15 + use std::sync::Arc;
16 +
17 + /// Where the published docs live. `DOCS_PATH` overrides for a deployment whose
18 + /// working directory is not the crate root.
19 + pub fn docs_path() -> String {
20 + std::env::var("DOCS_PATH").unwrap_or_else(|_| "site-docs/public".to_string())
21 + }
22 +
23 + /// Where the business assumptions live, substituted into doc bodies.
24 + pub fn assumptions_path() -> String {
25 + std::env::var("ASSUMPTIONS_PATH")
26 + .unwrap_or_else(|_| "docs/business/assumptions.toml".to_string())
27 + }
28 +
29 + /// Load and validate business assumptions. `Err` carries a human-readable
30 + /// message; normal startup turns it into a panic, the check modes into a
31 + /// sentinel line. Shared so the two paths validate identically.
32 + pub fn load_assumptions() -> Result<Arc<Assumptions>, String> {
33 + let path = assumptions_path();
34 + let a = Assumptions::load(&path)
35 + .map_err(|e| format!("failed to load assumptions from {path}: {e}"))?;
36 + a.validate()
37 + .map_err(|e| format!("assumptions validation failed:\n{e}"))?;
38 + tracing::info!(path = %path, "assumptions loaded and validated");
39 + Ok(Arc::new(a))
40 + }
41 +
42 + /// Build the documentation loader from disk with the production config.
43 + ///
44 + /// Shared by normal startup, the `MNW_CHECK_DOCS` integrity check, and the test
45 + /// harness, so all three see the exact same sections, link prefix, examples
46 + /// path, and assumption substitution. The broken-link report cannot drift from
47 + /// what is served, and a test asserting over `/docs` is asserting over the real
48 + /// tree.
49 + pub fn build_doc_loader(assumptions: Arc<Assumptions>) -> DocLoader {
50 + let docs_path = docs_path();
51 + DocLoader::load(
52 + std::path::Path::new(&docs_path),
53 + &DocLoaderConfig {
54 + sections: vec![
55 + ("about".to_string(), "About".to_string()),
56 + ("guide".to_string(), "Guide".to_string()),
57 + ("developer".to_string(), "Developer".to_string()),
58 + ("legal".to_string(), "Legal".to_string()),
59 + ("support".to_string(), "Support".to_string()),
60 + ("tech".to_string(), "Tech".to_string()),
61 + ],
62 + link_prefix: "/docs".to_string(),
63 + unpublished_pattern: Some("unpublished/".to_string()),
64 + examples_path: Some(std::path::Path::new(&docs_path).join("../examples")),
65 + pre_process: Some(Box::new(move |md: &str| {
66 + assumptions.substitute(md).map_err(|e| e.to_string())
67 + })),
68 + },
69 + )
70 + }
@@ -1,0 +1,74 @@
1 + //! The published documentation tree, end to end.
2 + //!
3 + //! These two assertions were written when the docs half of the sitemap and the
4 + //! search index were fixed, then dropped: the harness built its `DocLoader`
5 + //! with `sections: vec![]`, so under test the site had no doc pages and neither
6 + //! test could pass. The harness now loads the real tree through the same
7 + //! `site_docs::build_doc_loader` production uses, so they hold again.
8 + //!
9 + //! Both are corpus-wide rather than example-based on purpose. A test naming one
10 + //! slug pins that slug; these fail when any page stops being reachable, which is
11 + //! the failure that actually happens when a doc is added or a section renamed.
12 +
13 + use crate::harness::TestHarness;
14 + use serde_json::Value;
15 +
16 + /// Every page in the search index must be reachable. The index is what the
17 + /// client-side filter searches, so a slug present here but not served is a
18 + /// result that 404s when clicked.
19 + #[tokio::test]
20 + async fn every_indexed_doc_slug_is_served() {
21 + let mut h = TestHarness::new().await;
22 +
23 + let resp = h.client.get("/docs/search.json").await;
24 + assert_eq!(resp.status, 200, "search index should be served");
25 + let index: Vec<Value> = resp.json();
26 + assert!(
27 + !index.is_empty(),
28 + "the search index is empty, which means the harness loaded no docs and \
29 + this test is proving nothing"
30 + );
31 +
32 + for entry in &index {
33 + let slug = entry["slug"].as_str().expect("each entry carries a slug");
34 + let page = h.client.get(&format!("/docs/{slug}")).await;
35 + assert_eq!(
36 + page.status, 200,
37 + "/docs/{slug} is in the search index but is not served"
38 + );
39 + }
40 + }
41 +
42 + /// The sitemap is how a crawler finds the docs at all. It carries one URL per
43 + /// indexed page, and a doc missing from it is a doc that is published but
44 + /// undiscoverable.
45 + #[tokio::test]
46 + async fn the_sitemap_carries_every_doc_url() {
47 + let mut h = TestHarness::new().await;
48 +
49 + let resp = h.client.get("/docs/search.json").await;
50 + let index: Vec<Value> = resp.json();
51 + let slugs: Vec<String> = index
52 + .iter()
53 + .map(|e| e["slug"].as_str().expect("slug").to_string())
54 + .collect();
55 + assert!(
56 + !slugs.is_empty(),
57 + "no docs loaded, nothing is being asserted"
58 + );
59 +
60 + let resp = h.client.get("/sitemap.xml").await;
61 + assert_eq!(resp.status, 200, "sitemap should be served");
62 + let xml = resp.text;
63 +
64 + assert!(
65 + xml.contains("/docs</loc>") || xml.contains("/docs<"),
66 + "the docs index itself belongs in the sitemap"
67 + );
68 + for slug in &slugs {
69 + assert!(
70 + xml.contains(&format!("/docs/{slug}</loc>")),
71 + "/docs/{slug} is published but missing from the sitemap"
72 + );
73 + }
74 + }