Skip to main content

max / makenotwork

seed: example-marketplace scaffold + Phase 1 creators & projects Add the `--seed-examples` flow for the testnot.work staging box: a self-contained catalog of fabricated `@example.test` creators, replacing the nightly prod-data restore. Phase 0 (scaffold + guards): `seed` module, `--seed-examples` flag in main.rs, three layered prod-safety guards (opt-in env, host allowlist, no-real-users), wipe-example-data-first reset, guard unit tests. Phase 1 (content): durable `create_example_creator` (non-sandbox so it clears the discover gate), a 5-creator roster with re-skinned profiles, one public project each spanning all four PricingKinds. DB test asserts public visibility, idempotency, and prod-host refusal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-11 19:28 UTC
Commit: 6aaa61ede1d3e8c310ee5c81a7b4358ceb435ffb
Parent: 5ad8b9d
12 files changed, +648 insertions, -0 deletions
@@ -32,6 +32,41 @@ pub async fn create_user(
32 32 Ok(user)
33 33 }
34 34
35 + /// Insert a durable example-marketplace creator (see [`crate::seed`]).
36 + ///
37 + /// Unlike [`create_sandbox_user`], this leaves `is_sandbox` at its `FALSE`
38 + /// default so the account and its projects appear on every public surface
39 + /// (discover/browse/search gate only on `is_sandbox = FALSE`). It grants
40 + /// `can_create_projects`, marks the email verified, and pins the top
41 + /// `creator_tier` so no capability gate blocks the item spread seeded in later
42 + /// phases. Only ever called by the `--seed-examples` flow, which is itself
43 + /// confined to testnot/localhost by [`crate::seed::run`]'s guards.
44 + #[tracing::instrument(skip_all)]
45 + pub async fn create_example_creator(
46 + pool: &PgPool,
47 + username: &Username,
48 + email: &Email,
49 + password_hash: &str,
50 + ) -> Result<DbUser> {
51 + let user = sqlx::query_as::<_, DbUser>(
52 + r#"
53 + INSERT INTO users (
54 + username, email, password_hash,
55 + can_create_projects, email_verified, creator_tier
56 + )
57 + VALUES ($1, $2, $3, TRUE, TRUE, 'everything')
58 + RETURNING *
59 + "#,
60 + )
61 + .bind(username)
62 + .bind(email)
63 + .bind(password_hash)
64 + .fetch_one(pool)
65 + .await?;
66 +
67 + Ok(user)
68 + }
69 +
35 70 /// Fetch a user by primary key. Returns `None` if not found.
36 71 #[tracing::instrument(skip_all)]
37 72 pub async fn get_user_by_id(pool: &PgPool, id: UserId) -> Result<Option<DbUser>> {
@@ -33,6 +33,7 @@ pub mod pricing;
33 33 pub mod pricing_comparison;
34 34 pub mod synckit_billing;
35 35 pub mod scheduler;
36 + pub mod seed;
36 37 pub mod routes;
37 38 pub mod rss;
38 39 pub mod scanning;
@@ -161,6 +161,24 @@ async fn main() {
161 161
162 162 tracing::info!("Migrations complete");
163 163
164 + // Example-marketplace seed for staging (testnot.work). Runs against the
165 + // already-migrated DB, then exits — never during normal boot. Layered guards
166 + // (opt-in env, host allowlist, no-real-users) make it refuse against prod.
167 + // See `makenotwork::seed` and `_private/docs/mnw/testnot-example-seed.md`.
168 + if std::env::args().any(|a| a == "--seed-examples") {
169 + let opts = makenotwork::seed::SeedOptions::from_env(&config.host_url);
170 + match makenotwork::seed::run(&db, &opts).await {
171 + Ok(()) => {
172 + tracing::info!("example seed complete");
173 + std::process::exit(0);
174 + }
175 + Err(e) => {
176 + tracing::error!(error = %e, "example seed refused or failed");
177 + std::process::exit(1);
178 + }
179 + }
180 + }
181 +
164 182 // Create PostgreSQL-backed session store (persists across restarts)
165 183 let session_store = PostgresStore::new(db.clone());
166 184 session_store
@@ -0,0 +1 @@
1 + //! Seed blog posts per project (drives blog + RSS + landing). Filled in Phase 4.
@@ -0,0 +1,162 @@
1 + //! Seed the fabricated example creators (`@example.test`) and hold the roster
2 + //! that drives the rest of the seed.
3 + //!
4 + //! The roster is the single source of truth for Phase 1: each [`CreatorSpec`]
5 + //! carries the creator's public identity plus the one project they own (built by
6 + //! [`super::projects`]). Naming follows the locked 2026-07-11 convention — real
7 + //! apps never appear under their real names; public-domain / CC0 media accounts
8 + //! get thematic handles, purely-generated copy gets invented names.
9 +
10 + use uuid::Uuid;
11 +
12 + use super::{SeedError, EXAMPLE_EMAIL_DOMAIN};
13 + use crate::auth;
14 + use crate::db::{self, DbUser, Email, Username};
15 +
16 + /// The single project a seeded creator owns. Pricing (per-`PricingKind`) and
17 + /// media land on this project's items in Phases 2-3; Phase 1 seeds the shell.
18 + pub struct ProjectSpec {
19 + /// URL slug (auto-suffixed on the rare collision by `create_project`).
20 + pub slug: &'static str,
21 + /// Public project title.
22 + pub title: &'static str,
23 + /// Project description (re-skinned from `content_seed.md`, no real-app names).
24 + pub description: &'static str,
25 + /// Enabled features; `create_project` derives `project_type` from these.
26 + pub features: &'static [&'static str],
27 + }
28 +
29 + /// One fabricated creator plus the project they own.
30 + struct CreatorSpec {
31 + /// Login handle and local-part of `{handle}@example.test`.
32 + handle: &'static str,
33 + /// Display name shown on the profile.
34 + display_name: &'static str,
35 + /// Short bio — matter-of-fact, no pomp, no real-app names.
36 + bio: &'static str,
37 + /// The project this creator owns.
38 + project: ProjectSpec,
39 + }
40 +
41 + /// A creator after insertion, paired with the project spec still to be seeded.
42 + pub struct SeededCreator {
43 + /// The durable, publicly-visible creator row (`is_sandbox = FALSE`).
44 + pub user: DbUser,
45 + /// The project [`super::projects::seed_projects`] will create for this user.
46 + pub project: &'static ProjectSpec,
47 + }
48 +
49 + /// The five Phase-1 creators. Together their projects span all four
50 + /// `PricingKind`s (realized via items in Phase 2). See the sprint doc
51 + /// `_private/docs/mnw/testnot-example-seed.md`.
52 + const ROSTER: &[CreatorSpec] = &[
53 + CreatorSpec {
54 + handle: "openreels",
55 + display_name: "Open Reels",
56 + bio: "A small label restoring and reissuing public-domain recordings. \
57 + Every release is free to share; pay what it's worth if it moves you.",
58 + project: ProjectSpec {
59 + slug: "restored-reels-vol-1",
60 + title: "Restored Reels, Vol. 1",
61 + description: "A first volume of public-domain recordings, cleaned up \
62 + and remastered for easy listening. Free to stream and \
63 + share; name your price if you'd like to support the \
64 + restoration work.",
65 + features: &["audio"],
66 + },
67 + },
68 + CreatorSpec {
69 + handle: "deskriver",
70 + display_name: "Deskriver Tools",
71 + bio: "Source-available desktop tools that stay out of your way. No \
72 + accounts required to run them, no engagement tricks, no cloud \
73 + lock-in.",
74 + project: ProjectSpec {
75 + slug: "deskriver-suite",
76 + title: "Deskriver Suite",
77 + description: "A bundle of small, source-available desktop utilities. \
78 + Runs offline with no account required, stores \
79 + everything locally, and stays out of your way. One \
80 + download, buy it once.",
81 + features: &["downloads", "license_keys"],
82 + },
83 + },
84 + CreatorSpec {
85 + handle: "stillfield",
86 + display_name: "Stillfield",
87 + bio: "Quiet landscape and still-life photography, released to the public \
88 + domain. Download, print, remix — no permission needed.",
89 + project: ProjectSpec {
90 + slug: "cc0-field-library",
91 + title: "CC0 Field Library",
92 + description: "A growing library of landscape and still-life \
93 + photography released to the public domain. Download the \
94 + full-resolution files and use them however you like, no \
95 + attribution required.",
96 + features: &["downloads"],
97 + },
98 + },
99 + CreatorSpec {
100 + handle: "marginalia",
101 + display_name: "Marginalia Press",
102 + bio: "A one-person press typesetting public-domain literature into clean, \
103 + readable editions. New volumes for subscribers each month.",
104 + project: ProjectSpec {
105 + slug: "the-marginalia-reader",
106 + title: "The Marginalia Reader",
107 + description: "Public-domain literature, re-typeset into clean and \
108 + readable editions. Subscribers get a new volume every \
109 + month, delivered as EPUB and PDF.",
110 + features: &["text", "blog", "subscriptions"],
111 + },
112 + },
113 + CreatorSpec {
114 + handle: "commonshare",
115 + display_name: "Commonshare",
116 + bio: "A benefit account: everything here is free, and any support routed \
117 + through it passes straight to community projects. Value to the \
118 + commons, not to us.",
119 + project: ProjectSpec {
120 + slug: "commons-sampler",
121 + title: "Commons Sampler",
122 + description: "A free sampler of work shared through the Commonshare \
123 + benefit account. Everything here is free to download; \
124 + anything routed through the account passes straight to \
125 + community projects.",
126 + features: &["downloads"],
127 + },
128 + },
129 + ];
130 +
131 + /// Create every roster creator: a durable `@example.test` account with a profile.
132 + ///
133 + /// Returns the created accounts paired with their project specs so
134 + /// [`super::projects::seed_projects`] can build one project per creator. Called
135 + /// only from [`super::run`], after its prod-safety guards and the example-data
136 + /// reset. Idempotency comes from that reset (prior example accounts are wiped
137 + /// first), so this always inserts fresh rows.
138 + pub async fn seed_creators(pool: &sqlx::PgPool) -> Result<Vec<SeededCreator>, SeedError> {
139 + let mut seeded = Vec::with_capacity(ROSTER.len());
140 + for spec in ROSTER {
141 + // Never-login account: hash a random string (the sandbox pattern). The
142 + // stored hash is a valid Argon2id hash no one holds the input to.
143 + let password_hash =
144 + auth::hash_password_async(format!("example-seed_{}", Uuid::new_v4())).await?;
145 + let username = Username::from_trusted(spec.handle.to_string());
146 + let email = Email::from_trusted(format!("{}@{EXAMPLE_EMAIL_DOMAIN}", spec.handle));
147 +
148 + let user =
149 + db::users::create_example_creator(pool, &username, &email, &password_hash).await?;
150 + let user =
151 + db::users::update_user_profile(pool, user.id, Some(spec.display_name), Some(spec.bio))
152 + .await?;
153 +
154 + tracing::info!(
155 + handle = spec.handle,
156 + user_id = %user.id,
157 + "example seed: created creator"
158 + );
159 + seeded.push(SeededCreator { user, project: &spec.project });
160 + }
161 + Ok(seeded)
162 + }
@@ -0,0 +1,2 @@
1 + //! Seed at least one item per `ItemType`, across pricing kinds and tags.
2 + //! Filled in Phase 2.
@@ -0,0 +1,2 @@
1 + //! Fetch and upload public-domain / CC0 media (audio, images, docs) through the
2 + //! storage layer, per `media-manifest.toml`. Filled in Phase 3.
@@ -0,0 +1,243 @@
1 + //! Example-marketplace seed for the `testnot.work` staging box.
2 + //!
3 + //! Replaces testnot's nightly prod-data restore with a self-contained catalog of
4 + //! fabricated creators and public-domain items, so staging never holds real user
5 + //! data. Invoked deliberately via the `--seed-examples` flag on the server binary
6 + //! (see `main.rs`), never at boot. Design + sprint plan:
7 + //! `_private/docs/mnw/testnot-example-seed.md`.
8 + //!
9 + //! # Prod safety
10 + //!
11 + //! The seed writes fabricated rows; it must be impossible to run against
12 + //! production. [`run`] enforces three layered guards, all required:
13 + //!
14 + //! 1. `ALLOW_EXAMPLE_SEED=1` must be set (opt-in switch).
15 + //! 2. `HOST_URL` must resolve to an approved example host — `testnot.work` (or a
16 + //! subdomain) or localhost. The prod apex `makenot.work` is not on the
17 + //! allowlist, so it is refused.
18 + //! 3. The `users` table must contain no *real* accounts. "Real" means any email
19 + //! outside the reserved [`EXAMPLE_EMAIL_DOMAIN`]; a database with only example
20 + //! accounts (or none) passes.
21 + //!
22 + //! # Reset semantics
23 + //!
24 + //! Decision (Phase 0): **wipe-example-data-first**. Before seeding, [`run`]
25 + //! deletes every `@example.test` account, which cascades to their projects,
26 + //! items, follows, and transactions (all `ON DELETE CASCADE` on `user_id`). This
27 + //! makes `--seed-examples` idempotent and re-runnable to reset testnot without a
28 + //! full schema drop. It is safe precisely because guard 3 guarantees only example
29 + //! data is ever present when the seed runs. The Phase 6 `mnw-testnot-seed.sh` may
30 + //! still reset the schema for a fully clean slate, but the seed does not depend on
31 + //! that.
32 +
33 + pub mod blog;
34 + pub mod creators;
35 + pub mod items;
36 + pub mod media;
37 + pub mod projects;
38 + pub mod social;
39 +
40 + use sqlx::PgPool;
41 +
42 + /// Reserved email domain for every seeded example account. It is the marker the
43 + /// prod-safety guard uses to tell fabricated seed data from real users: the seed
44 + /// refuses to run when any account with a different domain exists, and the reset
45 + /// step only ever deletes accounts in this domain. (The `003_seed_demo`
46 + /// migration's `elena@example.com` is not a concern — migration
47 + /// `080_remove_demo_data` deletes it, so a freshly-migrated DB holds no
48 + /// non-example accounts.)
49 + pub const EXAMPLE_EMAIL_DOMAIN: &str = "example.test";
50 +
51 + /// Why the example seed refused to run, or how it failed partway through.
52 + #[derive(Debug, thiserror::Error)]
53 + pub enum SeedError {
54 + /// `ALLOW_EXAMPLE_SEED` was not set to `1`.
55 + #[error("refusing to seed: ALLOW_EXAMPLE_SEED is not set to 1")]
56 + NotAllowed,
57 + /// `HOST_URL` did not resolve to an approved example host.
58 + #[error(
59 + "refusing to seed: HOST_URL {0:?} is not an approved example host \
60 + (testnot.work or localhost only)"
61 + )]
62 + ProdHost(String),
63 + /// The database already holds non-example accounts.
64 + #[error(
65 + "refusing to seed: database holds {0} non-example account(s); the example \
66 + seed only runs on a database that contains example data or nothing"
67 + )]
68 + RealUsersPresent(i64),
69 + /// A database error while checking guards, resetting, or seeding.
70 + #[error(transparent)]
71 + Db(#[from] sqlx::Error),
72 + /// An application error from a `db::*` helper or the password hasher while
73 + /// seeding content (the `db` layer returns [`crate::error::AppError`], not a
74 + /// raw `sqlx::Error`).
75 + #[error(transparent)]
76 + App(#[from] crate::error::AppError),
77 + }
78 +
79 + /// Inputs for a seed run, resolved from the process environment and config.
80 + #[derive(Debug, Clone)]
81 + pub struct SeedOptions {
82 + /// `ALLOW_EXAMPLE_SEED=1` was present.
83 + pub allow_example_seed: bool,
84 + /// The configured `HOST_URL` (from [`crate::config::Config`]).
85 + pub host_url: String,
86 + }
87 +
88 + impl SeedOptions {
89 + /// Read the opt-in switch from the environment; take `host_url` from config.
90 + pub fn from_env(host_url: &str) -> Self {
91 + Self {
92 + allow_example_seed: std::env::var("ALLOW_EXAMPLE_SEED").ok().as_deref() == Some("1"),
93 + host_url: host_url.to_string(),
94 + }
95 + }
96 + }
97 +
98 + /// Run the example seed: guards, reset, then content.
99 + ///
100 + /// Refuses (returns `Err`) unless every prod-safety guard passes. On success the
101 + /// database holds a fresh fabricated catalog. Callers should treat this as a
102 + /// one-shot: run it, then exit the process.
103 + pub async fn run(pool: &PgPool, opts: &SeedOptions) -> Result<(), SeedError> {
104 + // Guards 1 + 2: opt-in switch and approved host. Pure, no DB.
105 + check_static_guards(opts.allow_example_seed, &opts.host_url)?;
106 +
107 + // Guard 3: no real accounts present.
108 + let real = count_real_users(pool).await?;
109 + if real > 0 {
110 + return Err(SeedError::RealUsersPresent(real));
111 + }
112 +
113 + tracing::warn!(
114 + host = %opts.host_url,
115 + "example seed: all guards passed; wiping prior example data, then seeding"
116 + );
117 +
118 + // Reset semantics: wipe-example-data-first (see module docs).
119 + let wiped = reset_example_data(pool).await?;
120 + tracing::info!(wiped_users = wiped, "example seed: cleared prior example accounts");
121 +
122 + // Phase 1: durable creators, then one project each. Later phases layer items
123 + // (2), media (3), and blog/social (4) on top of these rows.
124 + let creators = creators::seed_creators(pool).await?;
125 + projects::seed_projects(pool, &creators).await?;
126 + tracing::warn!("example seed: items/media/blog/social pending (Phases 2-4)");
127 +
128 + Ok(())
129 + }
130 +
131 + /// The env + host guards, computed from explicit inputs so they can be unit-tested
132 + /// without a process environment or a database.
133 + pub fn check_static_guards(allow_example_seed: bool, host_url: &str) -> Result<(), SeedError> {
134 + if !allow_example_seed {
135 + return Err(SeedError::NotAllowed);
136 + }
137 + if !is_approved_example_host(host_url) {
138 + return Err(SeedError::ProdHost(host_url.to_string()));
139 + }
140 + Ok(())
141 + }
142 +
143 + /// Whether `host_url`'s host is on the example-seed allowlist. Allowlist, not
144 + /// denylist: only `testnot.work` (and subdomains) and localhost pass, so the prod
145 + /// apex and any unknown host are refused by default.
146 + fn is_approved_example_host(host_url: &str) -> bool {
147 + let host = url::Url::parse(host_url)
148 + .ok()
149 + .and_then(|u| u.host_str().map(str::to_ascii_lowercase));
150 + match host {
151 + Some(h) => {
152 + h == "testnot.work"
153 + || h.ends_with(".testnot.work")
154 + || h == "localhost"
155 + || h == "127.0.0.1"
156 + || h == "::1"
157 + }
158 + // Not a parseable URL with a host — refuse rather than guess.
159 + None => false,
160 + }
161 + }
162 +
163 + /// Count accounts that are NOT example accounts (email domain outside
164 + /// [`EXAMPLE_EMAIL_DOMAIN`]). A non-zero result means real data is present and the
165 + /// seed must refuse.
166 + async fn count_real_users(pool: &PgPool) -> Result<i64, sqlx::Error> {
167 + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE lower(email) NOT LIKE $1")
168 + .bind(format!("%@{EXAMPLE_EMAIL_DOMAIN}"))
169 + .fetch_one(pool)
170 + .await
171 + }
172 +
173 + /// Delete every example account, cascading to its projects, items, and related
174 + /// rows. Returns the number of accounts removed. Safe only after the guards have
175 + /// confirmed no real data is present.
176 + async fn reset_example_data(pool: &PgPool) -> Result<u64, sqlx::Error> {
177 + let res = sqlx::query("DELETE FROM users WHERE lower(email) LIKE $1")
178 + .bind(format!("%@{EXAMPLE_EMAIL_DOMAIN}"))
179 + .execute(pool)
180 + .await?;
181 + Ok(res.rows_affected())
182 + }
183 +
184 + #[cfg(test)]
185 + mod tests {
186 + use super::*;
187 +
188 + #[test]
189 + fn refuses_without_allow_flag() {
190 + let err = check_static_guards(false, "https://testnot.work").unwrap_err();
191 + assert!(matches!(err, SeedError::NotAllowed));
192 + }
193 +
194 + #[test]
195 + fn refuses_prod_apex_even_with_flag() {
196 + // The exact prod-shaped config: allowed switch on, prod host.
197 + let err = check_static_guards(true, "https://makenot.work").unwrap_err();
198 + assert!(matches!(err, SeedError::ProdHost(_)));
199 + }
200 +
201 + #[test]
202 + fn refuses_prod_subdomain() {
203 + for host in [
204 + "https://www.makenot.work",
205 + "https://app.makenot.work",
206 + "https://makenot.work/creators",
207 + ] {
208 + let err = check_static_guards(true, host).unwrap_err();
209 + assert!(matches!(err, SeedError::ProdHost(_)), "should refuse {host}");
210 + }
211 + }
212 +
213 + #[test]
214 + fn allows_testnot_and_localhost() {
215 + for host in [
216 + "https://testnot.work",
217 + "https://testnot.work/",
218 + "https://sub.testnot.work",
219 + "http://localhost:3000",
220 + "http://127.0.0.1:8080",
221 + ] {
222 + assert!(
223 + check_static_guards(true, host).is_ok(),
224 + "should allow {host}"
225 + );
226 + }
227 + }
228 +
229 + #[test]
230 + fn refuses_lookalike_hosts() {
231 + // Substring tricks must not slip past the allowlist.
232 + for host in [
233 + "https://testnot.work.evil.com",
234 + "https://nottestnot.work",
235 + "https://testnot-work.com",
236 + "not-a-url",
237 + "",
238 + ] {
239 + let err = check_static_guards(true, host).unwrap_err();
240 + assert!(matches!(err, SeedError::ProdHost(_)), "should refuse {host}");
241 + }
242 + }
243 + }
@@ -0,0 +1,44 @@
1 + //! Seed one project per creator (their [`ProjectSpec`]), spread across genres.
2 + //!
3 + //! Each project is public the moment it is inserted: `create_project` leaves
4 + //! `is_public` at its `true` default and never sets `is_sandbox`, so the pair
5 + //! (durable creator + project) clears the only two public-visibility gates. The
6 + //! intended per-project `PricingKind` is realized via items in Phase 2; here we
7 + //! only establish the project shell and its `features`.
8 +
9 + use super::creators::SeededCreator;
10 + use super::SeedError;
11 + use crate::db::{self, Slug};
12 +
13 + /// Create the one project each seeded creator owns.
14 + ///
15 + /// Called from [`super::run`] immediately after [`super::creators::seed_creators`],
16 + /// with the creators it returned. `create_project` auto-suffixes any slug
17 + /// collision and defaults the project to public, so no publish step is needed.
18 + pub async fn seed_projects(
19 + pool: &sqlx::PgPool,
20 + creators: &[SeededCreator],
21 + ) -> Result<(), SeedError> {
22 + for creator in creators {
23 + let spec = creator.project;
24 + let slug = Slug::from_trusted(spec.slug.to_string());
25 + let features: Vec<String> = spec.features.iter().map(|f| f.to_string()).collect();
26 +
27 + let project = db::projects::create_project(
28 + pool,
29 + creator.user.id,
30 + &slug,
31 + spec.title,
32 + Some(spec.description),
33 + &features,
34 + )
35 + .await?;
36 +
37 + tracing::info!(
38 + slug = %project.slug,
39 + user_id = %creator.user.id,
40 + "example seed: created project"
41 + );
42 + }
43 + Ok(())
44 + }
@@ -0,0 +1,2 @@
1 + //! Seed follows/fans (and reviews/comments where the surface exists) so counts
2 + //! render. Filled in Phase 4.
@@ -3,6 +3,7 @@ mod auth;
3 3 mod csrf_coverage;
4 4 mod custom_pages;
5 5 mod sso;
6 + mod seed_examples;
6 7 mod discover;
7 8 mod embeds;
8 9 mod enum_drift;
@@ -0,0 +1,137 @@
1 + //! DB-layer contract tests for the `--seed-examples` flow (`seed::run`, Phase 1).
2 + //!
3 + //! These exercise the seed against real Postgres to prove the invariants the
4 + //! example marketplace rests on:
5 + //!
6 + //! 1. It clears the no-real-users guard on a *freshly-migrated* DB (migration
7 + //! `080_remove_demo_data` leaves no non-example accounts) and creates the
8 + //! full roster.
9 + //! 2. Every seeded creator+project is publicly visible — `is_sandbox = FALSE`
10 + //! on the user and `is_public = true` on the project, the only two gates the
11 + //! discover feed applies. The discover predicate returning them is the proof.
12 + //! 3. Idempotency: a second `run()` wipes the prior example data first, so
13 + //! counts stay fixed instead of doubling.
14 + //! 4. The prod-host guard still refuses even with the opt-in flag set.
15 +
16 + use crate::harness::db::TestDb;
17 + use makenotwork::seed::{self, SeedOptions};
18 +
19 + /// The Phase-1 roster size (see `seed::creators::ROSTER`).
20 + const SEEDED_CREATORS: i64 = 5;
21 +
22 + /// Guard-passing options for a test run: opt-in on, approved example host.
23 + fn testnot_opts() -> SeedOptions {
24 + SeedOptions {
25 + allow_example_seed: true,
26 + host_url: "https://testnot.work".to_string(),
27 + }
28 + }
29 +
30 + async fn count_example_creators(pool: &sqlx::PgPool) -> i64 {
31 + sqlx::query_scalar(
32 + "SELECT COUNT(*) FROM users \
33 + WHERE lower(split_part(email, '@', 2)) = 'example.test' AND is_sandbox = FALSE",
34 + )
35 + .fetch_one(pool)
36 + .await
37 + .expect("count example creators")
38 + }
39 +
40 + async fn count_public_example_projects(pool: &sqlx::PgPool) -> i64 {
41 + sqlx::query_scalar(
42 + "SELECT COUNT(*) FROM projects p \
43 + JOIN users u ON u.id = p.user_id \
44 + WHERE lower(split_part(u.email, '@', 2)) = 'example.test' AND p.is_public = TRUE",
45 + )
46 + .fetch_one(pool)
47 + .await
48 + .expect("count public example projects")
49 + }
50 +
51 + /// Slugs of seeded projects that clear the *exact* public-discover gate
52 + /// (`p.is_public AND u.is_sandbox = FALSE`, per `db::discover::discover_projects`).
53 + /// Mirrors that predicate in SQL because the `discover` module is `pub(crate)` and
54 + /// so unreachable from this external test crate.
55 + async fn discover_visible_example_slugs(pool: &sqlx::PgPool) -> Vec<String> {
56 + sqlx::query_scalar::<_, String>(
57 + "SELECT p.slug::text FROM projects p \
58 + JOIN users u ON u.id = p.user_id \
59 + WHERE p.is_public = TRUE AND u.is_sandbox = FALSE \
60 + AND lower(split_part(u.email, '@', 2)) = 'example.test' \
61 + ORDER BY p.slug",
62 + )
63 + .fetch_all(pool)
64 + .await
65 + .expect("discover-visible example slugs")
66 + }
67 +
68 + #[tokio::test]
69 + async fn seeds_publicly_visible_creators_and_projects() {
70 + let db = TestDb::new().await;
71 +
72 + // Precondition the guard relies on: a freshly-migrated DB holds no real
73 + // accounts (080_remove_demo_data cleared the 003 demo seed).
74 + let real: i64 = sqlx::query_scalar(
75 + "SELECT COUNT(*) FROM users WHERE lower(email) NOT LIKE '%@example.test'",
76 + )
77 + .fetch_one(&db.pool)
78 + .await
79 + .expect("count real users");
80 + assert_eq!(real, 0, "a migrated DB should hold no non-example accounts");
81 +
82 + seed::run(&db.pool, &testnot_opts())
83 + .await
84 + .expect("seed should run on a clean migrated DB");
85 +
86 + // All five creators exist and are non-sandbox (publicly visible), and each
87 + // owns a public project.
88 + assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
89 + assert_eq!(count_public_example_projects(&db.pool).await, SEEDED_CREATORS);
90 +
91 + // Public visibility proof: all five projects clear the discover gate.
92 + let slugs = discover_visible_example_slugs(&db.pool).await;
93 + assert_eq!(slugs.len() as i64, SEEDED_CREATORS);
94 + for slug in [
95 + "restored-reels-vol-1",
96 + "deskriver-suite",
97 + "cc0-field-library",
98 + "the-marginalia-reader",
99 + "commons-sampler",
100 + ] {
101 + assert!(
102 + slugs.iter().any(|s| s == slug),
103 + "discover-visible set missing seeded project {slug}"
104 + );
105 + }
106 + }
107 +
108 + #[tokio::test]
109 + async fn seed_is_idempotent() {
110 + let db = TestDb::new().await;
111 +
112 + seed::run(&db.pool, &testnot_opts()).await.expect("first seed");
113 + seed::run(&db.pool, &testnot_opts()).await.expect("second seed");
114 +
115 + // Re-running wipes prior example data first, so counts stay fixed.
116 + assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
117 + assert_eq!(count_public_example_projects(&db.pool).await, SEEDED_CREATORS);
118 + }
119 +
120 + #[tokio::test]
121 + async fn refuses_on_prod_host() {
122 + let db = TestDb::new().await;
123 +
124 + let err = seed::run(
125 + &db.pool,
126 + &SeedOptions {
127 + allow_example_seed: true,
128 + host_url: "https://makenot.work".to_string(),
129 + },
130 + )
131 + .await
132 + .expect_err("prod host must be refused");
133 + assert!(matches!(err, seed::SeedError::ProdHost(_)));
134 +
135 + // Nothing was created, and the demo account is untouched.
136 + assert_eq!(count_example_creators(&db.pool).await, 0);
137 + }