Skip to main content

max / makenotwork

19.5 KB · 546 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 struct PurchaseSpec {
59 /// Item title, matched against the seeded catalog. Titles are unique within
60 /// a project and, across this roster, unique overall.
61 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 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 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 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 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 =
198 record_purchase(pool, project, item, amount_cents, purchased_at).await?;
199 if project.spec.features.contains(&"license_keys") {
200 issue_license_key(pool, item.id, transaction_id, spec.days_ago).await?;
201 }
202 if spec.downloaded {
203 record_download(pool, item.id, purchased_at).await?;
204 }
205 bought += 1;
206 }
207 }
208
209 if bought != PURCHASES.len() {
210 // A renamed item silently drops a row from the library, and the frame
211 // just looks thin. Say so instead.
212 tracing::warn!(
213 matched = bought,
214 expected = PURCHASES.len(),
215 "example seed: some demo-buyer purchases matched no item; check the titles in buyer.rs"
216 );
217 }
218
219 seed_subscription(pool, projects).await?;
220
221 tracing::warn!(
222 user_id = %BUYER_ACCOUNT_ID,
223 handle = HANDLE,
224 purchases = bought,
225 "example seed: demo buyer seeded (login-capable, for the capture run only)"
226 );
227 Ok(())
228 }
229
230 /// What the buyer paid: the fixed price, or the pay-what-you-want floor plus the
231 /// spec's tip, or nothing for a free item.
232 ///
233 /// Reading it off the item rather than hardcoding it keeps the library's badges
234 /// honest. `get_user_purchases` derives its Free badge from `amount_cents = 0`,
235 /// so a paid item recorded at zero would badge wrong.
236 fn amount_for(item: &db::DbItem, spec: &PurchaseSpec) -> i32 {
237 if item.pwyw_enabled {
238 return item.pwyw_min_cents.unwrap_or(0) + spec.tip_cents;
239 }
240 item.price_cents
241 }
242
243 /// Insert (or reset) the buyer account at its fixed id.
244 ///
245 /// `is_sandbox` stays FALSE: a sandbox account is refused by
246 /// `SessionUser::check_not_sandbox`, and this one has to hold a real session.
247 /// `can_create_projects` stays FALSE, because the whole point is a buyer.
248 async fn seed_account(pool: &sqlx::PgPool, password_hash: &str) -> Result<(), SeedError> {
249 let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", HANDLE.replace('_', "-"));
250 sqlx::query(
251 r"
252 INSERT INTO users (
253 id, username, email, password_hash, display_name,
254 can_create_projects, email_verified
255 )
256 VALUES ($1, $2, $3, $4, $5, FALSE, TRUE)
257 ON CONFLICT (id) DO UPDATE SET
258 username = EXCLUDED.username,
259 email = EXCLUDED.email,
260 password_hash = EXCLUDED.password_hash,
261 display_name = EXCLUDED.display_name,
262 -- A capture run that tripped the lockout must not survive the
263 -- reseed: the point of the reset is a known state.
264 failed_login_attempts = 0,
265 locked_until = NULL,
266 suspended_at = NULL,
267 deactivated_at = NULL
268 ",
269 )
270 .bind(BUYER_ACCOUNT_ID)
271 .bind(HANDLE)
272 .bind(&email)
273 .bind(password_hash)
274 .bind(DISPLAY_NAME)
275 .execute(pool)
276 .await?;
277 Ok(())
278 }
279
280 /// Drop everything hanging off the buyer from a previous run.
281 ///
282 /// The catalog phases get idempotency from the example-data wipe, which deletes
283 /// the creators and cascades their items. This account is not in that set (it is
284 /// keyed by id, not created per run), so its purchases would otherwise survive a
285 /// reseed pointing at items that no longer exist.
286 async fn clear_prior_history(pool: &sqlx::PgPool) -> Result<(), SeedError> {
287 // license_keys and user_downloads cascade from transactions and items
288 // respectively, but the buyer's own rows are keyed by owner/user, so clear
289 // them explicitly rather than relying on which side of the join went first.
290 for statement in [
291 "DELETE FROM user_downloads WHERE user_id = $1",
292 "DELETE FROM license_keys WHERE owner_id = $1",
293 "DELETE FROM subscriptions WHERE subscriber_id = $1",
294 "DELETE FROM transactions WHERE buyer_id = $1",
295 ] {
296 sqlx::query(statement)
297 .bind(BUYER_ACCOUNT_ID)
298 .execute(pool)
299 .await?;
300 }
301 Ok(())
302 }
303
304 /// Record one completed transaction, which is what the `purchases` view reads.
305 ///
306 /// `platform_fee_cents` is zero and that is not a placeholder: MNW's platform
307 /// fee is 0%, so a demo receipt showing anything else would misrepresent the
308 /// product. The Stripe ids are fabricated and marked `demo_`; nothing on testnot
309 /// talks to live Stripe, and the prefix makes a stray row obvious.
310 ///
311 /// The currency comes off the seller rather than being hardcoded. `transactions`
312 /// constrains it to a lowercase supported code, and the real payment path
313 /// settles in the seller's currency, so reading it keeps a demo receipt true to
314 /// what a live one would say if a seeded creator is ever given a non-USD
315 /// settlement currency.
316 async fn record_purchase(
317 pool: &sqlx::PgPool,
318 project: &SeededProject,
319 item: &db::DbItem,
320 amount_cents: i32,
321 purchased_at: DateTime<Utc>,
322 ) -> Result<Uuid, SeedError> {
323 let (seller_username, currency): (String, String) = sqlx::query_as(
324 "SELECT username, lower(settlement_currency::text) FROM users WHERE id = $1",
325 )
326 .bind(project.user_id)
327 .fetch_one(pool)
328 .await?;
329
330 let transaction_id: Uuid = sqlx::query_scalar(
331 r"
332 INSERT INTO transactions (
333 buyer_id, seller_id, item_id, amount_cents, platform_fee_cents,
334 currency, status, stripe_payment_intent_id,
335 created_at, completed_at, item_title, seller_username
336 )
337 VALUES ($1, $2, $3, $4, 0, $5, 'completed', $6, $7, $7, $8, $9)
338 RETURNING id
339 ",
340 )
341 .bind(BUYER_ACCOUNT_ID)
342 .bind(project.user_id)
343 .bind(item.id)
344 .bind(amount_cents)
345 .bind(&currency)
346 .bind(format!("pi_demo_{}", item.id))
347 .bind(purchased_at)
348 .bind(&item.title)
349 .bind(&seller_username)
350 .fetch_one(pool)
351 .await?;
352
353 Ok(transaction_id)
354 }
355
356 /// Issue a license key for a purchase from a project that sells them.
357 ///
358 /// License keys are one of the things the library page shows and one of the
359 /// things MNW sells, so the frame is worth more with one visible. The code shape
360 /// mirrors the real generator's grouping without reusing it: this is display
361 /// data on a demo box, not a key anything validates.
362 async fn issue_license_key(
363 pool: &sqlx::PgPool,
364 item_id: ItemId,
365 transaction_id: Uuid,
366 days_ago: i64,
367 ) -> Result<(), SeedError> {
368 // Derived from the item id so a reseed of the same catalog produces the same
369 // key, and no two items collide on the UNIQUE constraint.
370 let raw = item_id.as_uuid().simple().to_string().to_uppercase();
371 let key_code = format!("DEMO-{}-{}-{}", &raw[0..4], &raw[4..8], &raw[8..12]);
372 sqlx::query(
373 r"
374 INSERT INTO license_keys (
375 item_id, owner_id, transaction_id, key_code, max_activations, created_at
376 )
377 VALUES ($1, $2, $3, $4, 3, $5)
378 ON CONFLICT (key_code) DO NOTHING
379 ",
380 )
381 .bind(item_id)
382 .bind(BUYER_ACCOUNT_ID)
383 .bind(transaction_id)
384 .bind(&key_code)
385 .bind(Utc::now() - Duration::days(days_ago))
386 .execute(pool)
387 .await?;
388 Ok(())
389 }
390
391 /// Mark every current version of an item as already downloaded.
392 ///
393 /// `get_user_purchases` lights its "new version" badge when the item has more
394 /// versions than the buyer has downloads, so this is what turns the badge off.
395 /// Leaving it on for a row or two is the point; leaving it on for all nine would
396 /// read as a broken library rather than a used one.
397 async fn record_download(
398 pool: &sqlx::PgPool,
399 item_id: ItemId,
400 downloaded_at: DateTime<Utc>,
401 ) -> Result<(), SeedError> {
402 sqlx::query(
403 r"
404 INSERT INTO user_downloads (user_id, item_id, version_id, downloaded_at)
405 SELECT $1, $2, v.id, $3
406 FROM versions v
407 WHERE v.item_id = $2 AND v.s3_key IS NOT NULL
408 ON CONFLICT DO NOTHING
409 ",
410 )
411 .bind(BUYER_ACCOUNT_ID)
412 .bind(item_id)
413 .bind(downloaded_at)
414 .execute(pool)
415 .await?;
416 Ok(())
417 }
418
419 /// Give the buyer an active subscription to the roster's subscription project.
420 ///
421 /// `get_user_subscriptions_with_details` joins the tier and the project and
422 /// filters on nothing but the subscriber, so an `active` row with a future
423 /// `current_period_end` is the whole requirement. No Stripe call: the ids are
424 /// fabricated and prefixed, as with the purchases.
425 async fn seed_subscription(
426 pool: &sqlx::PgPool,
427 projects: &[SeededProject],
428 ) -> Result<(), SeedError> {
429 let Some(project) = projects
430 .iter()
431 .find(|p| p.spec.slug == SUBSCRIBED_PROJECT_SLUG)
432 else {
433 tracing::warn!(
434 slug = SUBSCRIBED_PROJECT_SLUG,
435 "example seed: subscription project missing; demo buyer has no subscription"
436 );
437 return Ok(());
438 };
439
440 let tier_id: Option<Uuid> =
441 sqlx::query_scalar("SELECT id FROM subscription_tiers WHERE project_id = $1 AND name = $2")
442 .bind(project.project.id)
443 .bind(SUBSCRIBED_TIER_NAME)
444 .fetch_optional(pool)
445 .await?;
446 let Some(tier_id) = tier_id else {
447 tracing::warn!(
448 tier = SUBSCRIBED_TIER_NAME,
449 "example seed: subscription tier missing; demo buyer has no subscription"
450 );
451 return Ok(());
452 };
453
454 // Started three months back, renewing in a fortnight: a subscription that is
455 // established rather than brand new, and visibly current.
456 let started = Utc::now() - Duration::days(92);
457 let period_start = Utc::now() - Duration::days(16);
458 let period_end = Utc::now() + Duration::days(14);
459 sqlx::query(
460 r"
461 INSERT INTO subscriptions (
462 subscriber_id, tier_id, project_id, stripe_subscription_id,
463 stripe_customer_id, status, current_period_start, current_period_end,
464 created_at
465 )
466 VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8)
467 ",
468 )
469 .bind(BUYER_ACCOUNT_ID)
470 .bind(tier_id)
471 .bind(project.project.id)
472 .bind(format!("sub_demo_{BUYER_ACCOUNT_ID}"))
473 .bind(format!("cus_demo_{BUYER_ACCOUNT_ID}"))
474 .bind(period_start)
475 .bind(period_end)
476 .bind(started)
477 .execute(pool)
478 .await?;
479 Ok(())
480 }
481
482 #[cfg(test)]
483 mod tests {
484 use super::*;
485
486 #[test]
487 fn buyer_email_stays_inside_the_reserved_domain() {
488 // The seed's reset only deletes @example.test accounts, and its third
489 // guard refuses to run at all when a non-example account exists. A
490 // handle producing an address outside the domain would both survive
491 // resets and block the next seed.
492 let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", HANDLE.replace('_', "-"));
493 assert!(email.ends_with("@example.test"), "{email}");
494 }
495
496 #[test]
497 fn the_buyer_does_not_own_the_whole_catalog() {
498 // Eleven items are seeded. A library holding all of them reads as a
499 // fixture; this is the assertion that keeps someone from "fixing" the
500 // gap by adding the last two.
501 assert!(
502 PURCHASES.len() < 11,
503 "the demo buyer should leave some of the catalog unbought"
504 );
505 }
506
507 #[test]
508 fn purchase_titles_are_unique() {
509 let mut seen = std::collections::HashSet::new();
510 for spec in PURCHASES {
511 assert!(
512 seen.insert(spec.title),
513 "duplicate purchase {:?}",
514 spec.title
515 );
516 }
517 }
518
519 #[test]
520 fn purchase_dates_are_distinct_and_ordered() {
521 // The library lists by date. Two rows sharing a day is a coin flip in
522 // the ordering, which makes a re-capture differ from the approved one.
523 let days: Vec<i64> = PURCHASES.iter().map(|p| p.days_ago).collect();
524 let mut sorted = days.clone();
525 sorted.sort_unstable();
526 sorted.dedup();
527 assert_eq!(sorted.len(), days.len(), "two purchases share a date");
528 assert!(
529 days.windows(2).all(|w| w[0] < w[1]),
530 "keep PURCHASES in date order, newest first, so the list reads like the page"
531 );
532 }
533
534 #[test]
535 fn at_least_one_row_keeps_its_new_version_badge() {
536 assert!(
537 PURCHASES.iter().any(|p| !p.downloaded),
538 "the update-available state is worth showing on at least one row"
539 );
540 assert!(
541 PURCHASES.iter().filter(|p| !p.downloaded).count() <= 3,
542 "an all-badged library reads as broken, not as used"
543 );
544 }
545 }
546