Skip to main content

max / makenotwork

29.3 KB · 764 lines History Blame Raw
1 //! Test harness for in-process integration tests.
2
3 pub(crate) mod client;
4 pub(crate) mod db;
5 pub(crate) mod email;
6 pub(crate) mod storage;
7 pub(crate) mod stripe;
8
9 /// CDN render base every test app uses unless `Opts::cdn_base_url` overrides it.
10 /// `Config::cdn_base_url` is required, so tests always have one.
11 pub(crate) const TEST_CDN_BASE: &str = "https://cdn.test";
12
13 /// Compute SHA-256 hash of a SyncKit API key (mirrors server's hash_api_key).
14 pub(crate) fn hash_api_key(api_key: &str) -> String {
15 use sha2::Digest;
16 hex::encode(sha2::Sha256::digest(api_key.as_bytes()))
17 }
18
19 use docengine::DocLoader;
20 use makenotwork::config::{
21 BuildConfig, Config, CreatorTierPricing, EmailWebhookConfig, IntegrationsConfig, ScanConfig,
22 StripeConfig,
23 };
24 use makenotwork::db::UserId;
25 use makenotwork::email::{EmailClient, EmailConfig};
26 use makenotwork::payments::{PaymentProvider, StripeClient};
27 use makenotwork::scanning::ScanPipeline;
28 use makenotwork::{AppState, AppStateParts, AppStorage, build_app};
29 use sqlx::PgPool;
30 use std::sync::Arc;
31 use tower_sessions::cookie::SameSite;
32 use tower_sessions::cookie::time::Duration as CookieDuration;
33 use tower_sessions::{Expiry, SessionManagerLayer};
34 use tower_sessions_sqlx_store::PostgresStore;
35
36 use self::client::TestClient;
37 use self::db::TestDb;
38 use self::storage::InMemoryStorage;
39
40 /// Record a test's wall-clock duration to a shared timing file.
41 /// Call at the end of a test with the test name and start instant.
42 /// Results are appended to `/tmp/mnw-test-timing.csv` for analysis.
43 #[allow(dead_code)]
44 pub(crate) fn record_test_timing(name: &str, start: std::time::Instant) {
45 let elapsed_ms = start.elapsed().as_millis();
46 let line = format!("{name},{elapsed_ms}\n");
47 use std::io::Write;
48 if let Ok(mut f) = std::fs::OpenOptions::new()
49 .create(true)
50 .append(true)
51 .open("/tmp/mnw-test-timing.csv")
52 {
53 let _ = f.write_all(line.as_bytes());
54 }
55 }
56
57 /// Result of setting up a test creator with project and item.
58 #[allow(dead_code)]
59 pub(crate) struct CreatorSetup {
60 pub user_id: UserId,
61 pub project_id: String,
62 pub item_id: String,
63 pub slug: String,
64 }
65
66 /// Options for customizing a test harness build.
67 #[derive(Default)]
68 pub(crate) struct BuildOptions {
69 pub storage: Option<Arc<InMemoryStorage>>,
70 pub synckit_storage: Option<Arc<InMemoryStorage>>,
71 pub stripe_client: Option<Arc<dyn PaymentProvider>>,
72 pub scanner: Option<Arc<ScanPipeline>>,
73 pub admin_user_id: Option<UserId>,
74 pub existing_db: Option<TestDb>,
75 pub postmark_webhook_token: Option<String>,
76 pub postmark_broadcast_webhook_token: Option<String>,
77 pub git_repos_path: Option<String>,
78 pub build_trigger_token: Option<String>,
79 pub postmark_inbound_webhook_token: Option<String>,
80 pub mt_base_url: Option<String>,
81 pub internal_shared_secret: Option<String>,
82 pub cli_service_token: Option<String>,
83 pub mock_email: Option<Arc<email::MockEmailTransport>>,
84 /// Overrides the CDN render base. `None` uses [`TEST_CDN_BASE`]; the base is
85 /// required config, so there is no "no CDN" mode to opt into.
86 pub cdn_base_url: Option<String>,
87 /// Site access gate. Defaults to `Open`; set to `FanPlusOrCreator` to test
88 /// the testnot-style gate.
89 pub access_gate: makenotwork::config::AccessGate,
90 /// Delegated-login (SSO) provider config. `None` = local password form.
91 pub sso: Option<makenotwork::config::SsoConfig>,
92 /// Sticker monthly creator-tier price IDs. `None` = empty (creator-tier
93 /// checkout is unconfigured and bails). Set to exercise creator-tier flows.
94 pub creator_tier_prices:
95 Option<std::collections::HashMap<makenotwork::db::CreatorTier, String>>,
96 }
97
98 /// Full test harness: isolated database, in-process app, cookie-aware client.
99 #[allow(dead_code)]
100 pub(crate) struct TestHarness {
101 pub client: TestClient,
102 pub db: PgPool,
103 pub storage: Option<Arc<InMemoryStorage>>,
104 /// Mock email transport, if configured. Use `.sent()` to inspect sent emails.
105 pub mock_email: Option<Arc<email::MockEmailTransport>>,
106 /// Mock payment provider, if configured. Use `.checkouts()` to inspect created sessions.
107 pub mock_stripe: Option<Arc<stripe::MockPaymentProvider>>,
108 /// Pieces needed to drain the scan worker synchronously from tests
109 /// (`drain_scan_jobs`). `None` when the harness wasn't built with a scanner.
110 scan_deps: Option<ScanDeps>,
111 _test_db: TestDb,
112 }
113
114 struct ScanDeps {
115 s3: Arc<dyn makenotwork::storage::StorageBackend>,
116 pipeline: Arc<ScanPipeline>,
117 semaphore: Arc<tokio::sync::Semaphore>,
118 }
119
120 impl TestHarness {
121 /// Spin up a fresh database, build the app, and return a ready-to-use harness.
122 pub(crate) async fn new() -> Self {
123 Self::build(BuildOptions::default()).await
124 }
125
126 /// Harness with in-memory storage backend.
127 #[allow(dead_code)]
128 pub(crate) async fn with_storage() -> Self {
129 let mem = Arc::new(InMemoryStorage::new());
130 Self::build(BuildOptions {
131 storage: Some(mem),
132 ..Default::default()
133 })
134 .await
135 }
136
137 /// Harness with SyncKit in-memory storage backend (for OTA tests).
138 #[allow(dead_code)]
139 pub(crate) async fn with_synckit_storage() -> Self {
140 let mem = Arc::new(InMemoryStorage::new());
141 Self::build(BuildOptions {
142 synckit_storage: Some(mem),
143 ..Default::default()
144 })
145 .await
146 }
147
148 /// Harness with in-memory storage + file scanning pipeline.
149 #[allow(dead_code)]
150 pub(crate) async fn with_storage_and_scanner() -> Self {
151 let mem = Arc::new(InMemoryStorage::new());
152 let scanner = Self::no_op_scanner();
153 Self::build(BuildOptions {
154 storage: Some(mem),
155 scanner: Some(Arc::new(scanner)),
156 ..Default::default()
157 })
158 .await
159 }
160
161 /// Harness with admin user + in-memory storage + file scanning pipeline.
162 /// Returns (harness, admin_user_id).
163 #[allow(dead_code)]
164 pub(crate) async fn with_admin_storage_and_scanner() -> (Self, UserId) {
165 let test_db = TestDb::new().await;
166 let pool = test_db.pool.clone();
167 let admin_id = Self::insert_admin_user(&pool).await;
168
169 let mem = Arc::new(InMemoryStorage::new());
170 let scanner = Self::no_op_scanner();
171 let harness = Self::build(BuildOptions {
172 storage: Some(mem),
173 scanner: Some(Arc::new(scanner)),
174 admin_user_id: Some(admin_id),
175 existing_db: Some(test_db),
176 ..Default::default()
177 })
178 .await;
179 (harness, admin_id)
180 }
181
182 /// Harness with Stripe client configured (fake key, known webhook secrets).
183 #[allow(dead_code)]
184 pub(crate) async fn with_stripe() -> Self {
185 let stripe_config = StripeConfig {
186 secret_key: "sk_test_fake_key_for_testing".to_string(),
187 webhook_secret: vec![stripe::TEST_WEBHOOK_SECRET.to_string()],
188 webhook_secret_v2: Some(stripe::TEST_WEBHOOK_SECRET_V2.to_string()),
189 };
190 let stripe_client: Arc<dyn PaymentProvider> =
191 Arc::new(StripeClient::new(&stripe_config).expect("test Stripe client builds"));
192 Self::build(BuildOptions {
193 stripe_client: Some(stripe_client),
194 ..Default::default()
195 })
196 .await
197 }
198
199 /// Harness with mock Stripe + mock email for full payment flow testing.
200 /// Access mocks via `harness.mock_stripe` and `harness.mock_email`.
201 #[allow(dead_code)]
202 pub(crate) async fn with_mocks() -> Self {
203 let mock_stripe = Arc::new(stripe::MockPaymentProvider::new());
204 let mock_email = Arc::new(email::MockEmailTransport::new());
205 let mem = Arc::new(InMemoryStorage::new());
206 let mut harness = Self::build(BuildOptions {
207 storage: Some(mem),
208 stripe_client: Some(mock_stripe.clone() as Arc<dyn PaymentProvider>),
209 mock_email: Some(mock_email),
210 ..Default::default()
211 })
212 .await;
213 harness.mock_stripe = Some(mock_stripe);
214 harness
215 }
216
217 /// Harness wired for creator-tier checkout: mock Stripe plus a configured
218 /// sticker price for the Everything tier (the founder window stays closed,
219 /// so checkout uses the sticker price ID, the mock ignores it anyway).
220 /// Exposes `mock_stripe` for asserting on the trial passed to Stripe.
221 #[allow(dead_code)]
222 pub(crate) async fn with_creator_tier_checkout() -> Self {
223 let mock_stripe = Arc::new(stripe::MockPaymentProvider::new());
224 let mut prices = std::collections::HashMap::new();
225 prices.insert(
226 makenotwork::db::CreatorTier::Everything,
227 "price_test_everything".to_string(),
228 );
229 let mut harness = Self::build(BuildOptions {
230 storage: Some(Arc::new(InMemoryStorage::new())),
231 stripe_client: Some(mock_stripe.clone() as Arc<dyn PaymentProvider>),
232 creator_tier_prices: Some(prices),
233 ..Default::default()
234 })
235 .await;
236 harness.mock_stripe = Some(mock_stripe);
237 harness
238 }
239
240 /// Harness with admin user configured. Returns (harness, admin_user_id).
241 #[allow(dead_code)]
242 pub(crate) async fn with_admin() -> (Self, UserId) {
243 let test_db = TestDb::new().await;
244 let pool = test_db.pool.clone();
245 let admin_id = Self::insert_admin_user(&pool).await;
246
247 let harness = Self::build(BuildOptions {
248 admin_user_id: Some(admin_id),
249 existing_db: Some(test_db),
250 ..Default::default()
251 })
252 .await;
253 (harness, admin_id)
254 }
255
256 /// Harness with Postmark webhook token configured.
257 #[allow(dead_code)]
258 pub(crate) async fn with_postmark() -> Self {
259 Self::build(BuildOptions {
260 postmark_webhook_token: Some("test-postmark-token".to_string()),
261 postmark_broadcast_webhook_token: Some("test-broadcast-token".to_string()),
262 ..Default::default()
263 })
264 .await
265 }
266
267 /// Harness with git repos path configured.
268 #[allow(dead_code)]
269 pub(crate) async fn with_git_repos(path: String) -> Self {
270 Self::build(BuildOptions {
271 git_repos_path: Some(path),
272 ..Default::default()
273 })
274 .await
275 }
276
277 /// Insert an admin user and return the ID.
278 async fn insert_admin_user(pool: &PgPool) -> UserId {
279 let password_hash =
280 makenotwork::auth::hash_password("password123").expect("hash_password for admin");
281 sqlx::query_scalar(
282 "INSERT INTO users (username, email, password_hash, email_verified)
283 VALUES ('admin', 'admin@test.com', $1, true)
284 RETURNING id",
285 )
286 .bind(&password_hash)
287 .fetch_one(pool)
288 .await
289 .expect("Failed to insert admin user")
290 }
291
292 /// Create a no-op scan pipeline for tests.
293 fn no_op_scanner() -> ScanPipeline {
294 let scan_config = ScanConfig {
295 clamav_socket: None,
296 yara_rules_dir: "/nonexistent".to_string(),
297 malwarebazaar_enabled: false,
298 urlhaus_enabled: false,
299 abuse_ch_auth_key: None,
300 metadefender_api_key: None,
301 yara_min_rule_files: 0,
302 clamav_max_scan_bytes: None,
303 };
304 ScanPipeline::new(&scan_config).expect("ScanPipeline::new with no-op config")
305 }
306
307 /// Builder shared by all constructors. Public so workflow tests can use custom `BuildOptions`.
308 pub(crate) async fn build(opts: BuildOptions) -> Self {
309 let t0 = std::time::Instant::now();
310 let test_db = match opts.existing_db {
311 Some(db) => db,
312 None => TestDb::new().await,
313 };
314 let pool = test_db.pool.clone();
315
316 // Create session store (migration already applied in template DB)
317 let session_store = PostgresStore::new(pool.clone());
318 if !test_db.session_migrated {
319 session_store
320 .migrate()
321 .await
322 .expect("Failed to migrate session store");
323 }
324
325 let session_layer = SessionManagerLayer::new(session_store)
326 .with_secure(false)
327 .with_same_site(SameSite::Lax)
328 .with_expiry(Expiry::OnInactivity(CookieDuration::days(1)));
329
330 // Minimal config, no S3, no Stripe (those come from opts)
331 let config = Config {
332 host: "127.0.0.1".parse().unwrap(),
333 port: 0,
334 database_url: String::new(),
335 host_url: std::sync::Arc::from("http://localhost:3000"),
336 signing_secret: "test-signing-secret-for-integration-tests".to_string(),
337 storage: None,
338 synckit_storage: None,
339 public_storage: None,
340 stripe: None,
341 admin_user_id: opts.admin_user_id,
342 synckit_jwt_secret: Some("test-synckit-jwt-secret".to_string()),
343 scan: None,
344 cdn_base_url: opts
345 .cdn_base_url
346 .clone()
347 .unwrap_or_else(|| TEST_CDN_BASE.to_string()),
348 user_pages_host: std::sync::Arc::from("u.localhost"),
349 access_gate: opts.access_gate,
350 sso: opts.sso.clone(),
351 build: BuildConfig {
352 trigger_token: opts.build_trigger_token,
353 host_linux: None,
354 host_darwin: None,
355 git_repos_path: opts.git_repos_path,
356 git_ssh_host: None,
357 },
358 email_webhooks: EmailWebhookConfig {
359 webhook_token: opts.postmark_webhook_token,
360 broadcast_webhook_token: opts.postmark_broadcast_webhook_token,
361 inbound_webhook_token: opts.postmark_inbound_webhook_token,
362 enforce_sender_auth: true,
363 },
364 creator_pricing: CreatorTierPricing {
365 fan_plus_price_id: None,
366 tier_prices: opts.creator_tier_prices.unwrap_or_default(),
367 tier_annual_prices: std::collections::HashMap::new(),
368 tier_founder_prices: std::collections::HashMap::new(),
369 tier_founder_annual_prices: std::collections::HashMap::new(),
370 founder_window_open: false,
371 },
372 integrations: IntegrationsConfig {
373 mt_base_url: None,
374 wam_url: None,
375 internal_shared_secret: opts.internal_shared_secret.clone(),
376 cli_service_token: opts.cli_service_token.clone(),
377 alerts_ingest_token: None,
378 },
379 };
380
381 let mock_email_ref = opts.mock_email.clone();
382 let email = if let Some(ref mock) = opts.mock_email {
383 EmailClient::with_transport(mock.clone() as Arc<dyn makenotwork::email::EmailTransport>)
384 } else {
385 EmailClient::new(
386 EmailConfig {
387 postmark_token: None,
388 from_address: "test@makenot.work".to_string(),
389 from_name: "Test".to_string(),
390 },
391 Some(pool.clone()),
392 )
393 };
394
395 let rp_origin = url::Url::parse(&config.host_url).expect("test HOST_URL");
396 let rp_id = rp_origin
397 .host_str()
398 .expect("test HOST_URL host")
399 .to_string();
400 let webauthn = Arc::new(
401 webauthn_rs::WebauthnBuilder::new(&rp_id, &rp_origin)
402 .expect("WebauthnBuilder")
403 .rp_name("Test")
404 .build()
405 .expect("Webauthn"),
406 );
407
408 // Convert InMemoryStorage to trait object
409 let storage = opts.storage;
410 let s3 = storage
411 .clone()
412 .map(|s| s as Arc<dyn makenotwork::storage::StorageBackend>);
413 let synckit_s3 = opts
414 .synckit_storage
415 .map(|s| s as Arc<dyn makenotwork::storage::StorageBackend>);
416 // Public bucket shares the same in-memory backend as `s3` (one flat map,
417 // no bucket isolation) so the cross-bucket image promote resolves.
418 let public_s3 = s3.clone();
419
420 // Route through the same `AppState::build` constructor production uses
421 // (main.rs), so the derived in-memory state, start timestamps, the empty
422 // cache maps, the concurrency semaphores, has a single source of truth.
423 // The harness only supplies the externally-wired dependencies.
424 let state = AppState::build(AppStateParts {
425 db: pool.clone(),
426 config,
427 storage: AppStorage {
428 s3,
429 synckit_s3,
430 public_s3,
431 },
432 stripe: opts.stripe_client,
433 email,
434 docs: Arc::new(DocLoader::load(
435 std::path::Path::new("."),
436 &docengine::DocLoaderConfig {
437 sections: vec![],
438 link_prefix: "/docs".to_string(),
439 unpublished_pattern: None,
440 examples_path: None,
441 pre_process: None,
442 },
443 )),
444 tier_prices: {
445 // Install the process-global TierPrices so handler code paths
446 // that call CreatorTier::{price_cents,max_file_bytes,
447 // max_storage_bytes} work under test (they read the global,
448 // same as production). Idempotent across tests.
449 makenotwork::tier_prices::TierPrices::install_test_default();
450 makenotwork::tier_prices::TierPrices::global().clone()
451 },
452 runway_config: makenotwork::tier_prices::RunwayConfig::default(),
453 pricing_comparison: makenotwork::pricing_comparison::PricingComparison::load(
454 "docs/business/assumptions.toml",
455 ),
456 scanner: opts.scanner,
457 webauthn,
458 syntax: None,
459 mt_client: opts
460 .mt_base_url
461 .zip(opts.internal_shared_secret)
462 .map(|(url, secret)| makenotwork::mt_client::MtClient::new(url, secret)),
463 wam: None,
464 domain_cache: Arc::new(dashmap::DashMap::new()),
465 metrics_handle: None,
466 page_view_tx: makenotwork::db::page_views::spawn_batcher(pool.clone()),
467 bg: makenotwork::background::spawn_pool_detached(),
468 });
469
470 // Capture scan deps before `build_app` consumes `state`.
471 let scan_deps = match (state.scanner.clone(), state.storage.s3.clone()) {
472 (Some(pipeline), Some(s3)) => Some(ScanDeps {
473 s3,
474 pipeline,
475 semaphore: state.limiters.scan_semaphore.clone(),
476 }),
477 _ => None,
478 };
479
480 let app = build_app(&state, session_layer);
481 let client = TestClient::new(app);
482
483 // Extract mock_stripe: if the stripe_client is a MockPaymentProvider,
484 // we stored the Arc in BuildOptions.stripe_client. We can't downcast the
485 // trait object, so with_mocks() stores the mock ref separately. For the
486 // general build path, mock_stripe is None.
487 let mock_stripe = None; // Set by with_mocks() post-build via direct field access
488
489 let build_ms = t0.elapsed().as_millis();
490 if build_ms > 1000 {
491 eprintln!("[test-harness] SLOW harness build: {build_ms}ms");
492 }
493
494 TestHarness {
495 client,
496 db: pool,
497 storage,
498 mock_email: mock_email_ref,
499 mock_stripe,
500 scan_deps,
501 _test_db: test_db,
502 }
503 }
504
505 /// Sign up a new user via POST /join. Returns the user's ID.
506 pub(crate) async fn signup(&mut self, username: &str, email: &str, password: &str) -> UserId {
507 // Fetch a page first to establish session + CSRF
508 self.client.fetch_csrf_token().await;
509
510 let body = format!(
511 "username={}&email={}&password={}",
512 urlencoding::encode(username),
513 urlencoding::encode(email),
514 urlencoding::encode(password),
515 );
516
517 let resp = self.client.post_form("/join/step/account", &body).await;
518 assert!(
519 resp.status.is_success() || resp.status.is_redirection(),
520 "Signup failed with status {}: {}",
521 resp.status,
522 resp.text
523 );
524
525 // Login rotates the CSRF token, fetch the new one
526 self.client.fetch_csrf_token().await;
527
528 // Look up the user in the database
529 sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE username = $1")
530 .bind(username)
531 .fetch_one(&self.db)
532 .await
533 .expect("User not found after signup")
534 }
535
536 /// Grant creator permissions to a user via direct SQL.
537 pub(crate) async fn grant_creator(&self, user_id: UserId) {
538 sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1")
539 .bind(user_id)
540 .execute(&self.db)
541 .await
542 .expect("Failed to grant creator");
543 }
544
545 /// Trust a user for uploads via direct SQL.
546 pub(crate) async fn trust_user(&self, user_id: UserId) {
547 sqlx::query("UPDATE users SET upload_trusted = true WHERE id = $1")
548 .bind(user_id)
549 .execute(&self.db)
550 .await
551 .expect("Failed to trust user");
552 }
553
554 /// Give a user an active creator tier subscription via direct SQL.
555 /// Also syncs the denormalized `creator_tier` column on the users table.
556 pub(crate) async fn grant_tier(&self, user_id: UserId, tier: &str) {
557 sqlx::query(
558 r"INSERT INTO creator_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, tier, status)
559 VALUES ($1, 'sub_test_' || $1::text, 'cus_test_' || $1::text, $2, 'active')
560 ON CONFLICT (user_id) DO UPDATE SET tier = $2, status = 'active'",
561 )
562 .bind(user_id)
563 .bind(tier)
564 .execute(&self.db)
565 .await
566 .expect("Failed to grant tier");
567
568 sqlx::query("UPDATE users SET creator_tier = $2 WHERE id = $1")
569 .bind(user_id)
570 .bind(tier)
571 .execute(&self.db)
572 .await
573 .expect("Failed to sync creator_tier");
574 }
575
576 /// Suspend a user via direct SQL.
577 #[allow(dead_code)]
578 pub(crate) async fn suspend_user(&self, user_id: UserId) {
579 sqlx::query("UPDATE users SET suspended_at = NOW(), suspension_reason = 'test suspension' WHERE id = $1")
580 .bind(user_id)
581 .execute(&self.db)
582 .await
583 .expect("Failed to suspend user");
584 }
585
586 /// POST a single login attempt and return the response. Refreshes the
587 /// CSRF token first so the new Manual-posture `/login` (Phase 2) accepts
588 /// the form even when a previous `/logout` invalidated the cached token.
589 /// Use this for negative-path login tests (lockout, suspended, wrong
590 /// password) that need to inspect the response rather than asserting
591 /// success like `login()` does.
592 pub(crate) async fn failed_login_attempt(
593 &mut self,
594 login: &str,
595 password: &str,
596 ) -> client::TestResponse {
597 self.client.fetch_csrf_token().await;
598 let body = format!(
599 "login={}&password={}",
600 urlencoding::encode(login),
601 urlencoding::encode(password),
602 );
603 self.client.post_form("/login", &body).await
604 }
605
606 /// Synchronously drain queued scan jobs by running the worker loop in-
607 /// process until the queue is empty. Mirrors the production worker pool
608 /// without spawning a background task, integration tests call this
609 /// between upload-confirm and any assertion on `scan_status`.
610 pub(crate) async fn drain_scan_jobs(&self) {
611 let Some(deps) = &self.scan_deps else { return };
612 let ctx = makenotwork::scanning::worker::WorkerContext {
613 db: self.db.clone(),
614 s3: deps.s3.clone(),
615 pipeline: deps.pipeline.clone(),
616 scan_semaphore: deps.semaphore.clone(),
617 wam: None,
618 bg: makenotwork::background::spawn_pool_detached(),
619 // No Cloudflare purge in tests; quarantine still deletes from origin.
620 cloudflare: None,
621 cdn_base_url: std::sync::Arc::from(TEST_CDN_BASE),
622 // OTA artifacts scan from the SyncKit bucket; tests share one backend.
623 synckit_s3: Some(deps.s3.clone()),
624 // Public bucket shares the same backend; image promotes copy here.
625 public_s3: Some(deps.s3.clone()),
626 };
627 // Hard cap to avoid an infinite loop if a job re-enqueues itself.
628 for _ in 0..256 {
629 match makenotwork::scanning::worker::process_next_for_test(&ctx).await {
630 Ok(true) => {}
631 Ok(false) => return,
632 Err(e) => panic!("scan worker drain failed: {e}"),
633 }
634 }
635 panic!("drain_scan_jobs did not terminate within 256 iterations");
636 }
637
638 /// Synchronously perform any queued S3 object deletions (`main` bucket).
639 /// Handler-side deletes only enqueue to `pending_s3_deletions`; the actual
640 /// delete is the scheduler's job in production. Tests that assert an object
641 /// was removed from storage call this first (the deterministic mirror of
642 /// the production retry worker). Returns the number of objects deleted.
643 pub(crate) async fn drain_s3_deletions(&self) -> usize {
644 let Some(storage) = self.storage.as_ref() else {
645 return 0;
646 };
647 makenotwork::scheduler::drain_pending_s3_deletions_for_test(&self.db, storage.as_ref())
648 .await
649 }
650
651 /// Log in as an existing user via POST /login. The client's session
652 /// cookies are updated automatically.
653 pub(crate) async fn login(&mut self, login: &str, password: &str) {
654 // Fetch CSRF token first
655 self.client.fetch_csrf_token().await;
656
657 let body = format!(
658 "login={}&password={}",
659 urlencoding::encode(login),
660 urlencoding::encode(password),
661 );
662
663 let resp = self.client.post_form("/login", &body).await;
664 assert!(
665 resp.status.is_success() || resp.status.is_redirection(),
666 "Login failed with status {}: {}",
667 resp.status,
668 resp.text
669 );
670
671 // Login rotates the CSRF token, fetch the new one
672 self.client.fetch_csrf_token().await;
673 }
674
675 /// Create a test creator: signup, grant creator access, re-login.
676 /// Uses password "password123" and email "{username}@test.com".
677 pub(crate) async fn create_creator(&mut self, username: &str) -> UserId {
678 let user_id = self
679 .signup(username, &format!("{username}@test.com"), "password123")
680 .await;
681 self.grant_creator(user_id).await;
682 self.client.post_form("/logout", "").await;
683 self.login(username, "password123").await;
684 user_id
685 }
686
687 /// Create a test creator with a project and one item. Creator is logged in afterward.
688 /// Project slug: "{username}-proj". Returns all created IDs.
689 pub(crate) async fn create_creator_with_item(
690 &mut self,
691 username: &str,
692 item_type: &str,
693 price_cents: i64,
694 ) -> CreatorSetup {
695 let user_id = self.create_creator(username).await;
696
697 // Usernames may contain underscores (valid for accounts) but project
698 // slugs may not, the slug charset is lowercase letters, digits, and
699 // hyphens only. Sanitize so a `seller_vis` creator yields `seller-vis-proj`.
700 let slug = format!("{}-proj", username.replace('_', "-"));
701 let resp = self
702 .client
703 .post_form("/api/projects", &format!("slug={slug}&title=Test+Project"))
704 .await;
705 assert!(
706 resp.status.is_success(),
707 "Create project failed: {}",
708 resp.text
709 );
710 let project: serde_json::Value = resp.json();
711 let project_id = project["id"].as_str().unwrap().to_string();
712
713 let resp = self
714 .client
715 .post_form(
716 &format!("/api/projects/{project_id}/items"),
717 &format!("title=Test+Item&item_type={item_type}&price_cents={price_cents}"),
718 )
719 .await;
720 assert!(
721 resp.status.is_success(),
722 "Create item failed: {}",
723 resp.text
724 );
725 let item: serde_json::Value = resp.json();
726 let item_id = item["id"].as_str().unwrap().to_string();
727
728 CreatorSetup {
729 user_id,
730 project_id,
731 item_id,
732 slug,
733 }
734 }
735
736 /// Connect a user's Stripe account via direct SQL.
737 /// Sets stripe_account_id, stripe_charges_enabled, and stripe_onboarding_complete.
738 /// Use after `create_creator()` for tests that need a Stripe-connected seller.
739 pub(crate) async fn connect_stripe(&self, user_id: UserId, account_id: &str) {
740 sqlx::query(
741 "UPDATE users SET stripe_account_id = $2, stripe_charges_enabled = true, \
742 stripe_onboarding_complete = true, stripe_payouts_enabled = true WHERE id = $1",
743 )
744 .bind(user_id)
745 .bind(account_id)
746 .execute(&self.db)
747 .await
748 .expect("Failed to connect Stripe");
749 }
750
751 /// Publish both a project and an item.
752 pub(crate) async fn publish_project_and_item(&mut self, project_id: &str, item_id: &str) {
753 self.client
754 .put_json(
755 &format!("/api/projects/{project_id}"),
756 r#"{"is_public": true}"#,
757 )
758 .await;
759 self.client
760 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
761 .await;
762 }
763 }
764