//! The demo buyer: one login-capable account with a purchase history, so //! `/library` can be photographed. //! //! `/library` is 401 to anonymous and every other seeded account is a creator //! with nothing bought, so a third of the pitch (buyers keep what they bought, //! one-click export) could not be shown to anyone. This account exists to close //! that, and nothing more. //! //! # Scope //! //! This is a capture-only credential used by the screenshot run and nobody else. //! A visitor-facing temp-account path on testnot is separate work (Phase 8-9 of //! `_private/docs/mnw/testnot-example-seed.md`), and the boundary matters: //! **nothing on the public demo changes**. No login CTA, no temp-account //! endpoint, no `ALLOW_TEMP_ACCOUNTS`, and the account is linked from nowhere. //! An anonymous visitor to testnot still cannot reach `/library`. That is a //! known, deliberate gap, and the temp-account path is what closes it. //! //! # Why it is opt-in, and why it lives in the seed //! //! Same two reasons as [`super::harness`]. The password is a credential and does //! not belong in a public repo, so the phase runs only when [`PASSWORD_ENV`] is //! set and skips with a warning otherwise. And `mnw-testnot-seed.sh` drops every //! schema before it reseeds, so an account created by hand is gone on the next //! reset with nothing in the diff to explain why the capture run started failing //! at login. //! //! The prod guards in [`super::run`] run first: this is unreachable without //! `ALLOW_EXAMPLE_SEED=1` on an approved host with no real accounts present. use chrono::{DateTime, Duration, Utc}; use uuid::Uuid; use super::projects::SeededProject; use super::{EXAMPLE_EMAIL_DOMAIN, SeedError}; use crate::auth; use crate::db::{self, ItemId}; /// Env var holding the demo buyer's password. Set in testnot's `EnvironmentFile` /// alongside the other box secrets, never in this repo. pub const PASSWORD_ENV: &str = "TESTNOT_BUYER_PASSWORD"; /// Fixed id, for the same reason the harness accounts have one: a reseed has to /// reproduce the same account rather than a new one each time. pub const BUYER_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_b001); /// Login handle, and the local part of `{handle}@example.test`. const HANDLE: &str = "demo_collector"; /// Shown on the profile and in the header while the capture runs. const DISPLAY_NAME: &str = "Demo Collector"; /// One purchase in the demo buyer's history. pub(super) struct PurchaseSpec { /// Item title, matched against the seeded catalog. Titles are unique within /// a project and, across this roster, unique overall. pub(super) title: &'static str, /// Days before the seed run to date the purchase. Spread on purpose: a /// library where every row says the same timestamp reads as a fixture, and /// the list is ordered by date, so the spread is what gives it a shape. pub(super) days_ago: i64, /// Cents paid above the pay-what-you-want minimum. Ignored for fixed-price /// and free items. A buyer who always pays exactly the floor is a buyer /// nobody recognises. pub(super) tip_cents: i32, /// Whether the buyer has already downloaded the current version. `false` /// leaves the "new version" badge lit, which is worth showing on one or two /// rows and noise on all of them. pub(super) downloaded: bool, } /// Nine of the eleven seeded items, spanning every purchasable type. /// /// Not all eleven: a library holding the entire catalog reads as seeded data /// rather than as somebody's shelf. "Weekly-Review Template" and "Typesetting /// the Commons" are deliberately left unbought. pub(super) const PURCHASES: &[PurchaseSpec] = &[ PurchaseSpec { title: "Restoration No. 1 (Full Mix)", days_ago: 2, tip_cents: 400, downloaded: false, }, PurchaseSpec { title: "Field Study 01 (Print)", days_ago: 5, tip_cents: 0, downloaded: true, }, PurchaseSpec { title: "Deskriver Focus (Plugin)", days_ago: 11, tip_cents: 0, downloaded: false, }, PurchaseSpec { title: "Stem Pack: Strings", days_ago: 19, tip_cents: 300, downloaded: true, }, PurchaseSpec { title: "On Slow Reading", days_ago: 24, tip_cents: 0, downloaded: true, }, PurchaseSpec { title: "Deskriver Utility (Download)", days_ago: 38, tip_cents: 0, downloaded: true, }, PurchaseSpec { title: "Session Take (Video)", days_ago: 52, tip_cents: 150, downloaded: true, }, PurchaseSpec { title: "Community Bundle Vol. 1", days_ago: 66, tip_cents: 0, downloaded: true, }, PurchaseSpec { title: "Minimal Preset Pack", days_ago: 91, tip_cents: 0, downloaded: true, }, ]; /// The subscription the buyer holds. Marginalia is the roster's one subscription /// project, and the library renders subscriptions beside purchases, so without /// this the frame shows half of what the page does. const SUBSCRIBED_PROJECT_SLUG: &str = "the-marginalia-reader"; /// Which tier. The middle one: the cheapest reads as a trial and the dearest as /// a plant. const SUBSCRIBED_TIER_NAME: &str = "Patron"; /// The one value the phase needs from the environment. #[derive(Clone)] pub struct BuyerOptions { /// The demo buyer's password, used by the capture run to log in. pub password: String, } /// Hand-written so the password cannot reach a log through /// [`super::SeedOptions`]'s derived `Debug`. impl std::fmt::Debug for BuyerOptions { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BuyerOptions") .field("password", &"") .finish() } } impl BuyerOptions { /// Read the password from the environment; `None` when unset or empty, which /// is the signal to skip the phase. pub fn from_env() -> Option { let password = std::env::var(PASSWORD_ENV).ok().filter(|s| !s.is_empty())?; Some(Self { password }) } } /// Seed the demo buyer, their purchase history, and their subscription. /// /// Called from [`super::run`] after the catalog phases, because every purchase /// references an item those phases created. Re-runnable: the account is an /// upsert on its fixed id, and the rows hanging off it are rebuilt from scratch /// each run so a reseed cannot accumulate duplicates. pub async fn seed_buyer( pool: &sqlx::PgPool, opts: &BuyerOptions, projects: &[SeededProject], ) -> Result<(), SeedError> { let password_hash = auth::hash_password_async(opts.password.clone()).await?; seed_account(pool, &password_hash).await?; clear_prior_history(pool).await?; let mut bought = 0; for project in projects { let items = db::items::get_items_by_project(pool, project.project.id).await?; for item in &items { let Some(spec) = PURCHASES.iter().find(|p| p.title == item.title) else { continue; }; let purchased_at = Utc::now() - Duration::days(spec.days_ago); let amount_cents = amount_for(item, spec); let transaction_id = super::sales::record_purchase( pool, project, item, BUYER_ACCOUNT_ID, amount_cents, purchased_at, ) .await?; if project.spec.features.contains(&"license_keys") { issue_license_key(pool, item.id, transaction_id, spec.days_ago).await?; } if spec.downloaded { record_download(pool, item.id, purchased_at).await?; } bought += 1; } } if bought != PURCHASES.len() { // A renamed item silently drops a row from the library, and the frame // just looks thin. Say so instead. tracing::warn!( matched = bought, expected = PURCHASES.len(), "example seed: some demo-buyer purchases matched no item; check the titles in buyer.rs" ); } seed_subscription(pool, projects).await?; tracing::warn!( user_id = %BUYER_ACCOUNT_ID, handle = HANDLE, purchases = bought, "example seed: demo buyer seeded (login-capable, for the capture run only)" ); Ok(()) } /// What the buyer paid: the fixed price, or the pay-what-you-want floor plus the /// spec's tip, or nothing for a free item. /// /// Reading it off the item rather than hardcoding it keeps the library's badges /// honest. `get_user_purchases` derives its Free badge from `amount_cents = 0`, /// so a paid item recorded at zero would badge wrong. fn amount_for(item: &db::DbItem, spec: &PurchaseSpec) -> i32 { if item.pwyw_enabled { return item.pwyw_min_cents.unwrap_or(0) + spec.tip_cents; } item.price_cents } /// Insert (or reset) the buyer account at its fixed id. /// /// `is_sandbox` stays FALSE: a sandbox account is refused by /// `SessionUser::check_not_sandbox`, and this one has to hold a real session. /// `can_create_projects` stays FALSE, because the whole point is a buyer. async fn seed_account(pool: &sqlx::PgPool, password_hash: &str) -> Result<(), SeedError> { let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", HANDLE.replace('_', "-")); sqlx::query( r" INSERT INTO users ( id, username, email, password_hash, display_name, can_create_projects, email_verified ) VALUES ($1, $2, $3, $4, $5, FALSE, TRUE) ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, display_name = EXCLUDED.display_name, -- A capture run that tripped the lockout must not survive the -- reseed: the point of the reset is a known state. failed_login_attempts = 0, locked_until = NULL, suspended_at = NULL, deactivated_at = NULL ", ) .bind(BUYER_ACCOUNT_ID) .bind(HANDLE) .bind(&email) .bind(password_hash) .bind(DISPLAY_NAME) .execute(pool) .await?; Ok(()) } /// Drop everything hanging off the buyer from a previous run. /// /// The catalog phases get idempotency from the example-data wipe, which deletes /// the creators and cascades their items. This account is not in that set (it is /// keyed by id, not created per run), so its purchases would otherwise survive a /// reseed pointing at items that no longer exist. async fn clear_prior_history(pool: &sqlx::PgPool) -> Result<(), SeedError> { // license_keys and user_downloads cascade from transactions and items // respectively, but the buyer's own rows are keyed by owner/user, so clear // them explicitly rather than relying on which side of the join went first. for statement in [ "DELETE FROM user_downloads WHERE user_id = $1", "DELETE FROM license_keys WHERE owner_id = $1", "DELETE FROM subscriptions WHERE subscriber_id = $1", "DELETE FROM transactions WHERE buyer_id = $1", ] { sqlx::query(statement) .bind(BUYER_ACCOUNT_ID) .execute(pool) .await?; } Ok(()) } /// Issue a license key for a purchase from a project that sells them. /// /// License keys are one of the things the library page shows and one of the /// things MNW sells, so the frame is worth more with one visible. The code shape /// mirrors the real generator's grouping without reusing it: this is display /// data on a demo box, not a key anything validates. async fn issue_license_key( pool: &sqlx::PgPool, item_id: ItemId, transaction_id: Uuid, days_ago: i64, ) -> Result<(), SeedError> { // Derived from the item id so a reseed of the same catalog produces the same // key, and no two items collide on the UNIQUE constraint. let raw = item_id.as_uuid().simple().to_string().to_uppercase(); let key_code = format!("DEMO-{}-{}-{}", &raw[0..4], &raw[4..8], &raw[8..12]); sqlx::query( r" INSERT INTO license_keys ( item_id, owner_id, transaction_id, key_code, max_activations, created_at ) VALUES ($1, $2, $3, $4, 3, $5) ON CONFLICT (key_code) DO NOTHING ", ) .bind(item_id) .bind(BUYER_ACCOUNT_ID) .bind(transaction_id) .bind(&key_code) .bind(Utc::now() - Duration::days(days_ago)) .execute(pool) .await?; Ok(()) } /// Mark every current version of an item as already downloaded. /// /// `get_user_purchases` lights its "new version" badge when the item has more /// versions than the buyer has downloads, so this is what turns the badge off. /// Leaving it on for a row or two is the point; leaving it on for all nine would /// read as a broken library rather than a used one. async fn record_download( pool: &sqlx::PgPool, item_id: ItemId, downloaded_at: DateTime, ) -> Result<(), SeedError> { sqlx::query( r" INSERT INTO user_downloads (user_id, item_id, version_id, downloaded_at) SELECT $1, $2, v.id, $3 FROM versions v WHERE v.item_id = $2 AND v.s3_key IS NOT NULL ON CONFLICT DO NOTHING ", ) .bind(BUYER_ACCOUNT_ID) .bind(item_id) .bind(downloaded_at) .execute(pool) .await?; Ok(()) } /// Give the buyer an active subscription to the roster's subscription project. /// /// `get_user_subscriptions_with_details` joins the tier and the project and /// filters on nothing but the subscriber, so an `active` row with a future /// `current_period_end` is the whole requirement. No Stripe call: the ids are /// fabricated and prefixed, as with the purchases. async fn seed_subscription( pool: &sqlx::PgPool, projects: &[SeededProject], ) -> Result<(), SeedError> { let Some(project) = projects .iter() .find(|p| p.spec.slug == SUBSCRIBED_PROJECT_SLUG) else { tracing::warn!( slug = SUBSCRIBED_PROJECT_SLUG, "example seed: subscription project missing; demo buyer has no subscription" ); return Ok(()); }; let tier_id: Option = sqlx::query_scalar("SELECT id FROM subscription_tiers WHERE project_id = $1 AND name = $2") .bind(project.project.id) .bind(SUBSCRIBED_TIER_NAME) .fetch_optional(pool) .await?; let Some(tier_id) = tier_id else { tracing::warn!( tier = SUBSCRIBED_TIER_NAME, "example seed: subscription tier missing; demo buyer has no subscription" ); return Ok(()); }; // Started three months back, renewing in a fortnight: a subscription that is // established rather than brand new, and visibly current. let started = Utc::now() - Duration::days(92); let period_start = Utc::now() - Duration::days(16); let period_end = Utc::now() + Duration::days(14); sqlx::query( r" INSERT INTO subscriptions ( subscriber_id, tier_id, project_id, stripe_subscription_id, stripe_customer_id, status, current_period_start, current_period_end, created_at ) VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8) ", ) .bind(BUYER_ACCOUNT_ID) .bind(tier_id) .bind(project.project.id) .bind(format!("sub_demo_{BUYER_ACCOUNT_ID}")) .bind(format!("cus_demo_{BUYER_ACCOUNT_ID}")) .bind(period_start) .bind(period_end) .bind(started) .execute(pool) .await?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn buyer_email_stays_inside_the_reserved_domain() { // The seed's reset only deletes @example.test accounts, and its third // guard refuses to run at all when a non-example account exists. A // handle producing an address outside the domain would both survive // resets and block the next seed. let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", HANDLE.replace('_', "-")); assert!(email.ends_with("@example.test"), "{email}"); } #[test] fn the_buyer_does_not_own_the_whole_catalog() { // Eleven items are seeded. A library holding all of them reads as a // fixture; this is the assertion that keeps someone from "fixing" the // gap by adding the last two. assert!( PURCHASES.len() < 11, "the demo buyer should leave some of the catalog unbought" ); } #[test] fn purchase_titles_are_unique() { let mut seen = std::collections::HashSet::new(); for spec in PURCHASES { assert!( seen.insert(spec.title), "duplicate purchase {:?}", spec.title ); } } #[test] fn purchase_dates_are_distinct_and_ordered() { // The library lists by date. Two rows sharing a day is a coin flip in // the ordering, which makes a re-capture differ from the approved one. let days: Vec = PURCHASES.iter().map(|p| p.days_ago).collect(); let mut sorted = days.clone(); sorted.sort_unstable(); sorted.dedup(); assert_eq!(sorted.len(), days.len(), "two purchases share a date"); assert!( days.windows(2).all(|w| w[0] < w[1]), "keep PURCHASES in date order, newest first, so the list reads like the page" ); } #[test] fn at_least_one_row_keeps_its_new_version_badge() { assert!( PURCHASES.iter().any(|p| !p.downloaded), "the update-available state is worth showing on at least one row" ); assert!( PURCHASES.iter().filter(|p| !p.downloaded).count() <= 3, "an all-badged library reads as broken, not as used" ); } }