Skip to main content

max / makenotwork

17.9 KB · 501 lines History Blame Raw
1 //! The demo buyer: one login-capable account with a purchase history, so
2 //! `/library` can be photographed.
3 //!
4 //! `/library` is 401 to anonymous and every other seeded account is a creator
5 //! with nothing bought, so a third of the pitch (buyers keep what they bought,
6 //! one-click export) could not be shown to anyone. This account exists to close
7 //! that, and nothing more.
8 //!
9 //! # Scope, decided 2026-08-05 (GoingsOn 839a8e5a)
10 //!
11 //! Two ways in were on the table. **A**: a visitor-facing temp-account path on
12 //! testnot, which also retires the prod `/sandbox` funnel (Phase 8-9 of
13 //! `_private/docs/mnw/testnot-example-seed.md`). **B**: a capture-only credential
14 //! used by the screenshot run and nobody else. B was chosen to unblock the
15 //! landing carousel, with A to follow on its own schedule.
16 //!
17 //! So this is B, and the boundary matters: **nothing on the public demo
18 //! changes**. No login CTA, no temp-account endpoint, no `ALLOW_TEMP_ACCOUNTS`,
19 //! and the account is linked from nowhere. An anonymous visitor to testnot still
20 //! cannot reach `/library`. That is a known, deliberate gap and A is what closes
21 //! it.
22 //!
23 //! # Why it is opt-in, and why it lives in the seed
24 //!
25 //! Same two reasons as [`super::harness`]. The password is a credential and does
26 //! not belong in a public repo, so the phase runs only when [`PASSWORD_ENV`] is
27 //! set and skips with a warning otherwise. And `mnw-testnot-seed.sh` drops every
28 //! schema before it reseeds, so an account created by hand is gone on the next
29 //! reset with nothing in the diff to explain why the capture run started failing
30 //! at login.
31 //!
32 //! The prod guards in [`super::run`] run first: this is unreachable without
33 //! `ALLOW_EXAMPLE_SEED=1` on an approved host with no real accounts present.
34
35 use chrono::{DateTime, Duration, Utc};
36 use uuid::Uuid;
37
38 use super::projects::SeededProject;
39 use super::{EXAMPLE_EMAIL_DOMAIN, SeedError};
40 use crate::auth;
41 use crate::db::{self, ItemId};
42
43 /// Env var holding the demo buyer's password. Set in testnot's `EnvironmentFile`
44 /// alongside the other box secrets, never in this repo.
45 pub const PASSWORD_ENV: &str = "TESTNOT_BUYER_PASSWORD";
46
47 /// Fixed id, for the same reason the harness accounts have one: a reseed has to
48 /// reproduce the same account rather than a new one each time.
49 pub const BUYER_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_b001);
50
51 /// Login handle, and the local part of `{handle}@example.test`.
52 const HANDLE: &str = "demo_collector";
53
54 /// Shown on the profile and in the header while the capture runs.
55 const DISPLAY_NAME: &str = "Demo Collector";
56
57 /// One purchase in the demo buyer's history.
58 pub(super) struct PurchaseSpec {
59 /// Item title, matched against the seeded catalog. Titles are unique within
60 /// a project and, across this roster, unique overall.
61 pub(super) title: &'static str,
62 /// Days before the seed run to date the purchase. Spread on purpose: a
63 /// library where every row says the same timestamp reads as a fixture, and
64 /// the list is ordered by date, so the spread is what gives it a shape.
65 pub(super) days_ago: i64,
66 /// Cents paid above the pay-what-you-want minimum. Ignored for fixed-price
67 /// and free items. A buyer who always pays exactly the floor is a buyer
68 /// nobody recognises.
69 pub(super) tip_cents: i32,
70 /// Whether the buyer has already downloaded the current version. `false`
71 /// leaves the "new version" badge lit, which is worth showing on one or two
72 /// rows and noise on all of them.
73 pub(super) downloaded: bool,
74 }
75
76 /// Nine of the eleven seeded items, spanning every purchasable type.
77 ///
78 /// Not all eleven: a library holding the entire catalog reads as seeded data
79 /// rather than as somebody's shelf. "Weekly-Review Template" and "Typesetting
80 /// the Commons" are deliberately left unbought.
81 pub(super) const PURCHASES: &[PurchaseSpec] = &[
82 PurchaseSpec {
83 title: "Restoration No. 1 (Full Mix)",
84 days_ago: 2,
85 tip_cents: 400,
86 downloaded: false,
87 },
88 PurchaseSpec {
89 title: "Field Study 01 (Print)",
90 days_ago: 5,
91 tip_cents: 0,
92 downloaded: true,
93 },
94 PurchaseSpec {
95 title: "Deskriver Focus (Plugin)",
96 days_ago: 11,
97 tip_cents: 0,
98 downloaded: false,
99 },
100 PurchaseSpec {
101 title: "Stem Pack: Strings",
102 days_ago: 19,
103 tip_cents: 300,
104 downloaded: true,
105 },
106 PurchaseSpec {
107 title: "On Slow Reading",
108 days_ago: 24,
109 tip_cents: 0,
110 downloaded: true,
111 },
112 PurchaseSpec {
113 title: "Deskriver Utility (Download)",
114 days_ago: 38,
115 tip_cents: 0,
116 downloaded: true,
117 },
118 PurchaseSpec {
119 title: "Session Take (Video)",
120 days_ago: 52,
121 tip_cents: 150,
122 downloaded: true,
123 },
124 PurchaseSpec {
125 title: "Community Bundle Vol. 1",
126 days_ago: 66,
127 tip_cents: 0,
128 downloaded: true,
129 },
130 PurchaseSpec {
131 title: "Minimal Preset Pack",
132 days_ago: 91,
133 tip_cents: 0,
134 downloaded: true,
135 },
136 ];
137
138 /// The subscription the buyer holds. Marginalia is the roster's one subscription
139 /// project, and the library renders subscriptions beside purchases, so without
140 /// this the frame shows half of what the page does.
141 const SUBSCRIBED_PROJECT_SLUG: &str = "the-marginalia-reader";
142
143 /// Which tier. The middle one: the cheapest reads as a trial and the dearest as
144 /// a plant.
145 const SUBSCRIBED_TIER_NAME: &str = "Patron";
146
147 /// The one value the phase needs from the environment.
148 #[derive(Clone)]
149 pub struct BuyerOptions {
150 /// The demo buyer's password, used by the capture run to log in.
151 pub password: String,
152 }
153
154 /// Hand-written so the password cannot reach a log through
155 /// [`super::SeedOptions`]'s derived `Debug`.
156 impl std::fmt::Debug for BuyerOptions {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 f.debug_struct("BuyerOptions")
159 .field("password", &"<redacted>")
160 .finish()
161 }
162 }
163
164 impl BuyerOptions {
165 /// Read the password from the environment; `None` when unset or empty, which
166 /// is the signal to skip the phase.
167 pub fn from_env() -> Option<Self> {
168 let password = std::env::var(PASSWORD_ENV).ok().filter(|s| !s.is_empty())?;
169 Some(Self { password })
170 }
171 }
172
173 /// Seed the demo buyer, their purchase history, and their subscription.
174 ///
175 /// Called from [`super::run`] after the catalog phases, because every purchase
176 /// references an item those phases created. Re-runnable: the account is an
177 /// upsert on its fixed id, and the rows hanging off it are rebuilt from scratch
178 /// each run so a reseed cannot accumulate duplicates.
179 pub async fn seed_buyer(
180 pool: &sqlx::PgPool,
181 opts: &BuyerOptions,
182 projects: &[SeededProject],
183 ) -> Result<(), SeedError> {
184 let password_hash = auth::hash_password_async(opts.password.clone()).await?;
185 seed_account(pool, &password_hash).await?;
186 clear_prior_history(pool).await?;
187
188 let mut bought = 0;
189 for project in projects {
190 let items = db::items::get_items_by_project(pool, project.project.id).await?;
191 for item in &items {
192 let Some(spec) = PURCHASES.iter().find(|p| p.title == item.title) else {
193 continue;
194 };
195 let purchased_at = Utc::now() - Duration::days(spec.days_ago);
196 let amount_cents = amount_for(item, spec);
197 let transaction_id = super::sales::record_purchase(
198 pool,
199 project,
200 item,
201 BUYER_ACCOUNT_ID,
202 amount_cents,
203 purchased_at,
204 )
205 .await?;
206 if project.spec.features.contains(&"license_keys") {
207 issue_license_key(pool, item.id, transaction_id, spec.days_ago).await?;
208 }
209 if spec.downloaded {
210 record_download(pool, item.id, purchased_at).await?;
211 }
212 bought += 1;
213 }
214 }
215
216 if bought != PURCHASES.len() {
217 // A renamed item silently drops a row from the library, and the frame
218 // just looks thin. Say so instead.
219 tracing::warn!(
220 matched = bought,
221 expected = PURCHASES.len(),
222 "example seed: some demo-buyer purchases matched no item; check the titles in buyer.rs"
223 );
224 }
225
226 seed_subscription(pool, projects).await?;
227
228 tracing::warn!(
229 user_id = %BUYER_ACCOUNT_ID,
230 handle = HANDLE,
231 purchases = bought,
232 "example seed: demo buyer seeded (login-capable, for the capture run only)"
233 );
234 Ok(())
235 }
236
237 /// What the buyer paid: the fixed price, or the pay-what-you-want floor plus the
238 /// spec's tip, or nothing for a free item.
239 ///
240 /// Reading it off the item rather than hardcoding it keeps the library's badges
241 /// honest. `get_user_purchases` derives its Free badge from `amount_cents = 0`,
242 /// so a paid item recorded at zero would badge wrong.
243 fn amount_for(item: &db::DbItem, spec: &PurchaseSpec) -> i32 {
244 if item.pwyw_enabled {
245 return item.pwyw_min_cents.unwrap_or(0) + spec.tip_cents;
246 }
247 item.price_cents
248 }
249
250 /// Insert (or reset) the buyer account at its fixed id.
251 ///
252 /// `is_sandbox` stays FALSE: a sandbox account is refused by
253 /// `SessionUser::check_not_sandbox`, and this one has to hold a real session.
254 /// `can_create_projects` stays FALSE, because the whole point is a buyer.
255 async fn seed_account(pool: &sqlx::PgPool, password_hash: &str) -> Result<(), SeedError> {
256 let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", HANDLE.replace('_', "-"));
257 sqlx::query(
258 r"
259 INSERT INTO users (
260 id, username, email, password_hash, display_name,
261 can_create_projects, email_verified
262 )
263 VALUES ($1, $2, $3, $4, $5, FALSE, TRUE)
264 ON CONFLICT (id) DO UPDATE SET
265 username = EXCLUDED.username,
266 email = EXCLUDED.email,
267 password_hash = EXCLUDED.password_hash,
268 display_name = EXCLUDED.display_name,
269 -- A capture run that tripped the lockout must not survive the
270 -- reseed: the point of the reset is a known state.
271 failed_login_attempts = 0,
272 locked_until = NULL,
273 suspended_at = NULL,
274 deactivated_at = NULL
275 ",
276 )
277 .bind(BUYER_ACCOUNT_ID)
278 .bind(HANDLE)
279 .bind(&email)
280 .bind(password_hash)
281 .bind(DISPLAY_NAME)
282 .execute(pool)
283 .await?;
284 Ok(())
285 }
286
287 /// Drop everything hanging off the buyer from a previous run.
288 ///
289 /// The catalog phases get idempotency from the example-data wipe, which deletes
290 /// the creators and cascades their items. This account is not in that set (it is
291 /// keyed by id, not created per run), so its purchases would otherwise survive a
292 /// reseed pointing at items that no longer exist.
293 async fn clear_prior_history(pool: &sqlx::PgPool) -> Result<(), SeedError> {
294 // license_keys and user_downloads cascade from transactions and items
295 // respectively, but the buyer's own rows are keyed by owner/user, so clear
296 // them explicitly rather than relying on which side of the join went first.
297 for statement in [
298 "DELETE FROM user_downloads WHERE user_id = $1",
299 "DELETE FROM license_keys WHERE owner_id = $1",
300 "DELETE FROM subscriptions WHERE subscriber_id = $1",
301 "DELETE FROM transactions WHERE buyer_id = $1",
302 ] {
303 sqlx::query(statement)
304 .bind(BUYER_ACCOUNT_ID)
305 .execute(pool)
306 .await?;
307 }
308 Ok(())
309 }
310
311 /// Issue a license key for a purchase from a project that sells them.
312 ///
313 /// License keys are one of the things the library page shows and one of the
314 /// things MNW sells, so the frame is worth more with one visible. The code shape
315 /// mirrors the real generator's grouping without reusing it: this is display
316 /// data on a demo box, not a key anything validates.
317 async fn issue_license_key(
318 pool: &sqlx::PgPool,
319 item_id: ItemId,
320 transaction_id: Uuid,
321 days_ago: i64,
322 ) -> Result<(), SeedError> {
323 // Derived from the item id so a reseed of the same catalog produces the same
324 // key, and no two items collide on the UNIQUE constraint.
325 let raw = item_id.as_uuid().simple().to_string().to_uppercase();
326 let key_code = format!("DEMO-{}-{}-{}", &raw[0..4], &raw[4..8], &raw[8..12]);
327 sqlx::query(
328 r"
329 INSERT INTO license_keys (
330 item_id, owner_id, transaction_id, key_code, max_activations, created_at
331 )
332 VALUES ($1, $2, $3, $4, 3, $5)
333 ON CONFLICT (key_code) DO NOTHING
334 ",
335 )
336 .bind(item_id)
337 .bind(BUYER_ACCOUNT_ID)
338 .bind(transaction_id)
339 .bind(&key_code)
340 .bind(Utc::now() - Duration::days(days_ago))
341 .execute(pool)
342 .await?;
343 Ok(())
344 }
345
346 /// Mark every current version of an item as already downloaded.
347 ///
348 /// `get_user_purchases` lights its "new version" badge when the item has more
349 /// versions than the buyer has downloads, so this is what turns the badge off.
350 /// Leaving it on for a row or two is the point; leaving it on for all nine would
351 /// read as a broken library rather than a used one.
352 async fn record_download(
353 pool: &sqlx::PgPool,
354 item_id: ItemId,
355 downloaded_at: DateTime<Utc>,
356 ) -> Result<(), SeedError> {
357 sqlx::query(
358 r"
359 INSERT INTO user_downloads (user_id, item_id, version_id, downloaded_at)
360 SELECT $1, $2, v.id, $3
361 FROM versions v
362 WHERE v.item_id = $2 AND v.s3_key IS NOT NULL
363 ON CONFLICT DO NOTHING
364 ",
365 )
366 .bind(BUYER_ACCOUNT_ID)
367 .bind(item_id)
368 .bind(downloaded_at)
369 .execute(pool)
370 .await?;
371 Ok(())
372 }
373
374 /// Give the buyer an active subscription to the roster's subscription project.
375 ///
376 /// `get_user_subscriptions_with_details` joins the tier and the project and
377 /// filters on nothing but the subscriber, so an `active` row with a future
378 /// `current_period_end` is the whole requirement. No Stripe call: the ids are
379 /// fabricated and prefixed, as with the purchases.
380 async fn seed_subscription(
381 pool: &sqlx::PgPool,
382 projects: &[SeededProject],
383 ) -> Result<(), SeedError> {
384 let Some(project) = projects
385 .iter()
386 .find(|p| p.spec.slug == SUBSCRIBED_PROJECT_SLUG)
387 else {
388 tracing::warn!(
389 slug = SUBSCRIBED_PROJECT_SLUG,
390 "example seed: subscription project missing; demo buyer has no subscription"
391 );
392 return Ok(());
393 };
394
395 let tier_id: Option<Uuid> =
396 sqlx::query_scalar("SELECT id FROM subscription_tiers WHERE project_id = $1 AND name = $2")
397 .bind(project.project.id)
398 .bind(SUBSCRIBED_TIER_NAME)
399 .fetch_optional(pool)
400 .await?;
401 let Some(tier_id) = tier_id else {
402 tracing::warn!(
403 tier = SUBSCRIBED_TIER_NAME,
404 "example seed: subscription tier missing; demo buyer has no subscription"
405 );
406 return Ok(());
407 };
408
409 // Started three months back, renewing in a fortnight: a subscription that is
410 // established rather than brand new, and visibly current.
411 let started = Utc::now() - Duration::days(92);
412 let period_start = Utc::now() - Duration::days(16);
413 let period_end = Utc::now() + Duration::days(14);
414 sqlx::query(
415 r"
416 INSERT INTO subscriptions (
417 subscriber_id, tier_id, project_id, stripe_subscription_id,
418 stripe_customer_id, status, current_period_start, current_period_end,
419 created_at
420 )
421 VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8)
422 ",
423 )
424 .bind(BUYER_ACCOUNT_ID)
425 .bind(tier_id)
426 .bind(project.project.id)
427 .bind(format!("sub_demo_{BUYER_ACCOUNT_ID}"))
428 .bind(format!("cus_demo_{BUYER_ACCOUNT_ID}"))
429 .bind(period_start)
430 .bind(period_end)
431 .bind(started)
432 .execute(pool)
433 .await?;
434 Ok(())
435 }
436
437 #[cfg(test)]
438 mod tests {
439 use super::*;
440
441 #[test]
442 fn buyer_email_stays_inside_the_reserved_domain() {
443 // The seed's reset only deletes @example.test accounts, and its third
444 // guard refuses to run at all when a non-example account exists. A
445 // handle producing an address outside the domain would both survive
446 // resets and block the next seed.
447 let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", HANDLE.replace('_', "-"));
448 assert!(email.ends_with("@example.test"), "{email}");
449 }
450
451 #[test]
452 fn the_buyer_does_not_own_the_whole_catalog() {
453 // Eleven items are seeded. A library holding all of them reads as a
454 // fixture; this is the assertion that keeps someone from "fixing" the
455 // gap by adding the last two.
456 assert!(
457 PURCHASES.len() < 11,
458 "the demo buyer should leave some of the catalog unbought"
459 );
460 }
461
462 #[test]
463 fn purchase_titles_are_unique() {
464 let mut seen = std::collections::HashSet::new();
465 for spec in PURCHASES {
466 assert!(
467 seen.insert(spec.title),
468 "duplicate purchase {:?}",
469 spec.title
470 );
471 }
472 }
473
474 #[test]
475 fn purchase_dates_are_distinct_and_ordered() {
476 // The library lists by date. Two rows sharing a day is a coin flip in
477 // the ordering, which makes a re-capture differ from the approved one.
478 let days: Vec<i64> = PURCHASES.iter().map(|p| p.days_ago).collect();
479 let mut sorted = days.clone();
480 sorted.sort_unstable();
481 sorted.dedup();
482 assert_eq!(sorted.len(), days.len(), "two purchases share a date");
483 assert!(
484 days.windows(2).all(|w| w[0] < w[1]),
485 "keep PURCHASES in date order, newest first, so the list reads like the page"
486 );
487 }
488
489 #[test]
490 fn at_least_one_row_keeps_its_new_version_badge() {
491 assert!(
492 PURCHASES.iter().any(|p| !p.downloaded),
493 "the update-available state is worth showing on at least one row"
494 );
495 assert!(
496 PURCHASES.iter().filter(|p| !p.downloaded).count() <= 3,
497 "an all-badged library reads as broken, not as used"
498 );
499 }
500 }
501