| 25 |
25 |
|
use makenotwork::storage::StorageBackend;
|
| 26 |
26 |
|
|
| 27 |
27 |
|
/// The Phase-1 roster size (see `seed::creators::ROSTER`).
|
| 28 |
|
- |
const SEEDED_CREATORS: i64 = 5;
|
|
28 |
+ |
pub(super) const SEEDED_CREATORS: i64 = 5;
|
| 29 |
29 |
|
/// Phase-2 item count: five per project, spanning every `ItemType`. Widened
|
| 30 |
30 |
|
/// from eleven on 2026-08-07 so the storefront grids read as grids.
|
| 31 |
31 |
|
const SEEDED_ITEMS: i64 = 25;
|
| 35 |
35 |
|
const ITEM_TYPES: i64 = 11;
|
| 36 |
36 |
|
|
| 37 |
37 |
|
/// Guard-passing options for a test run: opt-in on, approved example host.
|
| 38 |
|
- |
fn testnot_opts() -> SeedOptions {
|
|
38 |
+ |
pub(super) fn testnot_opts() -> SeedOptions {
|
| 39 |
39 |
|
SeedOptions {
|
| 40 |
40 |
|
allow_example_seed: true,
|
| 41 |
41 |
|
host_url: "https://testnot.work".to_string(),
|
| 44 |
44 |
|
}
|
| 45 |
45 |
|
}
|
| 46 |
46 |
|
|
| 47 |
|
- |
async fn count_example_creators(pool: &sqlx::PgPool) -> i64 {
|
|
47 |
+ |
pub(super) async fn count_example_creators(pool: &sqlx::PgPool) -> i64 {
|
| 48 |
48 |
|
sqlx::query_scalar(
|
| 49 |
49 |
|
"SELECT COUNT(*) FROM users \
|
| 50 |
50 |
|
WHERE lower(split_part(email, '@', 2)) = 'example.test' AND is_sandbox = FALSE",
|
| 69 |
69 |
|
/// (`p.is_public AND u.is_sandbox = FALSE`, per `db::discover::discover_projects`).
|
| 70 |
70 |
|
/// Mirrors that predicate in SQL because the `discover` module is `pub(crate)` and
|
| 71 |
71 |
|
/// so unreachable from this external test crate.
|
| 72 |
|
- |
async fn discover_visible_example_slugs(pool: &sqlx::PgPool) -> Vec<String> {
|
|
72 |
+ |
pub(super) async fn discover_visible_example_slugs(pool: &sqlx::PgPool) -> Vec<String> {
|
| 73 |
73 |
|
sqlx::query_scalar::<_, String>(
|
| 74 |
74 |
|
"SELECT p.slug::text FROM projects p \
|
| 75 |
75 |
|
JOIN users u ON u.id = p.user_id \
|
| 83 |
83 |
|
}
|
| 84 |
84 |
|
|
| 85 |
85 |
|
/// Total items under the seeded example projects.
|
| 86 |
|
- |
async fn count_example_items(pool: &sqlx::PgPool) -> i64 {
|
|
86 |
+ |
pub(super) async fn count_example_items(pool: &sqlx::PgPool) -> i64 {
|
| 87 |
87 |
|
sqlx::query_scalar(
|
| 88 |
88 |
|
"SELECT COUNT(*) FROM items i \
|
| 89 |
89 |
|
JOIN projects p ON p.id = i.project_id \
|
| 382 |
382 |
|
|
| 383 |
383 |
|
/// Guard-passing options bundled with an in-memory storage backend, so the media
|
| 384 |
384 |
|
/// phase runs and flips items visible.
|
| 385 |
|
- |
fn media_ctx() -> SeedMedia {
|
|
385 |
+ |
pub(super) fn media_ctx() -> SeedMedia {
|
| 386 |
386 |
|
let s3: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new());
|
| 387 |
387 |
|
let public: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new());
|
| 388 |
388 |
|
SeedMedia {
|
| 495 |
495 |
|
}
|
| 496 |
496 |
|
}
|
| 497 |
497 |
|
|
| 498 |
|
- |
async fn password_hash_of(pool: &sqlx::PgPool, id: uuid::Uuid) -> String {
|
|
498 |
+ |
pub(super) async fn password_hash_of(pool: &sqlx::PgPool, id: uuid::Uuid) -> String {
|
| 499 |
499 |
|
sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
|
| 500 |
500 |
|
.bind(id)
|
| 501 |
501 |
|
.fetch_one(pool)
|
| 642 |
642 |
|
assert_eq!(harness_accounts, 0, "harness phase must be opt-in");
|
| 643 |
643 |
|
assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
|
| 644 |
644 |
|
}
|
| 645 |
|
- |
|
| 646 |
|
- |
// ── Demo buyer (GoingsOn 839a8e5a, option B) ────────────────────────────────
|
| 647 |
|
- |
//
|
| 648 |
|
- |
// One login-capable account with a purchase history, so the landing carousel's
|
| 649 |
|
- |
// third frame has a library to photograph. Opt-in per box, like the harness.
|
| 650 |
|
- |
|
| 651 |
|
- |
/// The demo buyer's password for a test run. Not a secret here; on testnot it
|
| 652 |
|
- |
/// comes from the box's EnvironmentFile.
|
| 653 |
|
- |
const BUYER_PASSWORD: &str = "demo-buyer-test-password";
|
| 654 |
|
- |
|
| 655 |
|
- |
/// Purchases seeded for the demo buyer (see `seed::buyer::PURCHASES`).
|
| 656 |
|
- |
const BUYER_PURCHASES: i64 = 9;
|
| 657 |
|
- |
|
| 658 |
|
- |
fn buyer_opts() -> buyer::BuyerOptions {
|
| 659 |
|
- |
buyer::BuyerOptions {
|
| 660 |
|
- |
password: BUYER_PASSWORD.to_string(),
|
| 661 |
|
- |
}
|
| 662 |
|
- |
}
|
| 663 |
|
- |
|
| 664 |
|
- |
fn testnot_opts_with_buyer() -> SeedOptions {
|
| 665 |
|
- |
SeedOptions {
|
| 666 |
|
- |
buyer: Some(buyer_opts()),
|
| 667 |
|
- |
..testnot_opts()
|
| 668 |
|
- |
}
|
| 669 |
|
- |
}
|
| 670 |
|
- |
|
| 671 |
|
- |
async fn buyer_purchase_count(pool: &sqlx::PgPool) -> i64 {
|
| 672 |
|
- |
sqlx::query_scalar(
|
| 673 |
|
- |
"SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND status = 'completed'",
|
| 674 |
|
- |
)
|
| 675 |
|
- |
.bind(BUYER_ACCOUNT_ID)
|
| 676 |
|
- |
.fetch_one(pool)
|
| 677 |
|
- |
.await
|
| 678 |
|
- |
.expect("count buyer purchases")
|
| 679 |
|
- |
}
|
| 680 |
|
- |
|
| 681 |
|
- |
#[tokio::test]
|
| 682 |
|
- |
async fn demo_buyer_can_log_in_and_owns_a_library() {
|
| 683 |
|
- |
let db = TestDb::new().await;
|
| 684 |
|
- |
seed::run(&db.pool, &testnot_opts_with_buyer(), &media_ctx())
|
| 685 |
|
- |
.await
|
| 686 |
|
- |
.expect("seed with buyer phase");
|
| 687 |
|
- |
|
| 688 |
|
- |
// The login the capture run performs.
|
| 689 |
|
- |
let hash = password_hash_of(&db.pool, BUYER_ACCOUNT_ID).await;
|
| 690 |
|
- |
assert!(
|
| 691 |
|
- |
makenotwork::auth::verify_password_async(BUYER_PASSWORD.to_string(), hash)
|
| 692 |
|
- |
.await
|
| 693 |
|
- |
.expect("verify"),
|
| 694 |
|
- |
"the demo buyer's password should verify"
|
| 695 |
|
- |
);
|
| 696 |
|
- |
|
| 697 |
|
- |
// Not a sandbox account: `SessionUser::check_not_sandbox` would refuse the
|
| 698 |
|
- |
// session, and not a creator, because the point is a buyer.
|
| 699 |
|
- |
let (is_sandbox, can_create): (bool, bool) =
|
| 700 |
|
- |
sqlx::query_as("SELECT is_sandbox, can_create_projects FROM users WHERE id = $1")
|
| 701 |
|
- |
.bind(BUYER_ACCOUNT_ID)
|
| 702 |
|
- |
.fetch_one(&db.pool)
|
| 703 |
|
- |
.await
|
| 704 |
|
- |
.expect("buyer account");
|
| 705 |
|
- |
assert!(!is_sandbox, "a sandbox account cannot hold a session");
|
| 706 |
|
- |
assert!(!can_create, "the demo buyer is a buyer");
|
| 707 |
|
- |
|
| 708 |
|
- |
// Every purchase landed, and the library reads them through the `purchases`
|
| 709 |
|
- |
// view, which filters on status = 'completed'.
|
| 710 |
|
- |
assert_eq!(buyer_purchase_count(&db.pool).await, BUYER_PURCHASES);
|
| 711 |
|
- |
let in_view: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM purchases WHERE buyer_id = $1")
|
| 712 |
|
- |
.bind(BUYER_ACCOUNT_ID)
|
| 713 |
|
- |
.fetch_one(&db.pool)
|
| 714 |
|
- |
.await
|
| 715 |
|
- |
.expect("purchases view");
|
| 716 |
|
- |
assert_eq!(in_view, BUYER_PURCHASES, "every purchase should be visible");
|
| 717 |
|
- |
|
| 718 |
|
- |
// But not the whole catalog: a library holding all eleven reads as a
|
| 719 |
|
- |
// fixture rather than as somebody's shelf. (The constant-only half of this
|
| 720 |
|
- |
// is `seed::buyer`'s own unit test; here it is checked against the items
|
| 721 |
|
- |
// actually in the database.)
|
| 722 |
|
- |
let unbought = count_example_items(&db.pool).await - BUYER_PURCHASES;
|
| 723 |
|
- |
assert!(
|
| 724 |
|
- |
unbought > 0,
|
| 725 |
|
- |
"the buyer should leave some of the catalog unbought"
|
| 726 |
|
- |
);
|
| 727 |
|
- |
|
| 728 |
|
- |
// Platform fee is zero on every row. MNW charges 0%, so a demo receipt
|
| 729 |
|
- |
// showing anything else would misrepresent the product.
|
| 730 |
|
- |
let nonzero_fees: i64 = sqlx::query_scalar(
|
| 731 |
|
- |
"SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND platform_fee_cents <> 0",
|
| 732 |
|
- |
)
|
| 733 |
|
- |
.bind(BUYER_ACCOUNT_ID)
|
| 734 |
|
- |
.fetch_one(&db.pool)
|
| 735 |
|
- |
.await
|
| 736 |
|
- |
.expect("fee check");
|
| 737 |
|
- |
assert_eq!(nonzero_fees, 0);
|
| 738 |
|
- |
|
| 739 |
|
- |
// Paid rows recorded what was paid. `get_user_purchases` derives its Free
|
| 740 |
|
- |
// badge from `amount_cents = 0`, so a paid item at zero would badge wrong.
|
| 741 |
|
- |
let paid: i64 = sqlx::query_scalar(
|
| 742 |
|
- |
"SELECT COUNT(*) FROM transactions t JOIN items i ON i.id = t.item_id \
|
| 743 |
|
- |
WHERE t.buyer_id = $1 AND t.amount_cents > 0",
|
| 744 |
|
- |
)
|
| 745 |
|
- |
.bind(BUYER_ACCOUNT_ID)
|
| 746 |
|
- |
.fetch_one(&db.pool)
|
| 747 |
|
- |
.await
|
| 748 |
|
- |
.expect("paid count");
|
| 749 |
|
- |
assert!(
|
| 750 |
|
- |
paid >= 4,
|
| 751 |
|
- |
"the history should include real payments, got {paid}"
|
| 752 |
|
- |
);
|
| 753 |
|
- |
|
| 754 |
|
- |
// An active subscription, so the library's subscription block is not empty.
|
| 755 |
|
- |
let (tier, status): (String, String) = sqlx::query_as(
|
| 756 |
|
- |
"SELECT t.name, s.status FROM subscriptions s \
|
| 757 |
|
- |
JOIN subscription_tiers t ON t.id = s.tier_id WHERE s.subscriber_id = $1",
|
| 758 |
|
- |
)
|
| 759 |
|
- |
.bind(BUYER_ACCOUNT_ID)
|
| 760 |
|
- |
.fetch_one(&db.pool)
|
| 761 |
|
- |
.await
|
| 762 |
|
- |
.expect("buyer subscription");
|
| 763 |
|
- |
assert_eq!((tier.as_str(), status.as_str()), ("Patron", "active"));
|
| 764 |
|
- |
|
| 765 |
|
- |
// License keys for the project that sells them: one of the things the
|
| 766 |
|
- |
// library page shows, and one of the things MNW sells.
|
| 767 |
|
- |
let keys: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM license_keys WHERE owner_id = $1")
|
| 768 |
|
- |
.bind(BUYER_ACCOUNT_ID)
|
| 769 |
|
- |
.fetch_one(&db.pool)
|
| 770 |
|
- |
.await
|
| 771 |
|
- |
.expect("license keys");
|
| 772 |
|
- |
assert!(keys >= 1, "a license-keyed purchase should carry a key");
|
| 773 |
|
- |
|
| 774 |
|
- |
// Most rows are marked downloaded, so the "new version" badge is a signal
|
| 775 |
|
- |
// rather than the default state of every row.
|
| 776 |
|
- |
let downloaded_items: i64 =
|
| 777 |
|
- |
sqlx::query_scalar("SELECT COUNT(DISTINCT item_id) FROM user_downloads WHERE user_id = $1")
|
| 778 |
|
- |
.bind(BUYER_ACCOUNT_ID)
|
| 779 |
|
- |
.fetch_one(&db.pool)
|
| 780 |
|
- |
.await
|
| 781 |
|
- |
.expect("downloads");
|
| 782 |
|
- |
assert!(
|
| 783 |
|
- |
downloaded_items >= 1,
|
| 784 |
|
- |
"the buyer should have downloaded something"
|
| 785 |
|
- |
);
|
| 786 |
|
- |
}
|
| 787 |
|
- |
|
| 788 |
|
- |
#[tokio::test]
|
| 789 |
|
- |
async fn demo_buyer_history_does_not_duplicate_across_reseeds() {
|
| 790 |
|
- |
let db = TestDb::new().await;
|
| 791 |
|
- |
seed::run(&db.pool, &testnot_opts_with_buyer(), &media_ctx())
|
| 792 |
|
- |
.await
|
| 793 |
|
- |
.expect("first seed");
|
| 794 |
|
- |
seed::run(&db.pool, &testnot_opts_with_buyer(), &media_ctx())
|
| 795 |
|
- |
.await
|
| 796 |
|
- |
.expect("reseed");
|
| 797 |
|
- |
|
| 798 |
|
- |
// The catalog phases get idempotency from the example-data wipe, but this
|
| 799 |
|
- |
// account is keyed by a fixed id and is not in that set, so its history has
|
| 800 |
|
- |
// to be cleared explicitly. Without that, every reseed doubles the library.
|
| 801 |
|
- |
assert_eq!(buyer_purchase_count(&db.pool).await, BUYER_PURCHASES);
|
| 802 |
|
- |
|
| 803 |
|
- |
let subs: i64 =
|
| 804 |
|
- |
sqlx::query_scalar("SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1")
|
| 805 |
|
- |
.bind(BUYER_ACCOUNT_ID)
|
| 806 |
|
- |
.fetch_one(&db.pool)
|
| 807 |
|
- |
.await
|
| 808 |
|
- |
.expect("subscription count");
|
| 809 |
|
- |
assert_eq!(subs, 1, "a reseed must not stack subscriptions");
|
| 810 |
|
- |
|
| 811 |
|
- |
// And the id is stable, which is what lets a stored session cookie or a
|
| 812 |
|
- |
// scripted login survive a reset.
|
| 813 |
|
- |
let id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = $1")
|
| 814 |
|
- |
.bind("demo_collector")
|
| 815 |
|
- |
.fetch_one(&db.pool)
|
| 816 |
|
- |
.await
|
| 817 |
|
- |
.expect("buyer id");
|
| 818 |
|
- |
assert_eq!(id, BUYER_ACCOUNT_ID);
|
| 819 |
|
- |
}
|
| 820 |
|
- |
|
| 821 |
|
- |
#[tokio::test]
|
| 822 |
|
- |
async fn without_buyer_options_the_phase_does_not_run() {
|
| 823 |
|
- |
let db = TestDb::new().await;
|
| 824 |
|
- |
seed::run(&db.pool, &testnot_opts(), &media_ctx())
|
| 825 |
|
- |
.await
|
| 826 |
|
- |
.expect("seed without buyer");
|
| 827 |
|
- |
|
| 828 |
|
- |
let accounts: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = $1")
|
| 829 |
|
- |
.bind(BUYER_ACCOUNT_ID)
|
| 830 |
|
- |
.fetch_one(&db.pool)
|
| 831 |
|
- |
.await
|
| 832 |
|
- |
.expect("count");
|
| 833 |
|
- |
assert_eq!(accounts, 0, "the demo-buyer phase must be opt-in");
|
| 834 |
|
- |
assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
|
| 835 |
|
- |
}
|
| 836 |
|
- |
|
| 837 |
|
- |
#[tokio::test]
|
| 838 |
|
- |
async fn the_demo_buyer_changes_nothing_a_visitor_can_see() {
|
| 839 |
|
- |
let db = TestDb::new().await;
|
| 840 |
|
- |
seed::run(&db.pool, &testnot_opts_with_buyer(), &media_ctx())
|
| 841 |
|
- |
.await
|
| 842 |
|
- |
.expect("seed with buyer phase");
|
| 843 |
|
- |
|
| 844 |
|
- |
// Option B's boundary, asserted rather than merely documented: the buyer is
|
| 845 |
|
- |
// a capture credential, not a demo surface. It owns no project, so it never
|
| 846 |
|
- |
// appears on /discover or /creators, and it publishes nothing.
|
| 847 |
|
- |
let owned_projects: i64 =
|
| 848 |
|
- |
sqlx::query_scalar("SELECT COUNT(*) FROM projects WHERE user_id = $1")
|
| 849 |
|
- |
.bind(BUYER_ACCOUNT_ID)
|
| 850 |
|
- |
.fetch_one(&db.pool)
|
| 851 |
|
- |
.await
|
| 852 |
|
- |
.expect("owned projects");
|
| 853 |
|
- |
assert_eq!(owned_projects, 0);
|
| 854 |
|
- |
|
| 855 |
|
- |
// The catalog a visitor sees is exactly what it was without the phase.
|
| 856 |
|
- |
assert_eq!(
|
| 857 |
|
- |
discover_visible_example_slugs(&db.pool).await.len() as i64,
|
| 858 |
|
- |
SEEDED_CREATORS
|
| 859 |
|
- |
);
|
| 860 |
|
- |
}
|