Skip to main content

max / makenotwork

24.0 KB · 644 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 pub(super) const SEEDED_CREATORS: i64 = 5;
28 /// Phase-2 item count: five per project, spanning every `ItemType`. Widened
29 /// from eleven on 2026-08-07 so the storefront grids read as grids.
30 const SEEDED_ITEMS: i64 = 25;
31
32 /// Every `ItemType` must still appear at least once across the catalog. Held
33 /// separately from the item count now that projects carry several of a type.
34 const ITEM_TYPES: i64 = 11;
35
36 /// Guard-passing options for a test run: opt-in on, approved example host.
37 pub(super) fn testnot_opts() -> SeedOptions {
38 SeedOptions {
39 allow_example_seed: true,
40 host_url: "https://testnot.work".to_string(),
41 harness: None,
42 buyer: None,
43 }
44 }
45
46 pub(super) async fn count_example_creators(pool: &sqlx::PgPool) -> i64 {
47 sqlx::query_scalar(
48 "SELECT COUNT(*) FROM users \
49 WHERE lower(split_part(email, '@', 2)) = 'example.test' AND is_sandbox = FALSE",
50 )
51 .fetch_one(pool)
52 .await
53 .expect("count example creators")
54 }
55
56 async fn count_public_example_projects(pool: &sqlx::PgPool) -> i64 {
57 sqlx::query_scalar(
58 "SELECT COUNT(*) FROM projects p \
59 JOIN users u ON u.id = p.user_id \
60 WHERE lower(split_part(u.email, '@', 2)) = 'example.test' AND p.is_public = TRUE",
61 )
62 .fetch_one(pool)
63 .await
64 .expect("count public example projects")
65 }
66
67 /// Slugs of seeded projects that clear the *exact* public-discover gate
68 /// (`p.is_public AND u.is_sandbox = FALSE`, per `db::discover::discover_projects`).
69 /// Mirrors that predicate in SQL because the `discover` module is `pub(crate)` and
70 /// so unreachable from this external test crate.
71 pub(super) async fn discover_visible_example_slugs(pool: &sqlx::PgPool) -> Vec<String> {
72 sqlx::query_scalar::<_, String>(
73 "SELECT p.slug::text FROM projects p \
74 JOIN users u ON u.id = p.user_id \
75 WHERE p.is_public = TRUE AND u.is_sandbox = FALSE \
76 AND lower(split_part(u.email, '@', 2)) = 'example.test' \
77 ORDER BY p.slug",
78 )
79 .fetch_all(pool)
80 .await
81 .expect("discover-visible example slugs")
82 }
83
84 /// Total items under the seeded example projects.
85 pub(super) async fn count_example_items(pool: &sqlx::PgPool) -> i64 {
86 sqlx::query_scalar(
87 "SELECT COUNT(*) FROM items i \
88 JOIN projects p ON p.id = i.project_id \
89 JOIN users u ON u.id = p.user_id \
90 WHERE lower(split_part(u.email, '@', 2)) = 'example.test'",
91 )
92 .fetch_one(pool)
93 .await
94 .expect("count example items")
95 }
96
97 /// Example items that clear the *exact* item-discover gate. Phase 2 seeds items
98 /// hidden (`scan_status='pending'`), so this must be zero until Phase 3.
99 async fn count_discover_visible_example_items(pool: &sqlx::PgPool) -> i64 {
100 sqlx::query_scalar(
101 "SELECT COUNT(*) FROM items i \
102 JOIN projects p ON p.id = i.project_id \
103 JOIN users u ON u.id = p.user_id \
104 WHERE i.is_public = TRUE AND i.listed = TRUE AND p.is_public = TRUE \
105 AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL \
106 AND lower(split_part(u.email, '@', 2)) = 'example.test'",
107 )
108 .fetch_one(pool)
109 .await
110 .expect("count discover-visible example items")
111 }
112
113 #[tokio::test]
114 async fn seeds_publicly_visible_creators_and_projects() {
115 let db = TestDb::new().await;
116
117 // Precondition the guard relies on: a freshly-migrated DB holds no real
118 // accounts (080_remove_demo_data cleared the 003 demo seed).
119 let real: i64 = sqlx::query_scalar(
120 "SELECT COUNT(*) FROM users WHERE lower(email) NOT LIKE '%@example.test'",
121 )
122 .fetch_one(&db.pool)
123 .await
124 .expect("count real users");
125 assert_eq!(real, 0, "a migrated DB should hold no non-example accounts");
126
127 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
128 .await
129 .expect("seed should run on a clean migrated DB");
130
131 // All five creators exist and are non-sandbox (publicly visible), and each
132 // owns a public project.
133 assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
134 assert_eq!(
135 count_public_example_projects(&db.pool).await,
136 SEEDED_CREATORS
137 );
138
139 // Public visibility proof: all five projects clear the discover gate.
140 let slugs = discover_visible_example_slugs(&db.pool).await;
141 assert_eq!(slugs.len() as i64, SEEDED_CREATORS);
142 for slug in [
143 "restored-reels-vol-1",
144 "deskriver-suite",
145 "cc0-field-library",
146 "the-marginalia-reader",
147 "commons-sampler",
148 ] {
149 assert!(
150 slugs.iter().any(|s| s == slug),
151 "discover-visible set missing seeded project {slug}"
152 );
153 }
154 }
155
156 #[tokio::test]
157 async fn seeds_items_across_types_and_pricing_but_hidden() {
158 let db = TestDb::new().await;
159 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
160 .await
161 .expect("seed");
162
163 // Five items per project, and every ItemType still represented.
164 assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS);
165 let distinct_types: i64 = sqlx::query_scalar(
166 "SELECT COUNT(DISTINCT i.item_type) FROM items i \
167 JOIN projects p ON p.id = i.project_id \
168 JOIN users u ON u.id = p.user_id \
169 WHERE lower(split_part(u.email, '@', 2)) = 'example.test'",
170 )
171 .fetch_one(&db.pool)
172 .await
173 .expect("distinct item types");
174 assert_eq!(distinct_types, ITEM_TYPES, "every ItemType should appear");
175
176 // Phase-2 boundary: nothing is discover-visible yet (all pending).
177 assert_eq!(
178 count_discover_visible_example_items(&db.pool).await,
179 0,
180 "Phase 2 items must stay hidden until Phase 3 attaches media"
181 );
182
183 // Pricing kinds are represented: Pwyw (pwyw_enabled), BuyOnce (price > 0),
184 // and Free (price 0) items all exist.
185 let pwyw: i64 = sqlx::query_scalar(
186 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
187 JOIN users u ON u.id = p.user_id \
188 WHERE lower(split_part(u.email,'@',2))='example.test' AND i.pwyw_enabled = TRUE",
189 )
190 .fetch_one(&db.pool)
191 .await
192 .expect("pwyw count");
193 assert!(pwyw >= 1, "expected Pwyw items");
194 let buyonce: i64 = sqlx::query_scalar(
195 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
196 JOIN users u ON u.id = p.user_id \
197 WHERE lower(split_part(u.email,'@',2))='example.test' \
198 AND i.pwyw_enabled = FALSE AND i.price_cents > 0",
199 )
200 .fetch_one(&db.pool)
201 .await
202 .expect("buyonce count");
203 assert!(buyonce >= 1, "expected BuyOnce items");
204
205 // The subscription project has its pricing_model set and >= 2 tiers.
206 let sub_tiers: i64 = sqlx::query_scalar(
207 "SELECT COUNT(*) FROM subscription_tiers st JOIN projects p ON p.id = st.project_id \
208 WHERE p.slug = 'the-marginalia-reader' AND p.pricing_model = 'subscription'",
209 )
210 .fetch_one(&db.pool)
211 .await
212 .expect("subscription tiers");
213 assert!(
214 sub_tiers >= 2,
215 "subscription project should have >= 2 tiers, got {sub_tiers}"
216 );
217
218 // Every item is tagged (for the browse/filter surfaces), no example item
219 // lacks a tag, which would mean a roster slug failed to resolve.
220 let untagged: i64 = sqlx::query_scalar(
221 "SELECT COUNT(*) FROM items i \
222 JOIN projects p ON p.id = i.project_id JOIN users u ON u.id = p.user_id \
223 WHERE lower(split_part(u.email,'@',2))='example.test' \
224 AND NOT EXISTS (SELECT 1 FROM item_tags it WHERE it.item_id = i.id)",
225 )
226 .fetch_one(&db.pool)
227 .await
228 .expect("untagged item count");
229 assert_eq!(
230 untagged, 0,
231 "every seeded item should have at least one tag"
232 );
233 }
234
235 /// Only the subscription project may be non-free at the project level.
236 ///
237 /// A paid project renders `ProjectPaywallTemplate` to anyone without access, and
238 /// that template lists no items at all. Every testnot visitor is anonymous and
239 /// therefore permanently without access, so a paid project is a storefront nobody
240 /// can see inside. Spreading the four `PricingKind`s across projects hid 9 of the
241 /// 11 seeded items until 2026-08-05; this is the guard against doing it again.
242 /// Paid *items* are fine and are how BuyOnce and Pwyw get demonstrated.
243 #[tokio::test]
244 async fn only_the_subscription_project_is_paywalled() {
245 let db = TestDb::new().await;
246
247 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
248 .await
249 .expect("seed should succeed");
250
251 let paywalled: Vec<String> = sqlx::query_scalar(
252 "SELECT p.slug FROM projects p JOIN users u ON u.id = p.user_id \
253 WHERE lower(split_part(u.email,'@',2))='example.test' \
254 AND p.pricing_model IS NOT NULL AND p.pricing_model <> 'free' \
255 ORDER BY p.slug",
256 )
257 .fetch_all(&db.pool)
258 .await
259 .expect("paywalled project slugs");
260
261 assert_eq!(
262 paywalled,
263 vec!["the-marginalia-reader".to_string()],
264 "only the subscription project may be paywalled; the rest must be \
265 project-level free so their items are reachable anonymously"
266 );
267 }
268
269 #[tokio::test]
270 async fn seed_is_idempotent() {
271 let db = TestDb::new().await;
272
273 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
274 .await
275 .expect("first seed");
276 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
277 .await
278 .expect("second seed");
279
280 // Re-running wipes prior example data first (cascading to projects + items),
281 // so counts stay fixed instead of doubling.
282 assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
283 assert_eq!(
284 count_public_example_projects(&db.pool).await,
285 SEEDED_CREATORS
286 );
287 assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS);
288 }
289
290 #[tokio::test]
291 async fn refuses_on_prod_host() {
292 let db = TestDb::new().await;
293
294 let err = seed::run(
295 &db.pool,
296 &SeedOptions {
297 allow_example_seed: true,
298 host_url: "https://makenot.work".to_string(),
299 harness: None,
300 buyer: None,
301 },
302 &SeedMedia::none(),
303 )
304 .await
305 .expect_err("prod host must be refused");
306 assert!(matches!(err, seed::SeedError::ProdHost(_)));
307
308 // Nothing was created, and the demo account is untouched.
309 assert_eq!(count_example_creators(&db.pool).await, 0);
310 }
311
312 #[tokio::test]
313 async fn seeds_blog_posts_and_follows() {
314 let db = TestDb::new().await;
315 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
316 .await
317 .expect("seed");
318
319 // Two published posts per project (10), all published.
320 let posts: 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' AND bp.published_at IS NOT NULL",
324 )
325 .fetch_one(&db.pool)
326 .await
327 .expect("published posts");
328 assert_eq!(posts, 10, "expected 2 published blog posts per project");
329
330 // Follow graph: 20 user-follows + 20 project-follows among example accounts.
331 let user_follows: i64 = sqlx::query_scalar(
332 "SELECT COUNT(*) FROM follows f JOIN users fu ON fu.id = f.follower_id \
333 WHERE f.target_type = 'user' AND lower(split_part(fu.email,'@',2))='example.test'",
334 )
335 .fetch_one(&db.pool)
336 .await
337 .expect("user follows");
338 assert_eq!(user_follows, 20);
339 let project_follows: i64 = sqlx::query_scalar(
340 "SELECT COUNT(*) FROM follows f JOIN users fu ON fu.id = f.follower_id \
341 WHERE f.target_type = 'project' AND lower(split_part(fu.email,'@',2))='example.test'",
342 )
343 .fetch_one(&db.pool)
344 .await
345 .expect("project follows");
346 assert_eq!(project_follows, 20);
347
348 // Spot-check a rendered count: each creator has 4 followers.
349 let followers_of_openreels: i64 = sqlx::query_scalar(
350 "SELECT COUNT(*) FROM follows f \
351 WHERE f.target_type = 'user' \
352 AND f.target_id = (SELECT id FROM users WHERE username = 'openreels')",
353 )
354 .fetch_one(&db.pool)
355 .await
356 .expect("openreels followers");
357 assert_eq!(followers_of_openreels, 4);
358
359 // Idempotent: re-run keeps 10 posts + 40 follows, not doubled.
360 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
361 .await
362 .expect("re-seed");
363 let posts2: i64 = sqlx::query_scalar(
364 "SELECT COUNT(*) FROM blog_posts bp JOIN projects p ON p.id = bp.project_id \
365 JOIN users u ON u.id = p.user_id \
366 WHERE lower(split_part(u.email,'@',2))='example.test'",
367 )
368 .fetch_one(&db.pool)
369 .await
370 .expect("posts after re-run");
371 assert_eq!(posts2, 10, "re-run should not duplicate blog posts");
372 let follows2: i64 = sqlx::query_scalar(
373 "SELECT COUNT(*) FROM follows f JOIN users fu ON fu.id = f.follower_id \
374 WHERE lower(split_part(fu.email,'@',2))='example.test'",
375 )
376 .fetch_one(&db.pool)
377 .await
378 .expect("follows after re-run");
379 assert_eq!(follows2, 40, "re-run should not duplicate follows");
380 }
381
382 /// Guard-passing options bundled with an in-memory storage backend, so the media
383 /// phase runs and flips items visible.
384 pub(super) fn media_ctx() -> SeedMedia {
385 let s3: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new());
386 let public: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new());
387 SeedMedia {
388 s3: Some(s3),
389 public_s3: Some(public),
390 cdn_base_url: Some("https://cdn.example.test".to_string()),
391 // No curated assets: every slot takes its generated placeholder, and the
392 // test never reaches the network. Resolving the real manifest is a
393 // separate, network-touching concern (`seed::manifest`).
394 assets: makenotwork::seed::manifest::ResolvedAssets::default(),
395 }
396 }
397
398 #[tokio::test]
399 async fn seeds_media_makes_items_visible() {
400 let db = TestDb::new().await;
401 seed::run(&db.pool, &testnot_opts(), &media_ctx())
402 .await
403 .expect("seed with media");
404
405 // With media attached, every item is promoted 'clean' and now clears the
406 // full item-discover gate (contrast the no-storage `..._but_hidden` test).
407 assert_eq!(
408 count_discover_visible_example_items(&db.pool).await,
409 SEEDED_ITEMS,
410 "all items should be visible once media is attached"
411 );
412
413 // The audio item has its audio key set.
414 let audio_keyed: i64 = sqlx::query_scalar(
415 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
416 JOIN users u ON u.id = p.user_id \
417 WHERE lower(split_part(u.email,'@',2))='example.test' \
418 AND i.item_type = 'audio' AND i.audio_s3_key IS NOT NULL",
419 )
420 .fetch_one(&db.pool)
421 .await
422 .expect("audio keyed");
423 assert!(audio_keyed >= 1, "audio item should have an audio_s3_key");
424
425 // Download-type items produced clean, keyed versions.
426 let clean_versions: i64 = sqlx::query_scalar(
427 "SELECT COUNT(*) FROM versions v JOIN items i ON i.id = v.item_id \
428 JOIN projects p ON p.id = i.project_id JOIN users u ON u.id = p.user_id \
429 WHERE lower(split_part(u.email,'@',2))='example.test' \
430 AND v.s3_key IS NOT NULL AND v.scan_status = 'clean'",
431 )
432 .fetch_one(&db.pool)
433 .await
434 .expect("clean versions");
435 assert!(
436 clean_versions >= 1,
437 "expected a clean, keyed download version"
438 );
439
440 // Covers were attached (public bucket + CDN base present).
441 let covered: i64 = sqlx::query_scalar(
442 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
443 JOIN users u ON u.id = p.user_id \
444 WHERE lower(split_part(u.email,'@',2))='example.test' \
445 AND i.cover_s3_key IS NOT NULL AND i.cover_scan_status = 'clean'",
446 )
447 .fetch_one(&db.pool)
448 .await
449 .expect("covered items");
450 assert!(
451 covered >= 1,
452 "expected item covers with cover_scan_status clean"
453 );
454
455 // Idempotent with media too: re-running stays at the roster size.
456 seed::run(&db.pool, &testnot_opts(), &media_ctx())
457 .await
458 .expect("re-seed with media");
459 assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS);
460 assert_eq!(
461 count_discover_visible_example_items(&db.pool).await,
462 SEEDED_ITEMS
463 );
464 }
465
466 // ── Harness phase ──
467 //
468 // The accounts and OAuth client the mt browser axis logs in with. Their whole
469 // value is that a testnot reseed reproduces them exactly: an audit run that
470 // cannot log in stops at reads, which is the coverage gap the phase exists to
471 // close. These tests pin the three things a login actually depends on, the
472 // password verifying, the perks MNW will report, and the redirect URI being
473 // registered, plus survival across a reseed.
474
475 use makenotwork::seed::harness::{
476 self, CREATOR_ACCOUNT_ID, FAN_ACCOUNT_ID, HarnessOptions, OWNER_ACCOUNT_ID,
477 };
478
479 const HARNESS_REDIRECT_URI: &str = "http://mt.example.test:3400/auth/callback";
480
481 fn harness_opts() -> HarnessOptions {
482 HarnessOptions {
483 password: "harness-test-password".to_string(),
484 redirect_uri: HARNESS_REDIRECT_URI.to_string(),
485 }
486 }
487
488 /// Options that run the catalog *and* the harness phase, as a testnot box with
489 /// both env vars set does.
490 fn testnot_opts_with_harness() -> SeedOptions {
491 SeedOptions {
492 harness: Some(harness_opts()),
493 ..testnot_opts()
494 }
495 }
496
497 pub(super) async fn password_hash_of(pool: &sqlx::PgPool, id: uuid::Uuid) -> String {
498 sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
499 .bind(id)
500 .fetch_one(pool)
501 .await
502 .expect("harness account should exist")
503 }
504
505 #[tokio::test]
506 async fn harness_accounts_can_log_in_and_carry_their_perks() {
507 let db = TestDb::new().await;
508 seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none())
509 .await
510 .expect("seed with harness phase");
511
512 // The password verifies: this is the login the browser run performs.
513 for id in [FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID] {
514 let hash = password_hash_of(&db.pool, id).await;
515 assert!(
516 makenotwork::auth::verify_password_async(harness_opts().password, hash)
517 .await
518 .expect("verify"),
519 "seeded password should verify for {id}"
520 );
521 }
522
523 // Perks, as `/oauth/userinfo` computes them: fan_plus from an active
524 // subscription row, is_creator from a non-null creator_tier. The pair is
525 // what mt's `UserPerks::effective_plus` gate reads, and the two accounts
526 // exercise its two halves separately.
527 let fan_plus_active: bool = sqlx::query_scalar(
528 "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions \
529 WHERE user_id = $1 AND status = 'active')",
530 )
531 .bind(FAN_ACCOUNT_ID)
532 .fetch_one(&db.pool)
533 .await
534 .expect("fan plus lookup");
535 assert!(fan_plus_active, "fan account should hold active Fan+");
536
537 let tiers: Vec<Option<String>> =
538 sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = ANY($1) ORDER BY username")
539 .bind(vec![FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID])
540 .fetch_all(&db.pool)
541 .await
542 .expect("creator tiers");
543 // Ordered by username: harness_creator, harness_fan, harness_owner.
544 assert_eq!(
545 tiers,
546 vec![Some("everything".to_string()), None, None],
547 "only the creator account should report is_creator"
548 );
549
550 // Creator powers follow the tier. Three accounts that all hold them would be
551 // one role tested three times.
552 let can_create: Vec<bool> = sqlx::query_scalar(
553 "SELECT can_create_projects FROM users WHERE id = ANY($1) ORDER BY username",
554 )
555 .bind(vec![FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID])
556 .fetch_all(&db.pool)
557 .await
558 .expect("project rights");
559 assert_eq!(can_create, vec![true, false, false]);
560
561 // Not locked out, not suspended: a prior run's failed logins must not
562 // survive the reset.
563 let clean: i64 = sqlx::query_scalar(
564 "SELECT COUNT(*) FROM users WHERE id = ANY($1) \
565 AND failed_login_attempts = 0 AND locked_until IS NULL \
566 AND suspended_at IS NULL AND deactivated_at IS NULL",
567 )
568 .bind(vec![FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID])
569 .fetch_one(&db.pool)
570 .await
571 .expect("account state");
572 assert_eq!(clean, 3);
573 }
574
575 #[tokio::test]
576 async fn harness_client_registers_its_redirect_uri() {
577 let db = TestDb::new().await;
578 seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none())
579 .await
580 .expect("seed with harness phase");
581
582 // `validate_redirect_uri` waves through localhost only; the harness instance
583 // is reached over the tailnet, so the URI has to be registered or the
584 // authorize call fails with "redirect_uri is not allowed".
585 let row: (uuid::Uuid, Vec<String>, bool) = sqlx::query_as(
586 "SELECT creator_id, redirect_uris, is_active FROM sync_apps WHERE api_key_hash = $1",
587 )
588 .bind(makenotwork::db::synckit::hash_api_key(harness::CLIENT_ID))
589 .fetch_one(&db.pool)
590 .await
591 .expect("harness OAuth client should be registered");
592
593 assert_eq!(row.0, CREATOR_ACCOUNT_ID, "client is owned by the creator");
594 assert_eq!(row.1, vec![HARNESS_REDIRECT_URI.to_string()]);
595 assert!(row.2, "client must be active");
596 }
597
598 #[tokio::test]
599 async fn harness_survives_a_reseed_with_stable_ids() {
600 let db = TestDb::new().await;
601 seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none())
602 .await
603 .expect("first seed");
604
605 // A reseed wipes every @example.test account first, harness accounts
606 // included, then rebuilds them. Stable ids are the property mt's seed
607 // depends on: it pre-assigns community roles by mnw_account_id, so an id
608 // that moved would leave the harness a plain member with no way to moderate.
609 seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none())
610 .await
611 .expect("reseed");
612
613 let ids: Vec<uuid::Uuid> = sqlx::query_scalar(
614 "SELECT id FROM users WHERE username LIKE 'harness\\_%' ORDER BY username",
615 )
616 .fetch_all(&db.pool)
617 .await
618 .expect("harness ids");
619 assert_eq!(
620 ids,
621 vec![CREATOR_ACCOUNT_ID, FAN_ACCOUNT_ID, OWNER_ACCOUNT_ID],
622 "harness ids must be identical after a reseed"
623 );
624
625 // And the catalog is unchanged by the phase running twice.
626 assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS + 3);
627 }
628
629 #[tokio::test]
630 async fn without_harness_options_the_phase_does_not_run() {
631 let db = TestDb::new().await;
632 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
633 .await
634 .expect("seed without harness");
635
636 let harness_accounts: i64 =
637 sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username LIKE 'harness\\_%'")
638 .fetch_one(&db.pool)
639 .await
640 .expect("count");
641 assert_eq!(harness_accounts, 0, "harness phase must be opt-in");
642 assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
643 }
644