Skip to main content

max / makenotwork

seed: Phase 2 — items across types, pricing, tags, tiers Fill each example project with items: one of every ItemType (11), spanning Free/BuyOnce/Pwyw at item level plus a Subscription project with tiers. Tags attached from the hierarchical taxonomy; the Text item carries a real body. Project pricing_model set per project. Items are seeded hidden: items.scan_status defaults to 'clean' (migration 146), so seed_items explicitly forces 'pending' — a file-less item must not surface in discover with nothing to download. Phase 3 attaches media and flips them back to 'clean'. The roster in seed/creators.rs is the single source of truth (item/pricing/ tier/tag specs); seed_projects now returns SeededProject so seed_items has the project id + owner for ownership-checked writes. DB test extended to 4 tests: type coverage, pricing kinds, hidden-until-Phase-3, tiers, tags, idempotency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-11 20:51 UTC
Commit: 81d2afc01def4db3ff2fe9d4b01217af9381b69b
Parent: 6aaa61e
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]