Skip to main content

max / makenotwork

15.2 KB · 418 lines History Blame Raw
1 //! DB-layer contract tests for the `--seed-examples` flow (`seed::run`, Phases 1-2).
2 //!
3 //! These exercise the seed against real Postgres to prove the invariants the
4 //! example marketplace rests on:
5 //!
6 //! 1. It clears the no-real-users guard on a *freshly-migrated* DB (migration
7 //! `080_remove_demo_data` leaves no non-example accounts) and creates the
8 //! full roster.
9 //! 2. Every seeded creator+project is publicly visible, `is_sandbox = FALSE`
10 //! on the user and `is_public = true` on the project, the only two gates the
11 //! discover feed applies. The discover predicate returning them is the proof.
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
16 //! counts stay fixed instead of doubling.
17 //! 5. The prod-host guard still refuses even with the opt-in flag set.
18
19 use std::sync::Arc;
20
21 use crate::harness::db::TestDb;
22 use crate::harness::storage::InMemoryStorage;
23 use makenotwork::seed::{self, SeedMedia, SeedOptions};
24 use makenotwork::storage::StorageBackend;
25
26 /// The Phase-1 roster size (see `seed::creators::ROSTER`).
27 const SEEDED_CREATORS: i64 = 5;
28 /// Phase-2 item count: one per `ItemType` spread across the projects.
29 const SEEDED_ITEMS: i64 = 11;
30
31 /// Guard-passing options for a test run: opt-in on, approved example host.
32 fn testnot_opts() -> SeedOptions {
33 SeedOptions {
34 allow_example_seed: true,
35 host_url: "https://testnot.work".to_string(),
36 }
37 }
38
39 async fn count_example_creators(pool: &sqlx::PgPool) -> i64 {
40 sqlx::query_scalar(
41 "SELECT COUNT(*) FROM users \
42 WHERE lower(split_part(email, '@', 2)) = 'example.test' AND is_sandbox = FALSE",
43 )
44 .fetch_one(pool)
45 .await
46 .expect("count example creators")
47 }
48
49 async fn count_public_example_projects(pool: &sqlx::PgPool) -> i64 {
50 sqlx::query_scalar(
51 "SELECT COUNT(*) FROM projects p \
52 JOIN users u ON u.id = p.user_id \
53 WHERE lower(split_part(u.email, '@', 2)) = 'example.test' AND p.is_public = TRUE",
54 )
55 .fetch_one(pool)
56 .await
57 .expect("count public example projects")
58 }
59
60 /// Slugs of seeded projects that clear the *exact* public-discover gate
61 /// (`p.is_public AND u.is_sandbox = FALSE`, per `db::discover::discover_projects`).
62 /// Mirrors that predicate in SQL because the `discover` module is `pub(crate)` and
63 /// so unreachable from this external test crate.
64 async fn discover_visible_example_slugs(pool: &sqlx::PgPool) -> Vec<String> {
65 sqlx::query_scalar::<_, String>(
66 "SELECT p.slug::text FROM projects p \
67 JOIN users u ON u.id = p.user_id \
68 WHERE p.is_public = TRUE AND u.is_sandbox = FALSE \
69 AND lower(split_part(u.email, '@', 2)) = 'example.test' \
70 ORDER BY p.slug",
71 )
72 .fetch_all(pool)
73 .await
74 .expect("discover-visible example slugs")
75 }
76
77 /// Total items under the seeded example projects.
78 async fn count_example_items(pool: &sqlx::PgPool) -> i64 {
79 sqlx::query_scalar(
80 "SELECT COUNT(*) FROM items i \
81 JOIN projects p ON p.id = i.project_id \
82 JOIN users u ON u.id = p.user_id \
83 WHERE lower(split_part(u.email, '@', 2)) = 'example.test'",
84 )
85 .fetch_one(pool)
86 .await
87 .expect("count example items")
88 }
89
90 /// Example items that clear the *exact* item-discover gate. Phase 2 seeds items
91 /// hidden (`scan_status='pending'`), so this must be zero until Phase 3.
92 async fn count_discover_visible_example_items(pool: &sqlx::PgPool) -> i64 {
93 sqlx::query_scalar(
94 "SELECT COUNT(*) FROM items i \
95 JOIN projects p ON p.id = i.project_id \
96 JOIN users u ON u.id = p.user_id \
97 WHERE i.is_public = TRUE AND i.listed = TRUE AND p.is_public = TRUE \
98 AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL \
99 AND lower(split_part(u.email, '@', 2)) = 'example.test'",
100 )
101 .fetch_one(pool)
102 .await
103 .expect("count discover-visible example items")
104 }
105
106 #[tokio::test]
107 async fn seeds_publicly_visible_creators_and_projects() {
108 let db = TestDb::new().await;
109
110 // Precondition the guard relies on: a freshly-migrated DB holds no real
111 // accounts (080_remove_demo_data cleared the 003 demo seed).
112 let real: i64 = sqlx::query_scalar(
113 "SELECT COUNT(*) FROM users WHERE lower(email) NOT LIKE '%@example.test'",
114 )
115 .fetch_one(&db.pool)
116 .await
117 .expect("count real users");
118 assert_eq!(real, 0, "a migrated DB should hold no non-example accounts");
119
120 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
121 .await
122 .expect("seed should run on a clean migrated DB");
123
124 // All five creators exist and are non-sandbox (publicly visible), and each
125 // owns a public project.
126 assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
127 assert_eq!(
128 count_public_example_projects(&db.pool).await,
129 SEEDED_CREATORS
130 );
131
132 // Public visibility proof: all five projects clear the discover gate.
133 let slugs = discover_visible_example_slugs(&db.pool).await;
134 assert_eq!(slugs.len() as i64, SEEDED_CREATORS);
135 for slug in [
136 "restored-reels-vol-1",
137 "deskriver-suite",
138 "cc0-field-library",
139 "the-marginalia-reader",
140 "commons-sampler",
141 ] {
142 assert!(
143 slugs.iter().any(|s| s == slug),
144 "discover-visible set missing seeded project {slug}"
145 );
146 }
147 }
148
149 #[tokio::test]
150 async fn seeds_items_across_types_and_pricing_but_hidden() {
151 let db = TestDb::new().await;
152 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
153 .await
154 .expect("seed");
155
156 // One item per ItemType (11), and every type represented exactly once.
157 assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS);
158 let distinct_types: i64 = sqlx::query_scalar(
159 "SELECT COUNT(DISTINCT i.item_type) FROM items i \
160 JOIN projects p ON p.id = i.project_id \
161 JOIN users u ON u.id = p.user_id \
162 WHERE lower(split_part(u.email, '@', 2)) = 'example.test'",
163 )
164 .fetch_one(&db.pool)
165 .await
166 .expect("distinct item types");
167 assert_eq!(distinct_types, SEEDED_ITEMS, "every ItemType should appear");
168
169 // Phase-2 boundary: nothing is discover-visible yet (all pending).
170 assert_eq!(
171 count_discover_visible_example_items(&db.pool).await,
172 0,
173 "Phase 2 items must stay hidden until Phase 3 attaches media"
174 );
175
176 // Pricing kinds are represented: Pwyw (pwyw_enabled), BuyOnce (price > 0),
177 // and Free (price 0) items all exist.
178 let pwyw: 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' AND i.pwyw_enabled = TRUE",
182 )
183 .fetch_one(&db.pool)
184 .await
185 .expect("pwyw count");
186 assert!(pwyw >= 1, "expected Pwyw items");
187 let buyonce: i64 = sqlx::query_scalar(
188 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
189 JOIN users u ON u.id = p.user_id \
190 WHERE lower(split_part(u.email,'@',2))='example.test' \
191 AND i.pwyw_enabled = FALSE AND i.price_cents > 0",
192 )
193 .fetch_one(&db.pool)
194 .await
195 .expect("buyonce count");
196 assert!(buyonce >= 1, "expected BuyOnce items");
197
198 // The subscription project has its pricing_model set and >= 2 tiers.
199 let sub_tiers: i64 = sqlx::query_scalar(
200 "SELECT COUNT(*) FROM subscription_tiers st JOIN projects p ON p.id = st.project_id \
201 WHERE p.slug = 'the-marginalia-reader' AND p.pricing_model = 'subscription'",
202 )
203 .fetch_one(&db.pool)
204 .await
205 .expect("subscription tiers");
206 assert!(
207 sub_tiers >= 2,
208 "subscription project should have >= 2 tiers, got {sub_tiers}"
209 );
210
211 // Every item is tagged (for the browse/filter surfaces), no example item
212 // lacks a tag, which would mean a roster slug failed to resolve.
213 let untagged: i64 = sqlx::query_scalar(
214 "SELECT COUNT(*) FROM items i \
215 JOIN projects p ON p.id = i.project_id JOIN users u ON u.id = p.user_id \
216 WHERE lower(split_part(u.email,'@',2))='example.test' \
217 AND NOT EXISTS (SELECT 1 FROM item_tags it WHERE it.item_id = i.id)",
218 )
219 .fetch_one(&db.pool)
220 .await
221 .expect("untagged item count");
222 assert_eq!(
223 untagged, 0,
224 "every seeded item should have at least one tag"
225 );
226 }
227
228 #[tokio::test]
229 async fn seed_is_idempotent() {
230 let db = TestDb::new().await;
231
232 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
233 .await
234 .expect("first seed");
235 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
236 .await
237 .expect("second seed");
238
239 // Re-running wipes prior example data first (cascading to projects + items),
240 // so counts stay fixed instead of doubling.
241 assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
242 assert_eq!(
243 count_public_example_projects(&db.pool).await,
244 SEEDED_CREATORS
245 );
246 assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS);
247 }
248
249 #[tokio::test]
250 async fn refuses_on_prod_host() {
251 let db = TestDb::new().await;
252
253 let err = seed::run(
254 &db.pool,
255 &SeedOptions {
256 allow_example_seed: true,
257 host_url: "https://makenot.work".to_string(),
258 },
259 &SeedMedia::none(),
260 )
261 .await
262 .expect_err("prod host must be refused");
263 assert!(matches!(err, seed::SeedError::ProdHost(_)));
264
265 // Nothing was created, and the demo account is untouched.
266 assert_eq!(count_example_creators(&db.pool).await, 0);
267 }
268
269 #[tokio::test]
270 async fn seeds_blog_posts_and_follows() {
271 let db = TestDb::new().await;
272 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
273 .await
274 .expect("seed");
275
276 // Two published posts per project (10), all published.
277 let posts: i64 = sqlx::query_scalar(
278 "SELECT COUNT(*) FROM blog_posts bp JOIN projects p ON p.id = bp.project_id \
279 JOIN users u ON u.id = p.user_id \
280 WHERE lower(split_part(u.email,'@',2))='example.test' AND bp.published_at IS NOT NULL",
281 )
282 .fetch_one(&db.pool)
283 .await
284 .expect("published posts");
285 assert_eq!(posts, 10, "expected 2 published blog posts per project");
286
287 // Follow graph: 20 user-follows + 20 project-follows among example accounts.
288 let user_follows: i64 = sqlx::query_scalar(
289 "SELECT COUNT(*) FROM follows f JOIN users fu ON fu.id = f.follower_id \
290 WHERE f.target_type = 'user' AND lower(split_part(fu.email,'@',2))='example.test'",
291 )
292 .fetch_one(&db.pool)
293 .await
294 .expect("user follows");
295 assert_eq!(user_follows, 20);
296 let project_follows: i64 = sqlx::query_scalar(
297 "SELECT COUNT(*) FROM follows f JOIN users fu ON fu.id = f.follower_id \
298 WHERE f.target_type = 'project' AND lower(split_part(fu.email,'@',2))='example.test'",
299 )
300 .fetch_one(&db.pool)
301 .await
302 .expect("project follows");
303 assert_eq!(project_follows, 20);
304
305 // Spot-check a rendered count: each creator has 4 followers.
306 let followers_of_openreels: i64 = sqlx::query_scalar(
307 "SELECT COUNT(*) FROM follows f \
308 WHERE f.target_type = 'user' \
309 AND f.target_id = (SELECT id FROM users WHERE username = 'openreels')",
310 )
311 .fetch_one(&db.pool)
312 .await
313 .expect("openreels followers");
314 assert_eq!(followers_of_openreels, 4);
315
316 // Idempotent: re-run keeps 10 posts + 40 follows, not doubled.
317 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
318 .await
319 .expect("re-seed");
320 let posts2: i64 = sqlx::query_scalar(
321 "SELECT COUNT(*) FROM blog_posts bp JOIN projects p ON p.id = bp.project_id \
322 JOIN users u ON u.id = p.user_id \
323 WHERE lower(split_part(u.email,'@',2))='example.test'",
324 )
325 .fetch_one(&db.pool)
326 .await
327 .expect("posts after re-run");
328 assert_eq!(posts2, 10, "re-run should not duplicate blog posts");
329 let follows2: i64 = sqlx::query_scalar(
330 "SELECT COUNT(*) FROM follows f JOIN users fu ON fu.id = f.follower_id \
331 WHERE lower(split_part(fu.email,'@',2))='example.test'",
332 )
333 .fetch_one(&db.pool)
334 .await
335 .expect("follows after re-run");
336 assert_eq!(follows2, 40, "re-run should not duplicate follows");
337 }
338
339 /// Guard-passing options bundled with an in-memory storage backend, so the media
340 /// phase runs and flips items visible.
341 fn media_ctx() -> SeedMedia {
342 let s3: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new());
343 let public: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new());
344 SeedMedia {
345 s3: Some(s3),
346 public_s3: Some(public),
347 cdn_base_url: Some("https://cdn.example.test".to_string()),
348 }
349 }
350
351 #[tokio::test]
352 async fn seeds_media_makes_items_visible() {
353 let db = TestDb::new().await;
354 seed::run(&db.pool, &testnot_opts(), &media_ctx())
355 .await
356 .expect("seed with media");
357
358 // With media attached, every item is promoted 'clean' and now clears the
359 // full item-discover gate (contrast the no-storage `..._but_hidden` test).
360 assert_eq!(
361 count_discover_visible_example_items(&db.pool).await,
362 SEEDED_ITEMS,
363 "all items should be visible once media is attached"
364 );
365
366 // The audio item has its audio key set.
367 let audio_keyed: i64 = sqlx::query_scalar(
368 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
369 JOIN users u ON u.id = p.user_id \
370 WHERE lower(split_part(u.email,'@',2))='example.test' \
371 AND i.item_type = 'audio' AND i.audio_s3_key IS NOT NULL",
372 )
373 .fetch_one(&db.pool)
374 .await
375 .expect("audio keyed");
376 assert!(audio_keyed >= 1, "audio item should have an audio_s3_key");
377
378 // Download-type items produced clean, keyed versions.
379 let clean_versions: i64 = sqlx::query_scalar(
380 "SELECT COUNT(*) FROM versions v JOIN items i ON i.id = v.item_id \
381 JOIN projects p ON p.id = i.project_id JOIN users u ON u.id = p.user_id \
382 WHERE lower(split_part(u.email,'@',2))='example.test' \
383 AND v.s3_key IS NOT NULL AND v.scan_status = 'clean'",
384 )
385 .fetch_one(&db.pool)
386 .await
387 .expect("clean versions");
388 assert!(
389 clean_versions >= 1,
390 "expected a clean, keyed download version"
391 );
392
393 // Covers were attached (public bucket + CDN base present).
394 let covered: i64 = sqlx::query_scalar(
395 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
396 JOIN users u ON u.id = p.user_id \
397 WHERE lower(split_part(u.email,'@',2))='example.test' \
398 AND i.cover_s3_key IS NOT NULL AND i.cover_scan_status = 'clean'",
399 )
400 .fetch_one(&db.pool)
401 .await
402 .expect("covered items");
403 assert!(
404 covered >= 1,
405 "expected item covers with cover_scan_status clean"
406 );
407
408 // Idempotent with media too: re-running stays at the roster size.
409 seed::run(&db.pool, &testnot_opts(), &media_ctx())
410 .await
411 .expect("re-seed with media");
412 assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS);
413 assert_eq!(
414 count_discover_visible_example_items(&db.pool).await,
415 SEEDED_ITEMS
416 );
417 }
418