max / makenotwork
5 files changed,
+476 insertions,
-26 deletions
| @@ -1,20 +1,64 @@ | |||
| 1 | 1 | //! Seed the fabricated example creators (`@example.test`) and hold the roster | |
| 2 | 2 | //! that drives the rest of the seed. | |
| 3 | 3 | //! | |
| 4 | - | //! The roster is the single source of truth for Phase 1: each [`CreatorSpec`] | |
| 5 | - | //! carries the creator's public identity plus the one project they own (built by | |
| 6 | - | //! [`super::projects`]). Naming follows the locked 2026-07-11 convention — real | |
| 7 | - | //! apps never appear under their real names; public-domain / CC0 media accounts | |
| 8 | - | //! get thematic handles, purely-generated copy gets invented names. | |
| 4 | + | //! The roster is the single source of truth for the content phases: each | |
| 5 | + | //! [`CreatorSpec`] carries the creator's public identity plus the one project | |
| 6 | + | //! they own (built by [`super::projects`]) and that project's items, pricing, and | |
| 7 | + | //! subscription tiers (built by [`super::items`]). Naming follows the locked | |
| 8 | + | //! 2026-07-11 convention — real apps never appear under their real names; | |
| 9 | + | //! public-domain / CC0 media accounts get thematic handles, purely-generated copy | |
| 10 | + | //! gets invented names. | |
| 9 | 11 | ||
| 10 | 12 | use uuid::Uuid; | |
| 11 | 13 | ||
| 12 | 14 | use super::{SeedError, EXAMPLE_EMAIL_DOMAIN}; | |
| 13 | 15 | use crate::auth; | |
| 14 | - | use crate::db::{self, DbUser, Email, Username}; | |
| 16 | + | use crate::db::{self, DbUser, Email, ItemType, Username}; | |
| 15 | 17 | ||
| 16 | - | /// The single project a seeded creator owns. Pricing (per-`PricingKind`) and | |
| 17 | - | /// media land on this project's items in Phases 2-3; Phase 1 seeds the shell. | |
| 18 | + | /// Per-item pricing. The item's `PricingKind` is *derived* from these fields | |
| 19 | + | /// (`pricing.rs::for_item`): price 0 → Free, price > 0 → BuyOnce, `pwyw_enabled` | |
| 20 | + | /// → Pwyw. Subscription is a project-level concern ([`ProjectPricing`]), not an | |
| 21 | + | /// item price. | |
| 22 | + | pub enum ItemPricing { | |
| 23 | + | /// `price_cents = 0`. | |
| 24 | + | Free, | |
| 25 | + | /// `price_cents = cents` (> 0). | |
| 26 | + | BuyOnce(i32), | |
| 27 | + | /// Pay-what-you-want with a minimum of `min` cents. | |
| 28 | + | Pwyw { min: i32 }, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// A project's headline pricing model (`projects.pricing_model`). | |
| 32 | + | pub enum ProjectPricing { | |
| 33 | + | Free, | |
| 34 | + | BuyOnce(i32), | |
| 35 | + | Pwyw { min: i32 }, | |
| 36 | + | /// Access via subscription tiers ([`ProjectSpec::tiers`]). | |
| 37 | + | Subscription, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// A subscription tier on a project (`subscription_tiers`). | |
| 41 | + | pub struct TierSpec { | |
| 42 | + | pub name: &'static str, | |
| 43 | + | pub description: &'static str, | |
| 44 | + | pub price_cents: i32, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// One item within a project. Media (real files) attaches in Phase 3; Phase 2 | |
| 48 | + | /// seeds the row, its pricing, and its tags. `body` (Text items) is set inline. | |
| 49 | + | pub struct ItemSpec { | |
| 50 | + | pub title: &'static str, | |
| 51 | + | pub description: &'static str, | |
| 52 | + | pub item_type: ItemType, | |
| 53 | + | pub pricing: ItemPricing, | |
| 54 | + | /// Pre-seeded tag slugs to attach (resolved via `get_tag_by_slug`; unknown | |
| 55 | + | /// slugs are skipped). The first present slug becomes the primary tag. | |
| 56 | + | pub tags: &'static [&'static str], | |
| 57 | + | /// Markdown body for Text items; `None` for everything else. | |
| 58 | + | pub body: Option<&'static str>, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// The single project a seeded creator owns, plus its content. | |
| 18 | 62 | pub struct ProjectSpec { | |
| 19 | 63 | /// URL slug (auto-suffixed on the rare collision by `create_project`). | |
| 20 | 64 | pub slug: &'static str, | |
| @@ -24,6 +68,12 @@ pub struct ProjectSpec { | |||
| 24 | 68 | pub description: &'static str, | |
| 25 | 69 | /// Enabled features; `create_project` derives `project_type` from these. | |
| 26 | 70 | pub features: &'static [&'static str], | |
| 71 | + | /// Headline pricing model for the project. | |
| 72 | + | pub pricing: ProjectPricing, | |
| 73 | + | /// Subscription tiers (only non-empty for a `Subscription` project). | |
| 74 | + | pub tiers: &'static [TierSpec], | |
| 75 | + | /// Items to seed under this project. | |
| 76 | + | pub items: &'static [ItemSpec], | |
| 27 | 77 | } | |
| 28 | 78 | ||
| 29 | 79 | /// One fabricated creator plus the project they own. | |
| @@ -46,8 +96,20 @@ pub struct SeededCreator { | |||
| 46 | 96 | pub project: &'static ProjectSpec, | |
| 47 | 97 | } | |
| 48 | 98 | ||
| 49 | - | /// The five Phase-1 creators. Together their projects span all four | |
| 50 | - | /// `PricingKind`s (realized via items in Phase 2). See the sprint doc | |
| 99 | + | /// A short original essay body for the Text item (generic, public-domain-flavored | |
| 100 | + | /// prose written for the seed — not copied from any real work). | |
| 101 | + | const SLOW_READING_BODY: &str = "\ | |
| 102 | + | There is a kind of reading that resists hurry. It asks you to sit with a sentence \ | |
| 103 | + | until it gives up its second meaning, and then its third. | |
| 104 | + | ||
| 105 | + | We have built our tools for the opposite habit — to skim, to extract, to move on. \ | |
| 106 | + | This press is a small argument against that. The editions here are set with wide \ | |
| 107 | + | margins on purpose: room for a pencil, room for a second thought. | |
| 108 | + | ||
| 109 | + | Read slowly. Read the same page twice. The commons is patient, and so are its books."; | |
| 110 | + | ||
| 111 | + | /// The five content creators. Their projects span all four `PricingKind`s and, | |
| 112 | + | /// across their items, every `ItemType`. See the sprint doc | |
| 51 | 113 | /// `_private/docs/mnw/testnot-example-seed.md`. | |
| 52 | 114 | const ROSTER: &[CreatorSpec] = &[ | |
| 53 | 115 | CreatorSpec { | |
| @@ -63,6 +125,37 @@ const ROSTER: &[CreatorSpec] = &[ | |||
| 63 | 125 | share; name your price if you'd like to support the \ | |
| 64 | 126 | restoration work.", | |
| 65 | 127 | features: &["audio"], | |
| 128 | + | pricing: ProjectPricing::Pwyw { min: 100 }, | |
| 129 | + | tiers: &[], | |
| 130 | + | items: &[ | |
| 131 | + | ItemSpec { | |
| 132 | + | title: "Restoration No. 1 (Full Mix)", | |
| 133 | + | description: "The complete restored recording, remastered from \ | |
| 134 | + | a public-domain source. Name your price.", | |
| 135 | + | item_type: ItemType::Audio, | |
| 136 | + | pricing: ItemPricing::Pwyw { min: 100 }, | |
| 137 | + | tags: &["audio", "audio.format.music"], | |
| 138 | + | body: None, | |
| 139 | + | }, | |
| 140 | + | ItemSpec { | |
| 141 | + | title: "Stem Pack: Strings", | |
| 142 | + | description: "Isolated string stems from the restoration, for \ | |
| 143 | + | sampling and study. WAV, ready for your DAW.", | |
| 144 | + | item_type: ItemType::Sample, | |
| 145 | + | pricing: ItemPricing::Pwyw { min: 200 }, | |
| 146 | + | tags: &["audio.format.samples", "audio.technique.sampling"], | |
| 147 | + | body: None, | |
| 148 | + | }, | |
| 149 | + | ItemSpec { | |
| 150 | + | title: "Session Take (Video)", | |
| 151 | + | description: "A short archival video of the session, restored \ | |
| 152 | + | and captioned.", | |
| 153 | + | item_type: ItemType::Video, | |
| 154 | + | pricing: ItemPricing::Pwyw { min: 100 }, | |
| 155 | + | tags: &["video", "video.genre.music-video"], | |
| 156 | + | body: None, | |
| 157 | + | }, | |
| 158 | + | ], | |
| 66 | 159 | }, | |
| 67 | 160 | }, | |
| 68 | 161 | CreatorSpec { | |
| @@ -79,6 +172,46 @@ const ROSTER: &[CreatorSpec] = &[ | |||
| 79 | 172 | everything locally, and stays out of your way. One \ | |
| 80 | 173 | download, buy it once.", | |
| 81 | 174 | features: &["downloads", "license_keys"], | |
| 175 | + | pricing: ProjectPricing::BuyOnce(900), | |
| 176 | + | tiers: &[], | |
| 177 | + | items: &[ | |
| 178 | + | ItemSpec { | |
| 179 | + | title: "Deskriver Focus (Plugin)", | |
| 180 | + | description: "A focus-timer plugin for the suite. Signed \ | |
| 181 | + | builds, offline, no telemetry.", | |
| 182 | + | item_type: ItemType::Plugin, | |
| 183 | + | pricing: ItemPricing::BuyOnce(1200), | |
| 184 | + | tags: &["software.format.plugin", "software.format.vst3"], | |
| 185 | + | body: None, | |
| 186 | + | }, | |
| 187 | + | ItemSpec { | |
| 188 | + | title: "Minimal Preset Pack", | |
| 189 | + | description: "A set of restrained presets for the suite — \ | |
| 190 | + | sensible defaults, nothing flashy.", | |
| 191 | + | item_type: ItemType::Preset, | |
| 192 | + | pricing: ItemPricing::BuyOnce(500), | |
| 193 | + | tags: &["software", "software.platform.macos"], | |
| 194 | + | body: None, | |
| 195 | + | }, | |
| 196 | + | ItemSpec { | |
| 197 | + | title: "Weekly-Review Template", | |
| 198 | + | description: "A ready-to-use weekly-review layout. Import it \ | |
| 199 | + | and adapt it to your week.", | |
| 200 | + | item_type: ItemType::Template, | |
| 201 | + | pricing: ItemPricing::BuyOnce(300), | |
| 202 | + | tags: &["writing.topic.productivity", "software"], | |
| 203 | + | body: None, | |
| 204 | + | }, | |
| 205 | + | ItemSpec { | |
| 206 | + | title: "Deskriver Utility (Download)", | |
| 207 | + | description: "The core command-line utility. Signed and \ | |
| 208 | + | notarized for macOS; runs fully offline.", | |
| 209 | + | item_type: ItemType::Digital, | |
| 210 | + | pricing: ItemPricing::BuyOnce(900), | |
| 211 | + | tags: &["software.format.cli", "software.platform.macos", "software.format.desktop"], | |
| 212 | + | body: None, | |
| 213 | + | }, | |
| 214 | + | ], | |
| 82 | 215 | }, | |
| 83 | 216 | }, | |
| 84 | 217 | CreatorSpec { | |
| @@ -94,6 +227,17 @@ const ROSTER: &[CreatorSpec] = &[ | |||
| 94 | 227 | full-resolution files and use them however you like, no \ | |
| 95 | 228 | attribution required.", | |
| 96 | 229 | features: &["downloads"], | |
| 230 | + | pricing: ProjectPricing::Free, | |
| 231 | + | tiers: &[], | |
| 232 | + | items: &[ItemSpec { | |
| 233 | + | title: "Field Study 01 (Print)", | |
| 234 | + | description: "A high-resolution landscape study, released CC0. \ | |
| 235 | + | Free to download, print, and remix.", | |
| 236 | + | item_type: ItemType::Image, | |
| 237 | + | pricing: ItemPricing::Free, | |
| 238 | + | tags: &["visual.medium.photography", "visual"], | |
| 239 | + | body: None, | |
| 240 | + | }], | |
| 97 | 241 | }, | |
| 98 | 242 | }, | |
| 99 | 243 | CreatorSpec { | |
| @@ -108,6 +252,46 @@ const ROSTER: &[CreatorSpec] = &[ | |||
| 108 | 252 | readable editions. Subscribers get a new volume every \ | |
| 109 | 253 | month, delivered as EPUB and PDF.", | |
| 110 | 254 | features: &["text", "blog", "subscriptions"], | |
| 255 | + | pricing: ProjectPricing::Subscription, | |
| 256 | + | tiers: &[ | |
| 257 | + | TierSpec { | |
| 258 | + | name: "Reader", | |
| 259 | + | description: "The monthly edition, EPUB and PDF.", | |
| 260 | + | price_cents: 300, | |
| 261 | + | }, | |
| 262 | + | TierSpec { | |
| 263 | + | name: "Patron", | |
| 264 | + | description: "The monthly edition plus the working notes and \ | |
| 265 | + | early proofs.", | |
| 266 | + | price_cents: 600, | |
| 267 | + | }, | |
| 268 | + | TierSpec { | |
| 269 | + | name: "Benefactor", | |
| 270 | + | description: "Everything, plus a printed copy of the annual \ | |
| 271 | + | collection.", | |
| 272 | + | price_cents: 1200, | |
| 273 | + | }, | |
| 274 | + | ], | |
| 275 | + | items: &[ | |
| 276 | + | ItemSpec { | |
| 277 | + | title: "On Slow Reading", | |
| 278 | + | description: "The opening essay of the reader — an argument \ | |
| 279 | + | for reading twice.", | |
| 280 | + | item_type: ItemType::Text, | |
| 281 | + | pricing: ItemPricing::Free, | |
| 282 | + | tags: &["writing.format.essay", "writing.topic.creativity"], | |
| 283 | + | body: Some(SLOW_READING_BODY), | |
| 284 | + | }, | |
| 285 | + | ItemSpec { | |
| 286 | + | title: "Typesetting the Commons", | |
| 287 | + | description: "A short course on preparing public-domain texts \ | |
| 288 | + | as clean digital editions.", | |
| 289 | + | item_type: ItemType::Course, | |
| 290 | + | pricing: ItemPricing::Free, | |
| 291 | + | tags: &["education.format.course", "education.topic.writing"], | |
| 292 | + | body: None, | |
| 293 | + | }, | |
| 294 | + | ], | |
| 111 | 295 | }, | |
| 112 | 296 | }, | |
| 113 | 297 | CreatorSpec { | |
| @@ -124,6 +308,17 @@ const ROSTER: &[CreatorSpec] = &[ | |||
| 124 | 308 | anything routed through the account passes straight to \ | |
| 125 | 309 | community projects.", | |
| 126 | 310 | features: &["downloads"], | |
| 311 | + | pricing: ProjectPricing::Free, | |
| 312 | + | tiers: &[], | |
| 313 | + | items: &[ItemSpec { | |
| 314 | + | title: "Community Bundle Vol. 1", | |
| 315 | + | description: "A free bundle collecting highlights shared through \ | |
| 316 | + | the account — music and art, all freely licensed.", | |
| 317 | + | item_type: ItemType::Bundle, | |
| 318 | + | pricing: ItemPricing::Free, | |
| 319 | + | tags: &["audio", "visual"], | |
| 320 | + | body: None, | |
| 321 | + | }], | |
| 127 | 322 | }, | |
| 128 | 323 | }, | |
| 129 | 324 | ]; |
| @@ -1,2 +1,132 @@ | |||
| 1 | - | //! Seed at least one item per `ItemType`, across pricing kinds and tags. | |
| 2 | - | //! Filled in Phase 2. | |
| 1 | + | //! Seed each project's items — at least one per `ItemType`, across pricing kinds | |
| 2 | + | //! and tags — plus the project's headline pricing model and subscription tiers. | |
| 3 | + | //! | |
| 4 | + | //! Items are seeded **hidden** until Phase 3. Note `items.scan_status` now | |
| 5 | + | //! defaults to `'clean'` (migration 146 — item-level scan status is vestigial, | |
| 6 | + | //! real gating is per-version at download time), so a fresh file-less item would | |
| 7 | + | //! be discover-visible. Since these items have no media yet, this module | |
| 8 | + | //! explicitly forces `scan_status = 'pending'` (which the discover gate excludes) | |
| 9 | + | //! so nothing shows a broken download; Phase 3 attaches real files and flips them | |
| 10 | + | //! back to `'clean'`. So this phase establishes rows, pricing, tags, and tiers only. | |
| 11 | + | ||
| 12 | + | use super::creators::{ItemPricing, ProjectPricing}; | |
| 13 | + | use super::projects::SeededProject; | |
| 14 | + | use super::SeedError; | |
| 15 | + | use crate::db::{self, AiTier, PriceCents, PricingKind}; | |
| 16 | + | ||
| 17 | + | /// Seed items, pricing, and tiers for every seeded project. | |
| 18 | + | /// | |
| 19 | + | /// Called from [`super::run`] after [`super::projects::seed_projects`]. All writes | |
| 20 | + | /// are ownership-checked against the project's creator (`user_id`), matching the | |
| 21 | + | /// real authoring paths. | |
| 22 | + | pub async fn seed_items( | |
| 23 | + | pool: &sqlx::PgPool, | |
| 24 | + | projects: &[SeededProject], | |
| 25 | + | ) -> Result<(), SeedError> { | |
| 26 | + | for seeded in projects { | |
| 27 | + | seed_project_pricing(pool, seeded).await?; | |
| 28 | + | for spec in seeded.spec.items { | |
| 29 | + | seed_one_item(pool, seeded, spec).await?; | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | Ok(()) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Set the project's headline `pricing_model` and, for a subscription project, | |
| 36 | + | /// its tiers. | |
| 37 | + | async fn seed_project_pricing(pool: &sqlx::PgPool, seeded: &SeededProject) -> Result<(), SeedError> { | |
| 38 | + | let (kind, price, pwyw_min) = match seeded.spec.pricing { | |
| 39 | + | ProjectPricing::Free => (PricingKind::Free, PriceCents::ZERO, None), | |
| 40 | + | ProjectPricing::BuyOnce(cents) => (PricingKind::BuyOnce, PriceCents::from_db(cents), None), | |
| 41 | + | ProjectPricing::Pwyw { min } => { | |
| 42 | + | (PricingKind::Pwyw, PriceCents::ZERO, Some(PriceCents::from_db(min))) | |
| 43 | + | } | |
| 44 | + | ProjectPricing::Subscription => (PricingKind::Subscription, PriceCents::ZERO, None), | |
| 45 | + | }; | |
| 46 | + | db::projects::update_project_pricing(pool, seeded.project.id, seeded.user_id, kind, price, pwyw_min) | |
| 47 | + | .await?; | |
| 48 | + | ||
| 49 | + | for tier in seeded.spec.tiers { | |
| 50 | + | db::subscriptions::create_subscription_tier( | |
| 51 | + | pool, | |
| 52 | + | seeded.project.id, | |
| 53 | + | tier.name, | |
| 54 | + | Some(tier.description), | |
| 55 | + | PriceCents::from_db(tier.price_cents), | |
| 56 | + | ) | |
| 57 | + | .await?; | |
| 58 | + | } | |
| 59 | + | Ok(()) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Create one item, apply its per-item pricing / text body, and attach its tags. | |
| 63 | + | async fn seed_one_item( | |
| 64 | + | pool: &sqlx::PgPool, | |
| 65 | + | seeded: &SeededProject, | |
| 66 | + | spec: &super::creators::ItemSpec, | |
| 67 | + | ) -> Result<(), SeedError> { | |
| 68 | + | // create_item takes the fixed price; Pwyw is enabled via a follow-up update. | |
| 69 | + | let price = match spec.pricing { | |
| 70 | + | ItemPricing::Free | ItemPricing::Pwyw { .. } => PriceCents::ZERO, | |
| 71 | + | ItemPricing::BuyOnce(cents) => PriceCents::from_db(cents), | |
| 72 | + | }; | |
| 73 | + | let item = db::items::create_item( | |
| 74 | + | pool, | |
| 75 | + | seeded.project.id, | |
| 76 | + | spec.title, | |
| 77 | + | Some(spec.description), | |
| 78 | + | price, | |
| 79 | + | spec.item_type, | |
| 80 | + | AiTier::Handmade, | |
| 81 | + | None, | |
| 82 | + | ) | |
| 83 | + | .await?; | |
| 84 | + | ||
| 85 | + | if let ItemPricing::Pwyw { min } = spec.pricing { | |
| 86 | + | db::items::update_item( | |
| 87 | + | pool, | |
| 88 | + | item.id, | |
| 89 | + | seeded.user_id, | |
| 90 | + | None, | |
| 91 | + | None, | |
| 92 | + | None, | |
| 93 | + | None, | |
| 94 | + | None, | |
| 95 | + | Some(true), | |
| 96 | + | Some(PriceCents::from_db(min)), | |
| 97 | + | None, | |
| 98 | + | None, | |
| 99 | + | None, | |
| 100 | + | None, | |
| 101 | + | ) | |
| 102 | + | .await?; | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | if let Some(body) = spec.body { | |
| 106 | + | db::items::update_item_text(pool, item.id, seeded.user_id, body).await?; | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Keep the item hidden until Phase 3 attaches media. `items.scan_status` | |
| 110 | + | // defaults to 'clean' (migration 146), so an unfinished, file-less item would | |
| 111 | + | // otherwise show in discover with nothing to download. | |
| 112 | + | db::scanning::update_item_scan_status(pool, item.id, db::FileScanStatus::Pending).await?; | |
| 113 | + | ||
| 114 | + | // Tags: resolve each pre-seeded slug; the first that resolves is primary. | |
| 115 | + | let mut primary_assigned = false; | |
| 116 | + | for slug in spec.tags { | |
| 117 | + | if let Some(tag) = db::tags::get_tag_by_slug(pool, slug).await? { | |
| 118 | + | let is_primary = !primary_assigned; | |
| 119 | + | db::tags::add_tag_to_item(pool, item.id, tag.id, is_primary).await?; | |
| 120 | + | primary_assigned = true; | |
| 121 | + | } else { | |
| 122 | + | tracing::warn!(slug, "example seed: tag slug not found, skipping"); | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | tracing::info!( | |
| 127 | + | title = spec.title, | |
| 128 | + | item_type = ?spec.item_type, | |
| 129 | + | "example seed: created item" | |
| 130 | + | ); | |
| 131 | + | Ok(()) | |
| 132 | + | } |
| @@ -119,11 +119,13 @@ pub async fn run(pool: &PgPool, opts: &SeedOptions) -> Result<(), SeedError> { | |||
| 119 | 119 | let wiped = reset_example_data(pool).await?; | |
| 120 | 120 | tracing::info!(wiped_users = wiped, "example seed: cleared prior example accounts"); | |
| 121 | 121 | ||
| 122 | - | // Phase 1: durable creators, then one project each. Later phases layer items | |
| 123 | - | // (2), media (3), and blog/social (4) on top of these rows. | |
| 122 | + | // Phases 1-2: durable creators, one project each, then items + pricing + tiers | |
| 123 | + | // + tags. Items are seeded hidden (`scan_status='pending'`); Phase 3 attaches | |
| 124 | + | // media and makes them visible. Phase 4 layers blog/social on top. | |
| 124 | 125 | let creators = creators::seed_creators(pool).await?; | |
| 125 | - | projects::seed_projects(pool, &creators).await?; | |
| 126 | - | tracing::warn!("example seed: items/media/blog/social pending (Phases 2-4)"); | |
| 126 | + | let projects = projects::seed_projects(pool, &creators).await?; | |
| 127 | + | items::seed_items(pool, &projects).await?; | |
| 128 | + | tracing::warn!("example seed: media/blog/social pending (Phases 3-4)"); | |
| 127 | 129 | ||
| 128 | 130 | Ok(()) | |
| 129 | 131 | } |
| @@ -3,12 +3,22 @@ | |||
| 3 | 3 | //! Each project is public the moment it is inserted: `create_project` leaves | |
| 4 | 4 | //! `is_public` at its `true` default and never sets `is_sandbox`, so the pair | |
| 5 | 5 | //! (durable creator + project) clears the only two public-visibility gates. The | |
| 6 | - | //! intended per-project `PricingKind` is realized via items in Phase 2; here we | |
| 7 | - | //! only establish the project shell and its `features`. | |
| 6 | + | //! project's pricing, items, and tiers land in [`super::items`]; this module | |
| 7 | + | //! returns enough context ([`SeededProject`]) for that step. | |
| 8 | 8 | ||
| 9 | - | use super::creators::SeededCreator; | |
| 9 | + | use super::creators::{ProjectSpec, SeededCreator}; | |
| 10 | 10 | use super::SeedError; | |
| 11 | - | use crate::db::{self, Slug}; | |
| 11 | + | use crate::db::{self, DbProject, Slug, UserId}; | |
| 12 | + | ||
| 13 | + | /// A project after insertion, with the owner and spec its items still need. | |
| 14 | + | pub struct SeededProject { | |
| 15 | + | /// The owning creator's id (needed for ownership-checked item/pricing writes). | |
| 16 | + | pub user_id: UserId, | |
| 17 | + | /// The inserted project row. | |
| 18 | + | pub project: DbProject, | |
| 19 | + | /// The spec that produced it — carries the item, pricing, and tier data. | |
| 20 | + | pub spec: &'static ProjectSpec, | |
| 21 | + | } | |
| 12 | 22 | ||
| 13 | 23 | /// Create the one project each seeded creator owns. | |
| 14 | 24 | /// | |
| @@ -18,7 +28,8 @@ use crate::db::{self, Slug}; | |||
| 18 | 28 | pub async fn seed_projects( | |
| 19 | 29 | pool: &sqlx::PgPool, | |
| 20 | 30 | creators: &[SeededCreator], | |
| 21 | - | ) -> Result<(), SeedError> { | |
| 31 | + | ) -> Result<Vec<SeededProject>, SeedError> { | |
| 32 | + | let mut seeded = Vec::with_capacity(creators.len()); | |
| 22 | 33 | for creator in creators { | |
| 23 | 34 | let spec = creator.project; | |
| 24 | 35 | let slug = Slug::from_trusted(spec.slug.to_string()); | |
| @@ -39,6 +50,11 @@ pub async fn seed_projects( | |||
| 39 | 50 | user_id = %creator.user.id, | |
| 40 | 51 | "example seed: created project" | |
| 41 | 52 | ); | |
| 53 | + | seeded.push(SeededProject { | |
| 54 | + | user_id: creator.user.id, | |
| 55 | + | project, | |
| 56 | + | spec, | |
| 57 | + | }); | |
| 42 | 58 | } | |
| 43 | - | Ok(()) | |
| 59 | + | Ok(seeded) | |
| 44 | 60 | } |
| @@ -1,4 +1,4 @@ | |||
| 1 | - | //! DB-layer contract tests for the `--seed-examples` flow (`seed::run`, Phase 1). | |
| 1 | + | //! DB-layer contract tests for the `--seed-examples` flow (`seed::run`, Phases 1-2). | |
| 2 | 2 | //! | |
| 3 | 3 | //! These exercise the seed against real Postgres to prove the invariants the | |
| 4 | 4 | //! example marketplace rests on: | |
| @@ -9,15 +9,20 @@ | |||
| 9 | 9 | //! 2. Every seeded creator+project is publicly visible — `is_sandbox = FALSE` | |
| 10 | 10 | //! on the user and `is_public = true` on the project, the only two gates the | |
| 11 | 11 | //! discover feed applies. The discover predicate returning them is the proof. | |
| 12 | - | //! 3. Idempotency: a second `run()` wipes the prior example data first, so | |
| 12 | + | //! 3. Phase-2 content: one item of every `ItemType`, spanning every pricing | |
| 13 | + | //! kind, with tags and subscription tiers — and all items **hidden** | |
| 14 | + | //! (`scan_status='pending'`) until Phase 3 attaches media. | |
| 15 | + | //! 4. Idempotency: a second `run()` wipes the prior example data first, so | |
| 13 | 16 | //! counts stay fixed instead of doubling. | |
| 14 | - | //! 4. The prod-host guard still refuses even with the opt-in flag set. | |
| 17 | + | //! 5. The prod-host guard still refuses even with the opt-in flag set. | |
| 15 | 18 | ||
| 16 | 19 | use crate::harness::db::TestDb; | |
| 17 | 20 | use makenotwork::seed::{self, SeedOptions}; | |
| 18 | 21 | ||
| 19 | 22 | /// The Phase-1 roster size (see `seed::creators::ROSTER`). | |
| 20 | 23 | const SEEDED_CREATORS: i64 = 5; | |
| 24 | + | /// Phase-2 item count: one per `ItemType` spread across the projects. | |
| 25 | + | const SEEDED_ITEMS: i64 = 11; | |
| 21 | 26 | ||
| 22 | 27 | /// Guard-passing options for a test run: opt-in on, approved example host. | |
| 23 | 28 | fn testnot_opts() -> SeedOptions { | |
| @@ -65,6 +70,35 @@ async fn discover_visible_example_slugs(pool: &sqlx::PgPool) -> Vec<String> { | |||
| 65 | 70 | .expect("discover-visible example slugs") | |
| 66 | 71 | } | |
| 67 | 72 | ||
| 73 | + | /// Total items under the seeded example projects. | |
| 74 | + | async fn count_example_items(pool: &sqlx::PgPool) -> i64 { | |
| 75 | + | sqlx::query_scalar( | |
| 76 | + | "SELECT COUNT(*) FROM items i \ | |
| 77 | + | JOIN projects p ON p.id = i.project_id \ | |
| 78 | + | JOIN users u ON u.id = p.user_id \ | |
| 79 | + | WHERE lower(split_part(u.email, '@', 2)) = 'example.test'", | |
| 80 | + | ) | |
| 81 | + | .fetch_one(pool) | |
| 82 | + | .await | |
| 83 | + | .expect("count example items") | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Example items that clear the *exact* item-discover gate. Phase 2 seeds items | |
| 87 | + | /// hidden (`scan_status='pending'`), so this must be zero until Phase 3. | |
| 88 | + | async fn count_discover_visible_example_items(pool: &sqlx::PgPool) -> i64 { | |
| 89 | + | sqlx::query_scalar( | |
| 90 | + | "SELECT COUNT(*) FROM items i \ | |
| 91 | + | JOIN projects p ON p.id = i.project_id \ | |
| 92 | + | JOIN users u ON u.id = p.user_id \ | |
| 93 | + | WHERE i.is_public = TRUE AND i.listed = TRUE AND p.is_public = TRUE \ | |
| 94 | + | AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL \ | |
| 95 | + | AND lower(split_part(u.email, '@', 2)) = 'example.test'", | |
| 96 | + | ) | |
| 97 | + | .fetch_one(pool) | |
| 98 | + | .await | |
| 99 | + | .expect("count discover-visible example items") | |
| 100 | + | } | |
| 101 | + | ||
| 68 | 102 | #[tokio::test] | |
| 69 | 103 | async fn seeds_publicly_visible_creators_and_projects() { | |
| 70 | 104 | let db = TestDb::new().await; | |
| @@ -106,15 +140,88 @@ async fn seeds_publicly_visible_creators_and_projects() { | |||
| 106 | 140 | } | |
| 107 | 141 | ||
| 108 | 142 | #[tokio::test] | |
| 143 | + | async fn seeds_items_across_types_and_pricing_but_hidden() { | |
| 144 | + | let db = TestDb::new().await; | |
| 145 | + | seed::run(&db.pool, &testnot_opts()).await.expect("seed"); | |
| 146 | + | ||
| 147 | + | // One item per ItemType (11), and every type represented exactly once. | |
| 148 | + | assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS); | |
| 149 | + | let distinct_types: i64 = sqlx::query_scalar( | |
| 150 | + | "SELECT COUNT(DISTINCT i.item_type) FROM items i \ | |
| 151 | + | JOIN projects p ON p.id = i.project_id \ | |
| 152 | + | JOIN users u ON u.id = p.user_id \ | |
| 153 | + | WHERE lower(split_part(u.email, '@', 2)) = 'example.test'", | |
| 154 | + | ) | |
| 155 | + | .fetch_one(&db.pool) | |
| 156 | + | .await | |
| 157 | + | .expect("distinct item types"); | |
| 158 | + | assert_eq!(distinct_types, SEEDED_ITEMS, "every ItemType should appear"); | |
| 159 | + | ||
| 160 | + | // Phase-2 boundary: nothing is discover-visible yet (all pending). | |
| 161 | + | assert_eq!( | |
| 162 | + | count_discover_visible_example_items(&db.pool).await, | |
| 163 | + | 0, | |
| 164 | + | "Phase 2 items must stay hidden until Phase 3 attaches media" | |
| 165 | + | ); | |
| 166 | + | ||
| 167 | + | // Pricing kinds are represented: Pwyw (pwyw_enabled), BuyOnce (price > 0), | |
| 168 | + | // and Free (price 0) items all exist. | |
| 169 | + | let pwyw: i64 = sqlx::query_scalar( | |
| 170 | + | "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \ | |
| 171 | + | JOIN users u ON u.id = p.user_id \ | |
| 172 | + | WHERE lower(split_part(u.email,'@',2))='example.test' AND i.pwyw_enabled = TRUE", | |
| 173 | + | ) | |
| 174 | + | .fetch_one(&db.pool) | |
| 175 | + | .await | |
| 176 | + | .expect("pwyw count"); | |
| 177 | + | assert!(pwyw >= 1, "expected Pwyw items"); | |
| 178 | + | let buyonce: i64 = sqlx::query_scalar( | |
| 179 | + | "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \ | |
| 180 | + | JOIN users u ON u.id = p.user_id \ | |
| 181 | + | WHERE lower(split_part(u.email,'@',2))='example.test' \ | |
| 182 | + | AND i.pwyw_enabled = FALSE AND i.price_cents > 0", | |
| 183 | + | ) | |
| 184 | + | .fetch_one(&db.pool) | |
| 185 | + | .await | |
| 186 | + | .expect("buyonce count"); | |
| 187 | + | assert!(buyonce >= 1, "expected BuyOnce items"); | |
| 188 | + | ||
| 189 | + | // The subscription project has its pricing_model set and >= 2 tiers. | |
| 190 | + | let sub_tiers: i64 = sqlx::query_scalar( | |
| 191 | + | "SELECT COUNT(*) FROM subscription_tiers st JOIN projects p ON p.id = st.project_id \ | |
| 192 | + | WHERE p.slug = 'the-marginalia-reader' AND p.pricing_model = 'subscription'", | |
| 193 | + | ) | |
| 194 | + | .fetch_one(&db.pool) | |
| 195 | + | .await | |
| 196 | + | .expect("subscription tiers"); | |
| 197 | + | assert!(sub_tiers >= 2, "subscription project should have >= 2 tiers, got {sub_tiers}"); | |
| 198 | + | ||
| 199 | + | // Every item is tagged (for the browse/filter surfaces) — no example item | |
| 200 | + | // lacks a tag, which would mean a roster slug failed to resolve. | |
| 201 | + | let untagged: i64 = sqlx::query_scalar( | |
| 202 | + | "SELECT COUNT(*) FROM items i \ | |
| 203 | + | JOIN projects p ON p.id = i.project_id JOIN users u ON u.id = p.user_id \ | |
| 204 | + | WHERE lower(split_part(u.email,'@',2))='example.test' \ | |
| 205 | + | AND NOT EXISTS (SELECT 1 FROM item_tags it WHERE it.item_id = i.id)", | |
| 206 | + | ) | |
| 207 | + | .fetch_one(&db.pool) | |
| 208 | + | .await | |
| 209 | + | .expect("untagged item count"); | |
| 210 | + | assert_eq!(untagged, 0, "every seeded item should have at least one tag"); | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | #[tokio::test] | |
| 109 | 214 | async fn seed_is_idempotent() { | |
| 110 | 215 | let db = TestDb::new().await; | |
| 111 | 216 | ||
| 112 | 217 | seed::run(&db.pool, &testnot_opts()).await.expect("first seed"); | |
| 113 | 218 | seed::run(&db.pool, &testnot_opts()).await.expect("second seed"); | |
| 114 | 219 | ||
| 115 | - | // Re-running wipes prior example data first, so counts stay fixed. | |
| 220 | + | // Re-running wipes prior example data first (cascading to projects + items), | |
| 221 | + | // so counts stay fixed instead of doubling. | |
| 116 | 222 | assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS); | |
| 117 | 223 | assert_eq!(count_public_example_projects(&db.pool).await, SEEDED_CREATORS); | |
| 224 | + | assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS); | |
| 118 | 225 | } | |
| 119 | 226 | ||
| 120 | 227 | #[tokio::test] |