max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+591 insertions,
-86 deletions
| @@ -5192,7 +5192,7 @@ | |||
| 5192 | 5192 | ||
| 5193 | 5193 | [[package]] | |
| 5194 | 5194 | name = "makenotwork" | |
| 5195 | - | version = "0.11.12" | |
| 5195 | + | version = "0.11.13" | |
| 5196 | 5196 | dependencies = [ | |
| 5197 | 5197 | "ammonia", | |
| 5198 | 5198 | "anyhow", | |
| @@ -10641,6 +10641,30 @@ | |||
| 10641 | 10641 | "pkg-config", | |
| 10642 | 10642 | ] | |
| 10643 | 10643 | ||
| 10644 | + | [[patch.unused]] | |
| 10645 | + | name = "quasi-axum" | |
| 10646 | + | version = "0.1.0" | |
| 10647 | + | ||
| 10648 | + | [[patch.unused]] | |
| 10649 | + | name = "quasi-http" | |
| 10650 | + | version = "0.1.0" | |
| 10651 | + | ||
| 10652 | + | [[patch.unused]] | |
| 10653 | + | name = "quasi-router" | |
| 10654 | + | version = "0.1.0" | |
| 10655 | + | ||
| 10656 | + | [[patch.unused]] | |
| 10657 | + | name = "quasi-store" | |
| 10658 | + | version = "0.1.0" | |
| 10659 | + | ||
| 10660 | + | [[patch.unused]] | |
| 10661 | + | name = "quasi-tauri" | |
| 10662 | + | version = "0.1.0" | |
| 10663 | + | ||
| 10664 | + | [[patch.unused]] | |
| 10665 | + | name = "quasi-webview" | |
| 10666 | + | version = "0.1.0" | |
| 10667 | + | ||
| 10644 | 10668 | [[patch.unused]] | |
| 10645 | 10669 | name = "synckit-client" | |
| 10646 | 10670 | version = "0.8.0" | |
| @@ -10656,19 +10680,3 @@ | |||
| 10656 | 10680 | [[patch.unused]] | |
| 10657 | 10681 | name = "painhours" | |
| 10658 | 10682 | version = "0.1.0" | |
| 10659 | - | ||
| 10660 | - | [[patch.unused]] | |
| 10661 | - | name = "quasi-http" | |
| 10662 | - | version = "0.1.0" | |
| 10663 | - | ||
| 10664 | - | [[patch.unused]] | |
| 10665 | - | name = "quasi-router" | |
| 10666 | - | version = "0.1.0" | |
| 10667 | - | ||
| 10668 | - | [[patch.unused]] | |
| 10669 | - | name = "quasi-tauri" | |
| 10670 | - | version = "0.1.0" | |
| 10671 | - | ||
| 10672 | - | [[patch.unused]] | |
| 10673 | - | name = "quasi-webview" | |
| 10674 | - | version = "0.1.0" |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.11.12" | |
| 3 | + | version = "0.11.13" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license = "LicenseRef-PolyForm-Noncommercial-1.0.0" | |
| 6 | 6 | # Server binary: never published to a registry. Marks the crate private so |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | "license": { | |
| 7 | 7 | "name": "PolyForm Noncommercial 1.0.0" | |
| 8 | 8 | }, | |
| 9 | - | "version": "0.11.12" | |
| 9 | + | "version": "0.11.13" | |
| 10 | 10 | }, | |
| 11 | 11 | "paths": { | |
| 12 | 12 | "/api/git/{owner}/{repo}/notes": { |
| @@ -55,22 +55,22 @@ | |||
| 55 | 55 | const DISPLAY_NAME: &str = "Demo Collector"; | |
| 56 | 56 | ||
| 57 | 57 | /// One purchase in the demo buyer's history. | |
| 58 | - | struct PurchaseSpec { | |
| 58 | + | pub(super) struct PurchaseSpec { | |
| 59 | 59 | /// Item title, matched against the seeded catalog. Titles are unique within | |
| 60 | 60 | /// a project and, across this roster, unique overall. | |
| 61 | - | title: &'static str, | |
| 61 | + | pub(super) title: &'static str, | |
| 62 | 62 | /// Days before the seed run to date the purchase. Spread on purpose: a | |
| 63 | 63 | /// library where every row says the same timestamp reads as a fixture, and | |
| 64 | 64 | /// the list is ordered by date, so the spread is what gives it a shape. | |
| 65 | - | days_ago: i64, | |
| 65 | + | pub(super) days_ago: i64, | |
| 66 | 66 | /// Cents paid above the pay-what-you-want minimum. Ignored for fixed-price | |
| 67 | 67 | /// and free items. A buyer who always pays exactly the floor is a buyer | |
| 68 | 68 | /// nobody recognises. | |
| 69 | - | tip_cents: i32, | |
| 69 | + | pub(super) tip_cents: i32, | |
| 70 | 70 | /// Whether the buyer has already downloaded the current version. `false` | |
| 71 | 71 | /// leaves the "new version" badge lit, which is worth showing on one or two | |
| 72 | 72 | /// rows and noise on all of them. | |
| 73 | - | downloaded: bool, | |
| 73 | + | pub(super) downloaded: bool, | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | /// Nine of the eleven seeded items, spanning every purchasable type. | |
| @@ -78,7 +78,7 @@ | |||
| 78 | 78 | /// Not all eleven: a library holding the entire catalog reads as seeded data | |
| 79 | 79 | /// rather than as somebody's shelf. "Weekly-Review Template" and "Typesetting | |
| 80 | 80 | /// the Commons" are deliberately left unbought. | |
| 81 | - | const PURCHASES: &[PurchaseSpec] = &[ | |
| 81 | + | pub(super) const PURCHASES: &[PurchaseSpec] = &[ | |
| 82 | 82 | PurchaseSpec { | |
| 83 | 83 | title: "Restoration No. 1 (Full Mix)", | |
| 84 | 84 | days_ago: 2, | |
| @@ -194,8 +194,15 @@ | |||
| 194 | 194 | }; | |
| 195 | 195 | let purchased_at = Utc::now() - Duration::days(spec.days_ago); | |
| 196 | 196 | let amount_cents = amount_for(item, spec); | |
| 197 | - | let transaction_id = | |
| 198 | - | record_purchase(pool, project, item, amount_cents, purchased_at).await?; | |
| 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?; | |
| 199 | 206 | if project.spec.features.contains(&"license_keys") { | |
| 200 | 207 | issue_license_key(pool, item.id, transaction_id, spec.days_ago).await?; | |
| 201 | 208 | } | |
| @@ -301,58 +308,6 @@ | |||
| 301 | 308 | Ok(()) | |
| 302 | 309 | } | |
| 303 | 310 | ||
| 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(¤cy) | |
| 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 | 311 | /// Issue a license key for a purchase from a project that sells them. | |
| 357 | 312 | /// | |
| 358 | 313 | /// License keys are one of the things the library page shows and one of the |
| @@ -70,6 +70,27 @@ | |||
| 70 | 70 | /// for Image items, whose cover *is* the work. An id that is declared but | |
| 71 | 71 | /// uncurated falls back to the generated placeholder. | |
| 72 | 72 | pub media: Option<&'static str>, | |
| 73 | + | /// Days before the seed run to date the item's release (`items.created_at`, | |
| 74 | + | /// rendered as "Released" on the item page and used by discover's newest | |
| 75 | + | /// sort). | |
| 76 | + | /// | |
| 77 | + | /// Without this every item carries the seed run's own timestamp, so the | |
| 78 | + | /// whole catalog reads "Released <today>" — the single clearest tell that | |
| 79 | + | /// nobody built this over time. Spread across the roster so each project has | |
| 80 | + | /// a release history rather than a launch day. | |
| 81 | + | /// | |
| 82 | + | /// Must predate any purchase of the item; `demo_buyer_purchases_postdate_release` | |
| 83 | + | /// in [`super::sales`] holds that. | |
| 84 | + | pub released_days_ago: i64, | |
| 85 | + | /// How many background buyers bought this item ([`super::sales`]). | |
| 86 | + | /// | |
| 87 | + | /// *Background*: the demo buyer's own purchases are counted on top of this, | |
| 88 | + | /// so an item they bought displays `other_sales + 1`. Zero is allowed and is | |
| 89 | + | /// worth keeping on a new release. | |
| 90 | + | /// | |
| 91 | + | /// Capped by the background-buyer pool, since one buyer cannot buy the same | |
| 92 | + | /// item twice (`idx_transactions_buyer_item_completed`). | |
| 93 | + | pub other_sales: u8, | |
| 73 | 94 | /// Manifest id of the item's cover art. `None` keeps the grey placeholder. | |
| 74 | 95 | pub cover: Option<&'static str>, | |
| 75 | 96 | } | |
| @@ -112,15 +133,15 @@ | |||
| 112 | 133 | } | |
| 113 | 134 | ||
| 114 | 135 | /// One fabricated creator plus the project they own. | |
| 115 | - | struct CreatorSpec { | |
| 136 | + | pub(super) struct CreatorSpec { | |
| 116 | 137 | /// Login handle and local-part of `{handle}@example.test`. | |
| 117 | - | handle: &'static str, | |
| 138 | + | pub(super) handle: &'static str, | |
| 118 | 139 | /// Display name shown on the profile. | |
| 119 | - | display_name: &'static str, | |
| 140 | + | pub(super) display_name: &'static str, | |
| 120 | 141 | /// Short bio, matter-of-fact, no pomp, no real-app names. | |
| 121 | - | bio: &'static str, | |
| 142 | + | pub(super) bio: &'static str, | |
| 122 | 143 | /// The project this creator owns. | |
| 123 | - | project: ProjectSpec, | |
| 144 | + | pub(super) project: ProjectSpec, | |
| 124 | 145 | } | |
| 125 | 146 | ||
| 126 | 147 | /// A creator after insertion, paired with the project spec still to be seeded. | |
| @@ -181,7 +202,7 @@ | |||
| 181 | 202 | /// The five content creators. Their projects span all four `PricingKind`s and, | |
| 182 | 203 | /// across their items, every `ItemType`. See the sprint doc | |
| 183 | 204 | /// `_private/docs/mnw/testnot-example-seed.md`. | |
| 184 | - | const ROSTER: &[CreatorSpec] = &[ | |
| 205 | + | pub(super) const ROSTER: &[CreatorSpec] = &[ | |
| 185 | 206 | CreatorSpec { | |
| 186 | 207 | handle: "openreels", | |
| 187 | 208 | display_name: "Open Reels", | |
| @@ -221,6 +242,8 @@ | |||
| 221 | 242 | tags: &["audio", "audio.format.music"], | |
| 222 | 243 | body: None, | |
| 223 | 244 | media: Some("restoration-1-audio"), | |
| 245 | + | released_days_ago: 148, | |
| 246 | + | other_sales: 7, | |
| 224 | 247 | cover: Some("restoration-1-cover"), | |
| 225 | 248 | }, | |
| 226 | 249 | ItemSpec { | |
| @@ -232,6 +255,8 @@ | |||
| 232 | 255 | tags: &["audio.format.samples", "audio.technique.sampling"], | |
| 233 | 256 | body: None, | |
| 234 | 257 | media: Some("stem-pack-strings-audio"), | |
| 258 | + | released_days_ago: 121, | |
| 259 | + | other_sales: 4, | |
| 235 | 260 | cover: Some("stem-pack-strings-cover"), | |
| 236 | 261 | }, | |
| 237 | 262 | ItemSpec { | |
| @@ -243,6 +268,8 @@ | |||
| 243 | 268 | tags: &["video", "video.genre.music-video"], | |
| 244 | 269 | body: None, | |
| 245 | 270 | media: Some("session-take-video"), | |
| 271 | + | released_days_ago: 96, | |
| 272 | + | other_sales: 2, | |
| 246 | 273 | cover: Some("session-take-cover"), | |
| 247 | 274 | }, | |
| 248 | 275 | ItemSpec { | |
| @@ -254,6 +281,8 @@ | |||
| 254 | 281 | tags: &["audio", "audio.format.music"], | |
| 255 | 282 | body: None, | |
| 256 | 283 | media: Some("restoration-2-audio"), | |
| 284 | + | released_days_ago: 54, | |
| 285 | + | other_sales: 3, | |
| 257 | 286 | cover: Some("restoration-2-cover"), | |
| 258 | 287 | }, | |
| 259 | 288 | ItemSpec { | |
| @@ -265,6 +294,8 @@ | |||
| 265 | 294 | tags: &["audio.format.samples", "audio.technique.sampling"], | |
| 266 | 295 | body: None, | |
| 267 | 296 | media: None, | |
| 297 | + | released_days_ago: 27, | |
| 298 | + | other_sales: 1, | |
| 268 | 299 | cover: Some("stem-pack-brass-cover"), | |
| 269 | 300 | }, | |
| 270 | 301 | ], | |
| @@ -310,6 +341,8 @@ | |||
| 310 | 341 | tags: &["software.format.plugin", "software.format.vst3"], | |
| 311 | 342 | body: None, | |
| 312 | 343 | media: None, | |
| 344 | + | released_days_ago: 209, | |
| 345 | + | other_sales: 9, | |
| 313 | 346 | cover: Some("deskriver-focus-cover"), | |
| 314 | 347 | }, | |
| 315 | 348 | ItemSpec { | |
| @@ -321,6 +354,8 @@ | |||
| 321 | 354 | tags: &["software", "software.platform.macos"], | |
| 322 | 355 | body: None, | |
| 323 | 356 | media: None, | |
| 357 | + | released_days_ago: 176, | |
| 358 | + | other_sales: 5, | |
| 324 | 359 | cover: Some("deskriver-presets-cover"), | |
| 325 | 360 | }, | |
| 326 | 361 | ItemSpec { | |
| @@ -332,6 +367,8 @@ | |||
| 332 | 367 | tags: &["writing.topic.productivity", "software"], | |
| 333 | 368 | body: None, | |
| 334 | 369 | media: None, | |
| 370 | + | released_days_ago: 132, | |
| 371 | + | other_sales: 6, | |
| 335 | 372 | cover: Some("deskriver-template-cover"), | |
| 336 | 373 | }, | |
| 337 | 374 | ItemSpec { | |
| @@ -347,6 +384,8 @@ | |||
| 347 | 384 | ], | |
| 348 | 385 | body: None, | |
| 349 | 386 | media: None, | |
| 387 | + | released_days_ago: 88, | |
| 388 | + | other_sales: 11, | |
| 350 | 389 | cover: Some("deskriver-utility-cover"), | |
| 351 | 390 | }, | |
| 352 | 391 | ItemSpec { | |
| @@ -358,6 +397,8 @@ | |||
| 358 | 397 | tags: &["software.format.plugin", "software.platform.macos"], | |
| 359 | 398 | body: None, | |
| 360 | 399 | media: None, | |
| 400 | + | released_days_ago: 41, | |
| 401 | + | other_sales: 6, | |
| 361 | 402 | cover: Some("deskriver-notes-cover"), | |
| 362 | 403 | }, | |
| 363 | 404 | ], | |
| @@ -399,6 +440,8 @@ | |||
| 399 | 440 | tags: &["visual.medium.photography", "visual"], | |
| 400 | 441 | body: None, | |
| 401 | 442 | media: None, | |
| 443 | + | released_days_ago: 243, | |
| 444 | + | other_sales: 12, | |
| 402 | 445 | cover: Some("field-study-01-cover"), | |
| 403 | 446 | }, | |
| 404 | 447 | ItemSpec { | |
| @@ -410,6 +453,8 @@ | |||
| 410 | 453 | tags: &["visual.medium.photography", "visual"], | |
| 411 | 454 | body: None, | |
| 412 | 455 | media: None, | |
| 456 | + | released_days_ago: 198, | |
| 457 | + | other_sales: 9, | |
| 413 | 458 | cover: Some("field-study-02-cover"), | |
| 414 | 459 | }, | |
| 415 | 460 | ItemSpec { | |
| @@ -421,6 +466,8 @@ | |||
| 421 | 466 | tags: &["visual.medium.photography", "visual"], | |
| 422 | 467 | body: None, | |
| 423 | 468 | media: None, | |
| 469 | + | released_days_ago: 155, | |
| 470 | + | other_sales: 7, | |
| 424 | 471 | cover: Some("field-study-03-cover"), | |
| 425 | 472 | }, | |
| 426 | 473 | ItemSpec { | |
| @@ -432,6 +479,8 @@ | |||
| 432 | 479 | tags: &["visual.medium.photography", "visual"], | |
| 433 | 480 | body: None, | |
| 434 | 481 | media: None, | |
| 482 | + | released_days_ago: 73, | |
| 483 | + | other_sales: 5, | |
| 435 | 484 | cover: Some("still-life-01-cover"), | |
| 436 | 485 | }, | |
| 437 | 486 | ItemSpec { | |
| @@ -443,6 +492,8 @@ | |||
| 443 | 492 | tags: &["visual.medium.photography", "visual"], | |
| 444 | 493 | body: None, | |
| 445 | 494 | media: None, | |
| 495 | + | released_days_ago: 31, | |
| 496 | + | other_sales: 2, | |
| 446 | 497 | cover: Some("still-life-02-cover"), | |
| 447 | 498 | }, | |
| 448 | 499 | ], | |
| @@ -501,6 +552,8 @@ | |||
| 501 | 552 | tags: &["writing.format.essay", "writing.topic.creativity"], | |
| 502 | 553 | body: Some(SLOW_READING_BODY), | |
| 503 | 554 | media: None, | |
| 555 | + | released_days_ago: 167, | |
| 556 | + | other_sales: 8, | |
| 504 | 557 | cover: Some("on-slow-reading-cover"), | |
| 505 | 558 | }, | |
| 506 | 559 | ItemSpec { | |
| @@ -512,6 +565,8 @@ | |||
| 512 | 565 | tags: &["education.format.course", "education.topic.writing"], | |
| 513 | 566 | body: None, | |
| 514 | 567 | media: None, | |
| 568 | + | released_days_ago: 139, | |
| 569 | + | other_sales: 4, | |
| 515 | 570 | cover: Some("typesetting-commons-cover"), | |
| 516 | 571 | }, | |
| 517 | 572 | ItemSpec { | |
| @@ -523,6 +578,8 @@ | |||
| 523 | 578 | tags: &["writing.format.essay", "writing"], | |
| 524 | 579 | body: Some(READER_ONE_BODY), | |
| 525 | 580 | media: None, | |
| 581 | + | released_days_ago: 104, | |
| 582 | + | other_sales: 6, | |
| 526 | 583 | cover: Some("reader-one-cover"), | |
| 527 | 584 | }, | |
| 528 | 585 | ItemSpec { | |
| @@ -534,6 +591,8 @@ | |||
| 534 | 591 | tags: &["writing.format.essay", "writing"], | |
| 535 | 592 | body: Some(READER_TWO_BODY), | |
| 536 | 593 | media: None, | |
| 594 | + | released_days_ago: 68, | |
| 595 | + | other_sales: 3, | |
| 537 | 596 | cover: Some("reader-two-cover"), | |
| 538 | 597 | }, | |
| 539 | 598 | ItemSpec { | |
| @@ -545,6 +604,8 @@ | |||
| 545 | 604 | tags: &["writing.format.essay", "writing.topic.creativity"], | |
| 546 | 605 | body: Some(ON_EDITIONS_BODY), | |
| 547 | 606 | media: None, | |
| 607 | + | released_days_ago: 22, | |
| 608 | + | other_sales: 1, | |
| 548 | 609 | cover: Some("on-editions-cover"), | |
| 549 | 610 | }, | |
| 550 | 611 | ], | |
| @@ -587,6 +648,8 @@ | |||
| 587 | 648 | tags: &["audio", "visual"], | |
| 588 | 649 | body: None, | |
| 589 | 650 | media: None, | |
| 651 | + | released_days_ago: 187, | |
| 652 | + | other_sales: 10, | |
| 590 | 653 | cover: Some("community-bundle-cover"), | |
| 591 | 654 | }, | |
| 592 | 655 | ItemSpec { | |
| @@ -598,6 +661,8 @@ | |||
| 598 | 661 | tags: &["audio", "visual"], | |
| 599 | 662 | body: None, | |
| 600 | 663 | media: None, | |
| 664 | + | released_days_ago: 129, | |
| 665 | + | other_sales: 6, | |
| 601 | 666 | cover: Some("community-bundle-2-cover"), | |
| 602 | 667 | }, | |
| 603 | 668 | ItemSpec { | |
| @@ -609,6 +674,8 @@ | |||
| 609 | 674 | tags: &["audio.format.samples", "audio"], | |
| 610 | 675 | body: None, | |
| 611 | 676 | media: None, | |
| 677 | + | released_days_ago: 112, | |
| 678 | + | other_sales: 8, | |
| 612 | 679 | cover: Some("field-recordings-cover"), | |
| 613 | 680 | }, | |
| 614 | 681 | ItemSpec { | |
| @@ -620,6 +687,8 @@ | |||
| 620 | 687 | tags: &["audio.format.samples", "audio.technique.sampling"], | |
| 621 | 688 | body: None, | |
| 622 | 689 | media: None, | |
| 690 | + | released_days_ago: 79, | |
| 691 | + | other_sales: 5, | |
| 623 | 692 | cover: Some("pd-loops-cover"), | |
| 624 | 693 | }, | |
| 625 | 694 | ItemSpec { | |
| @@ -631,6 +700,8 @@ | |||
| 631 | 700 | tags: &["writing", "writing.format.essay"], | |
| 632 | 701 | body: None, | |
| 633 | 702 | media: None, | |
| 703 | + | released_days_ago: 35, | |
| 704 | + | other_sales: 2, | |
| 634 | 705 | cover: Some("commons-reader-cover"), | |
| 635 | 706 | }, | |
| 636 | 707 | ], |
| @@ -115,6 +115,8 @@ | |||
| 115 | 115 | db::items::update_item_text(pool, item.id, seeded.user_id, body).await?; | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | + | backdate_release(pool, item.id, spec.released_days_ago).await?; | |
| 119 | + | ||
| 118 | 120 | // Keep the item hidden until Phase 3 attaches media. `items.scan_status` | |
| 119 | 121 | // defaults to 'clean' (migration 146), so an unfinished, file-less item would | |
| 120 | 122 | // otherwise show in discover with nothing to download. | |
| @@ -139,3 +141,23 @@ | |||
| 139 | 141 | ); | |
| 140 | 142 | Ok(()) | |
| 141 | 143 | } | |
| 144 | + | ||
| 145 | + | /// Date the item's release, which `create_item` sets to now. | |
| 146 | + | /// | |
| 147 | + | /// `items.created_at` is what the item page renders as "Released" and what | |
| 148 | + | /// discover sorts by, so leaving it at the seed run's timestamp gives every item | |
| 149 | + | /// in the catalog the same release date. There is no authoring path for this | |
| 150 | + | /// (a real creator's item really was created when they created it), hence the | |
| 151 | + | /// direct write rather than a `db::items` call. | |
| 152 | + | async fn backdate_release( | |
| 153 | + | pool: &sqlx::PgPool, | |
| 154 | + | item_id: db::ItemId, | |
| 155 | + | days_ago: i64, | |
| 156 | + | ) -> Result<(), SeedError> { | |
| 157 | + | sqlx::query("UPDATE items SET created_at = NOW() - ($2 || ' days')::interval WHERE id = $1") | |
| 158 | + | .bind(item_id) | |
| 159 | + | .bind(days_ago.to_string()) | |
| 160 | + | .execute(pool) | |
| 161 | + | .await?; | |
| 162 | + | Ok(()) | |
| 163 | + | } |
| @@ -38,6 +38,7 @@ | |||
| 38 | 38 | pub mod manifest; | |
| 39 | 39 | pub mod media; | |
| 40 | 40 | pub mod projects; | |
| 41 | + | pub mod sales; | |
| 41 | 42 | pub mod social; | |
| 42 | 43 | ||
| 43 | 44 | use std::sync::Arc; | |
| @@ -229,6 +230,12 @@ | |||
| 229 | 230 | ), | |
| 230 | 231 | } | |
| 231 | 232 | ||
| 233 | + | // Sales phase: the purchase history behind every item's "Sales" figure, and | |
| 234 | + | // the reconcile that makes `items.sales_count` agree with it. After the | |
| 235 | + | // buyer phase so it counts those purchases too, and unconditional because it | |
| 236 | + | // needs no credential. | |
| 237 | + | sales::seed_sales(pool, &projects).await?; | |
| 238 | + | ||
| 232 | 239 | Ok(()) | |
| 233 | 240 | } | |
| 234 | 241 |
| @@ -43,11 +43,18 @@ | |||
| 43 | 43 | } | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | + | /// Seeded creators and harness accounts, which is to say every example account | |
| 47 | + | /// except the background-buyer pool. Those are seeded by `seed::sales` purely to | |
| 48 | + | /// own transactions and are counted by | |
| 49 | + | /// [`every_item_reports_the_sales_it_actually_has`] instead; folding them in here | |
| 50 | + | /// would make the roster size a function of how many sales the demo shows. | |
| 46 | 51 | pub(super) async fn count_example_creators(pool: &sqlx::PgPool) -> i64 { | |
| 47 | 52 | sqlx::query_scalar( | |
| 48 | 53 | "SELECT COUNT(*) FROM users \ | |
| 49 | - | WHERE lower(split_part(email, '@', 2)) = 'example.test' AND is_sandbox = FALSE", | |
| 54 | + | WHERE lower(split_part(email, '@', 2)) = 'example.test' AND is_sandbox = FALSE \ | |
| 55 | + | AND username NOT LIKE $1", | |
| 50 | 56 | ) | |
| 57 | + | .bind(format!("{}%", seed::sales::BUYER_HANDLE_PREFIX)) | |
| 51 | 58 | .fetch_one(pool) | |
| 52 | 59 | .await | |
| 53 | 60 | .expect("count example creators") | |
| @@ -266,6 +273,76 @@ | |||
| 266 | 273 | ); | |
| 267 | 274 | } | |
| 268 | 275 | ||
| 276 | + | #[tokio::test] | |
| 277 | + | async fn every_item_reports_the_sales_it_actually_has() { | |
| 278 | + | let db = TestDb::new().await; | |
| 279 | + | ||
| 280 | + | seed::run(&db.pool, &testnot_opts(), &SeedMedia::none()) | |
| 281 | + | .await | |
| 282 | + | .expect("seed should succeed"); | |
| 283 | + | ||
| 284 | + | // The exact query `scheduler::integrity::check_sales_count_drift` runs. It | |
| 285 | + | // pages WAM on every row it returns, so a demo whose sales figures were | |
| 286 | + | // written rather than earned would alert forever. | |
| 287 | + | let drifted: Vec<(String, i32, i64)> = sqlx::query_as( | |
| 288 | + | r" | |
| 289 | + | SELECT i.title, i.sales_count, COUNT(t.id) | |
| 290 | + | FROM items i | |
| 291 | + | LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed' | |
| 292 | + | GROUP BY i.id, i.title, i.sales_count | |
| 293 | + | HAVING i.sales_count != COUNT(t.id) | |
| 294 | + | ", | |
| 295 | + | ) | |
| 296 | + | .fetch_all(&db.pool) | |
| 297 | + | .await | |
| 298 | + | .expect("drift query"); | |
| 299 | + | ||
| 300 | + | assert!( | |
| 301 | + | drifted.is_empty(), | |
| 302 | + | "sales_count disagrees with the transactions behind it: {drifted:?}" | |
| 303 | + | ); | |
| 304 | + | ||
| 305 | + | // And the figure is not uniformly zero, which is the state this phase | |
| 306 | + | // exists to leave behind. | |
| 307 | + | let sold: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM items WHERE sales_count > 0") | |
| 308 | + | .fetch_one(&db.pool) | |
| 309 | + | .await | |
| 310 | + | .expect("count of items with sales"); | |
| 311 | + | assert!(sold > 0, "no item reports a single sale"); | |
| 312 | + | ||
| 313 | + | // The pool those transactions belong to. Counted here because | |
| 314 | + | // `count_example_creators` deliberately excludes it. | |
| 315 | + | let buyers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username LIKE $1") | |
| 316 | + | .bind(format!("{}%", seed::sales::BUYER_HANDLE_PREFIX)) | |
| 317 | + | .fetch_one(&db.pool) | |
| 318 | + | .await | |
| 319 | + | .expect("count background buyers"); | |
| 320 | + | assert_eq!(buyers, seed::sales::BUYER_POOL as i64); | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | #[tokio::test] | |
| 324 | + | async fn no_item_is_released_on_the_day_the_seed_ran() { | |
| 325 | + | let db = TestDb::new().await; | |
| 326 | + | ||
| 327 | + | seed::run(&db.pool, &testnot_opts(), &SeedMedia::none()) | |
| 328 | + | .await | |
| 329 | + | .expect("seed should succeed"); | |
| 330 | + | ||
| 331 | + | // `items.created_at` is what the item page renders as "Released". A catalog | |
| 332 | + | // that all came out today is the clearest tell that it was generated. | |
| 333 | + | let stamped: Vec<String> = sqlx::query_scalar( | |
| 334 | + | "SELECT title FROM items WHERE created_at > NOW() - INTERVAL '1 day' ORDER BY title", | |
| 335 | + | ) | |
| 336 | + | .fetch_all(&db.pool) | |
| 337 | + | .await | |
| 338 | + | .expect("release dates"); | |
| 339 | + | ||
| 340 | + | assert!( | |
| 341 | + | stamped.is_empty(), | |
| 342 | + | "these items carry the seed run's own date as their release: {stamped:?}" | |
| 343 | + | ); | |
| 344 | + | } | |
| 345 | + | ||
| 269 | 346 | #[tokio::test] | |
| 270 | 347 | async fn seed_is_idempotent() { | |
| 271 | 348 | let db = TestDb::new().await; |
| @@ -1,0 +1,365 @@ | |||
| 1 | + | //! Background sales: the purchase history behind every item's "Sales" figure. | |
| 2 | + | //! | |
| 3 | + | //! [`super::buyer`] seeds one login-capable buyer so `/library` can be | |
| 4 | + | //! photographed. This phase seeds the rest of the marketplace: a pool of buyers | |
| 5 | + | //! who bought things and are never logged into, so an item page says how many | |
| 6 | + | //! people bought it instead of saying zero. | |
| 7 | + | //! | |
| 8 | + | //! # Why this is transactions rather than a number | |
| 9 | + | //! | |
| 10 | + | //! `items.sales_count` is denormalized, so the cheap version of this phase is an | |
| 11 | + | //! `UPDATE items SET sales_count = <a plausible number>`. That would be a lie | |
| 12 | + | //! the platform catches itself telling: `scheduler::integrity::check_sales_count_drift` | |
| 13 | + | //! compares the column against `COUNT(*)` over completed transactions and pages | |
| 14 | + | //! WAM on every mismatch, so a fabricated count would alert forever, and the demo | |
| 15 | + | //! would be showing sales that produced no revenue on the creator's own dashboard. | |
| 16 | + | //! | |
| 17 | + | //! So the sales are real rows, and [`reconcile_sales_counts`] derives the column | |
| 18 | + | //! from them at the end of the phase rather than incrementing as it goes. That is | |
| 19 | + | //! also what closes an existing drift: the demo buyer's nine purchases are | |
| 20 | + | //! inserted directly (they do not go through the purchase path that calls | |
| 21 | + | //! `increment_sales_count`), so before this phase existed every item they bought | |
| 22 | + | //! reported zero sales while holding a completed transaction. | |
| 23 | + | //! | |
| 24 | + | //! # What is deliberately not here | |
| 25 | + | //! | |
| 26 | + | //! No license keys. [`super::buyer::issue_license_key`] derives the key from the | |
| 27 | + | //! item id so a reseed reproduces the key in the approved screenshot, which means | |
| 28 | + | //! one key per item, which means it cannot also cover a second buyer of that item. | |
| 29 | + | //! Keys are only ever shown in the demo buyer's library, so background purchases | |
| 30 | + | //! skip them rather than making the visible key change on every reseed. | |
| 31 | + | ||
| 32 | + | use chrono::{DateTime, Duration, Utc}; | |
| 33 | + | use uuid::Uuid; | |
| 34 | + | ||
| 35 | + | use super::projects::SeededProject; | |
| 36 | + | use super::{EXAMPLE_EMAIL_DOMAIN, SeedError}; | |
| 37 | + | use crate::db::{self}; | |
| 38 | + | ||
| 39 | + | /// How many background buyers exist. | |
| 40 | + | /// | |
| 41 | + | /// The ceiling on any one item's `other_sales`: a buyer cannot buy the same item | |
| 42 | + | /// twice (`idx_transactions_buyer_item_completed`), so an item wanting N | |
| 43 | + | /// background sales needs N distinct accounts. `other_sales_fit_the_buyer_pool` | |
| 44 | + | /// holds the roster to it. | |
| 45 | + | pub const BUYER_POOL: usize = 14; | |
| 46 | + | ||
| 47 | + | /// Base of the background buyers' fixed ids, so a reseed reproduces the same | |
| 48 | + | /// accounts rather than a fresh set (same reason as [`super::buyer::BUYER_ACCOUNT_ID`], | |
| 49 | + | /// which sits at `…b001` and is deliberately outside this range). | |
| 50 | + | const BUYER_ID_BASE: u128 = 0x0000_0000_0000_0000_0000_0000_0000_b101; | |
| 51 | + | ||
| 52 | + | /// Stored in `password_hash`, which is `NOT NULL`. | |
| 53 | + | /// | |
| 54 | + | /// Not a hash of anything: these accounts exist to own transactions and are | |
| 55 | + | /// never logged into. `auth::verify_password` treats an unparseable hash as a | |
| 56 | + | /// non-match, so the accounts are unreachable by password rather than reachable | |
| 57 | + | /// with a guessable one. | |
| 58 | + | const UNUSABLE_PASSWORD_HASH: &str = "!seed-background-buyer-no-login"; | |
| 59 | + | ||
| 60 | + | /// Username prefix every background buyer carries. | |
| 61 | + | /// | |
| 62 | + | /// These accounts are neither creators nor the demo buyer, so anything counting | |
| 63 | + | /// the roster has to be able to tell them apart from it. Exported so the seed | |
| 64 | + | /// tests filter on the same string the seed writes. | |
| 65 | + | pub const BUYER_HANDLE_PREFIX: &str = "demo_buyer_"; | |
| 66 | + | ||
| 67 | + | /// Seed the background buyers and their purchases, then bring every seeded item's | |
| 68 | + | /// `sales_count` into agreement with its transactions. | |
| 69 | + | /// | |
| 70 | + | /// Called from [`super::run`] after [`super::buyer`], so the reconcile at the end | |
| 71 | + | /// counts the demo buyer's purchases too. Unlike the buyer phase this one needs | |
| 72 | + | /// no credential, so it always runs. | |
| 73 | + | pub async fn seed_sales(pool: &sqlx::PgPool, projects: &[SeededProject]) -> Result<(), SeedError> { | |
| 74 | + | let buyers = seed_buyer_pool(pool).await?; | |
| 75 | + | ||
| 76 | + | let mut sold = 0; | |
| 77 | + | // Rotates the slice of the pool used per item, so the same handful of | |
| 78 | + | // accounts are not the buyers of everything in roster order. | |
| 79 | + | let mut offset = 0; | |
| 80 | + | for project in projects { | |
| 81 | + | let items = db::items::get_items_by_project(pool, project.project.id).await?; | |
| 82 | + | for spec in project.spec.items { | |
| 83 | + | let Some(item) = items.iter().find(|i| i.title == spec.title) else { | |
| 84 | + | tracing::warn!( | |
| 85 | + | title = spec.title, | |
| 86 | + | "example seed: item missing at the sales phase; it will report zero sales" | |
| 87 | + | ); | |
| 88 | + | continue; | |
| 89 | + | }; | |
| 90 | + | for n in 0..spec.other_sales as usize { | |
| 91 | + | let buyer = buyers[(offset + n) % BUYER_POOL]; | |
| 92 | + | let purchased_at = purchase_date(spec.released_days_ago, n); | |
| 93 | + | let amount_cents = amount_for(item, n); | |
| 94 | + | record_purchase(pool, project, item, buyer, amount_cents, purchased_at).await?; | |
| 95 | + | sold += 1; | |
| 96 | + | } | |
| 97 | + | offset += spec.other_sales as usize + 1; | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | let reconciled = reconcile_sales_counts(pool).await?; | |
| 102 | + | tracing::info!( | |
| 103 | + | purchases = sold, | |
| 104 | + | items = reconciled, | |
| 105 | + | "example seed: background sales seeded" | |
| 106 | + | ); | |
| 107 | + | Ok(()) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// Create the pool, returning the ids in order. | |
| 111 | + | async fn seed_buyer_pool(pool: &sqlx::PgPool) -> Result<Vec<Uuid>, SeedError> { | |
| 112 | + | let mut ids = Vec::with_capacity(BUYER_POOL); | |
| 113 | + | for n in 0..BUYER_POOL { | |
| 114 | + | let id = Uuid::from_u128(BUYER_ID_BASE + n as u128); | |
| 115 | + | let handle = format!("{BUYER_HANDLE_PREFIX}{n:02}"); | |
| 116 | + | let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", handle.replace('_', "-")); | |
| 117 | + | sqlx::query( | |
| 118 | + | r" | |
| 119 | + | INSERT INTO users ( | |
| 120 | + | id, username, email, password_hash, display_name, | |
| 121 | + | can_create_projects, email_verified | |
| 122 | + | ) | |
| 123 | + | VALUES ($1, $2, $3, $4, $5, FALSE, TRUE) | |
| 124 | + | ON CONFLICT (id) DO UPDATE SET | |
| 125 | + | username = EXCLUDED.username, | |
| 126 | + | email = EXCLUDED.email | |
| 127 | + | ", | |
| 128 | + | ) | |
| 129 | + | .bind(id) | |
| 130 | + | .bind(&handle) | |
| 131 | + | .bind(&email) | |
| 132 | + | .bind(UNUSABLE_PASSWORD_HASH) | |
| 133 | + | .bind(format!("Demo Buyer {n:02}")) | |
| 134 | + | .execute(pool) | |
| 135 | + | .await?; | |
| 136 | + | ids.push(id); | |
| 137 | + | } | |
| 138 | + | Ok(ids) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// When the nth background buyer bought an item released `released_days_ago`. | |
| 142 | + | /// | |
| 143 | + | /// Spread across the item's life rather than clustered at either end, and always | |
| 144 | + | /// strictly inside it: a sale predating the release is the kind of detail that | |
| 145 | + | /// makes a demo look assembled rather than lived in, and the revenue dashboards | |
| 146 | + | /// bucket by date. | |
| 147 | + | fn purchase_date(released_days_ago: i64, n: usize) -> DateTime<Utc> { | |
| 148 | + | // Leave the release day itself clear, and keep every sale at least a day old | |
| 149 | + | // so nothing lands in the future between seeding and capture. | |
| 150 | + | let window = (released_days_ago - 2).max(1); | |
| 151 | + | // 7-day stride, wrapped: a spread that does not need a random source (the | |
| 152 | + | // seed has to reproduce, and `mnw-testnot-seed.sh` may run twice before a | |
| 153 | + | // capture). | |
| 154 | + | let offset = (n as i64 * 7) % window; | |
| 155 | + | Utc::now() - Duration::days(released_days_ago - 1 - offset) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// What the nth background buyer paid. | |
| 159 | + | /// | |
| 160 | + | /// Fixed price for a priced item, nothing for a free one, and the floor plus a | |
| 161 | + | /// varying tip for pay-what-you-want. `get_user_purchases` derives its Free badge | |
| 162 | + | /// from `amount_cents = 0`, and the creator revenue figures add these up, so this | |
| 163 | + | /// reads the item rather than inventing a number. | |
| 164 | + | fn amount_for(item: &db::DbItem, n: usize) -> i32 { | |
| 165 | + | if item.pwyw_enabled { | |
| 166 | + | // Most buyers pay the floor; some add a little. Deterministic, and not | |
| 167 | + | // a straight line. | |
| 168 | + | let tip = [0, 0, 250, 0, 100, 500, 0, 150][n % 8]; | |
| 169 | + | return item.pwyw_min_cents.unwrap_or(0) + tip; | |
| 170 | + | } | |
| 171 | + | item.price_cents | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// Record one completed transaction, which is what the `purchases` view reads. | |
| 175 | + | /// | |
| 176 | + | /// `platform_fee_cents` is zero and that is not a placeholder: MNW's platform | |
| 177 | + | /// fee is 0%, so a demo receipt showing anything else would misrepresent the | |
| 178 | + | /// product. The Stripe ids are fabricated and marked `demo_`; nothing on testnot | |
| 179 | + | /// talks to live Stripe, and the prefix makes a stray row obvious. | |
| 180 | + | /// | |
| 181 | + | /// The currency comes off the seller rather than being hardcoded. `transactions` | |
| 182 | + | /// constrains it to a lowercase supported code, and the real payment path | |
| 183 | + | /// settles in the seller's currency, so reading it keeps a demo receipt true to | |
| 184 | + | /// what a live one would say if a seeded creator is ever given a non-USD | |
| 185 | + | /// settlement currency. | |
| 186 | + | pub(super) async fn record_purchase( | |
| 187 | + | pool: &sqlx::PgPool, | |
| 188 | + | project: &SeededProject, | |
| 189 | + | item: &db::DbItem, | |
| 190 | + | buyer_id: Uuid, | |
| 191 | + | amount_cents: i32, | |
| 192 | + | purchased_at: DateTime<Utc>, | |
| 193 | + | ) -> Result<Uuid, SeedError> { | |
| 194 | + | let (seller_username, currency): (String, String) = sqlx::query_as( | |
| 195 | + | "SELECT username, lower(settlement_currency::text) FROM users WHERE id = $1", | |
| 196 | + | ) | |
| 197 | + | .bind(project.user_id) | |
| 198 | + | .fetch_one(pool) | |
| 199 | + | .await?; | |
| 200 | + | ||
| 201 | + | let transaction_id: Uuid = sqlx::query_scalar( | |
| 202 | + | r" | |
| 203 | + | INSERT INTO transactions ( | |
| 204 | + | buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, | |
| 205 | + | currency, status, stripe_payment_intent_id, | |
| 206 | + | created_at, completed_at, item_title, seller_username | |
| 207 | + | ) | |
| 208 | + | VALUES ($1, $2, $3, $4, 0, $5, 'completed', $6, $7, $7, $8, $9) | |
| 209 | + | RETURNING id | |
| 210 | + | ", | |
| 211 | + | ) | |
| 212 | + | .bind(buyer_id) | |
| 213 | + | .bind(project.user_id) | |
| 214 | + | .bind(item.id) | |
| 215 | + | .bind(amount_cents) | |
| 216 | + | .bind(¤cy) | |
| 217 | + | .bind(format!("pi_demo_{}_{}", item.id, buyer_id.simple())) | |
| 218 | + | .bind(purchased_at) | |
| 219 | + | .bind(&item.title) | |
| 220 | + | .bind(&seller_username) | |
| 221 | + | .fetch_one(pool) | |
| 222 | + | .await?; | |
| 223 | + | ||
| 224 | + | Ok(transaction_id) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// Set every seeded item's `sales_count` to its completed-transaction count. | |
| 228 | + | /// | |
| 229 | + | /// Derived rather than incremented, so the column agrees with the rows by | |
| 230 | + | /// construction and `check_sales_count_drift` stays quiet. Scoped to items owned | |
| 231 | + | /// by example accounts: the seed's guards mean nothing else should be in the | |
| 232 | + | /// database, and scoping it anyway keeps this from being a whole-table write if | |
| 233 | + | /// that ever stops being true. | |
| 234 | + | async fn reconcile_sales_counts(pool: &sqlx::PgPool) -> Result<u64, SeedError> { | |
| 235 | + | let res = sqlx::query( | |
| 236 | + | r" | |
| 237 | + | UPDATE items i SET sales_count = ( | |
| 238 | + | SELECT COUNT(*) FROM transactions t | |
| 239 | + | WHERE t.item_id = i.id AND t.status = 'completed' | |
| 240 | + | ) | |
| 241 | + | FROM projects p, users u | |
| 242 | + | WHERE i.project_id = p.id | |
| 243 | + | AND p.user_id = u.id | |
| 244 | + | AND lower(u.email) LIKE $1 | |
| 245 | + | ", | |
| 246 | + | ) | |
| 247 | + | .bind(format!("%@{EXAMPLE_EMAIL_DOMAIN}")) | |
| 248 | + | .execute(pool) | |
| 249 | + | .await?; | |
| 250 | + | Ok(res.rows_affected()) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | #[cfg(test)] | |
| 254 | + | mod tests { | |
| 255 | + | use super::*; | |
| 256 | + | use crate::seed::creators::ROSTER; | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn other_sales_fit_the_buyer_pool() { | |
| 260 | + | // One buyer cannot buy the same item twice, so an item asking for more | |
| 261 | + | // background sales than there are background buyers would fail the | |
| 262 | + | // seed on a unique-index violation, mid-run, on the box. | |
| 263 | + | for creator in ROSTER { | |
| 264 | + | for item in creator.project.items { | |
| 265 | + | assert!( | |
| 266 | + | item.other_sales as usize <= BUYER_POOL, | |
| 267 | + | "{}: other_sales {} exceeds the pool of {BUYER_POOL}", | |
| 268 | + | item.title, | |
| 269 | + | item.other_sales | |
| 270 | + | ); | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | #[test] | |
| 276 | + | fn every_background_sale_postdates_its_release() { | |
| 277 | + | for creator in ROSTER { | |
| 278 | + | for item in creator.project.items { | |
| 279 | + | for n in 0..item.other_sales as usize { | |
| 280 | + | let released = Utc::now() - Duration::days(item.released_days_ago); | |
| 281 | + | let bought = purchase_date(item.released_days_ago, n); | |
| 282 | + | assert!( | |
| 283 | + | bought > released, | |
| 284 | + | "{}: background sale {n} predates its release", | |
| 285 | + | item.title | |
| 286 | + | ); | |
| 287 | + | assert!( | |
| 288 | + | bought < Utc::now(), | |
| 289 | + | "{}: background sale {n} is in the future", | |
| 290 | + | item.title | |
| 291 | + | ); | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | #[test] | |
| 298 | + | fn demo_buyer_purchases_postdate_release() { | |
| 299 | + | // The two phases pick their dates independently: the roster says when an | |
| 300 | + | // item came out, `buyer::PURCHASES` says how long ago the demo buyer | |
| 301 | + | // bought it. A purchase older than the item it bought is invisible in | |
| 302 | + | // the library frame and wrong in every revenue view, so tie them here. | |
| 303 | + | for creator in ROSTER { | |
| 304 | + | for item in creator.project.items { | |
| 305 | + | let Some(purchase) = crate::seed::buyer::PURCHASES | |
| 306 | + | .iter() | |
| 307 | + | .find(|p| p.title == item.title) | |
| 308 | + | else { | |
| 309 | + | continue; | |
| 310 | + | }; | |
| 311 | + | assert!( | |
| 312 | + | purchase.days_ago < item.released_days_ago, | |
| 313 | + | "{}: bought {} days ago but released only {} days ago", | |
| 314 | + | item.title, | |
| 315 | + | purchase.days_ago, | |
| 316 | + | item.released_days_ago | |
| 317 | + | ); | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | #[test] | |
| 323 | + | fn total_sales_stay_within_the_pool_plus_the_demo_buyer() { | |
| 324 | + | // `other_sales` counts background buyers only. An item the demo buyer | |
| 325 | + | // also bought displays one more than that, and every one of those | |
| 326 | + | // buyers must be a distinct account. | |
| 327 | + | for creator in ROSTER { | |
| 328 | + | for item in creator.project.items { | |
| 329 | + | let demo = crate::seed::buyer::PURCHASES | |
| 330 | + | .iter() | |
| 331 | + | .any(|p| p.title == item.title) as usize; | |
| 332 | + | assert!( | |
| 333 | + | item.other_sales as usize + demo <= BUYER_POOL + 1, | |
| 334 | + | "{}: more buyers than accounts exist", | |
| 335 | + | item.title | |
| 336 | + | ); | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | #[test] | |
| 342 | + | fn release_dates_are_spread_rather_than_stamped() { | |
| 343 | + | // The tell this phase exists to remove: a catalog whose every item was | |
| 344 | + | // released the day the seed ran. A handful of shared dates is fine, a | |
| 345 | + | // catalog collapsed onto a few is not. | |
| 346 | + | let days: Vec<i64> = ROSTER | |
| 347 | + | .iter() | |
| 348 | + | .flat_map(|c| c.project.items.iter().map(|i| i.released_days_ago)) | |
| 349 | + | .collect(); | |
| 350 | + | let mut distinct = days.clone(); | |
| 351 | + | distinct.sort_unstable(); | |
| 352 | + | distinct.dedup(); | |
| 353 | + | assert!( | |
| 354 | + | distinct.len() >= days.len() * 3 / 4, | |
| 355 | + | "release dates are bunched: {} distinct across {} items", | |
| 356 | + | distinct.len(), | |
| 357 | + | days.len() | |
| 358 | + | ); | |
| 359 | + | let oldest = days.iter().max().copied().unwrap_or(0); | |
| 360 | + | assert!( | |
| 361 | + | oldest >= 180, | |
| 362 | + | "the catalog should have a history: oldest release is {oldest} days old" | |
| 363 | + | ); | |
| 364 | + | } | |
| 365 | + | } |