Skip to main content

max / makenotwork

24.5 KB · 639 lines History Blame Raw
1 //! Test harness for in-process integration tests.
2
3 pub mod client;
4 pub mod db;
5 pub mod email;
6 pub mod storage;
7 pub mod stripe;
8
9 /// Compute SHA-256 hash of a SyncKit API key (mirrors server's hash_api_key).
10 pub fn hash_api_key(api_key: &str) -> String {
11 use sha2::Digest;
12 hex::encode(sha2::Sha256::digest(api_key.as_bytes()))
13 }
14
15 use makenotwork::config::{Config, ScanConfig, StripeConfig};
16 use docengine::DocLoader;
17 use makenotwork::email::{EmailClient, EmailConfig};
18 use makenotwork::payments::{PaymentProvider, StripeClient};
19 use makenotwork::scanning::ScanPipeline;
20 use makenotwork::{build_app, AppState};
21 use sqlx::PgPool;
22 use std::sync::Arc;
23 use std::time::Instant;
24 use tower_sessions::cookie::time::Duration as CookieDuration;
25 use tower_sessions::cookie::SameSite;
26 use tower_sessions::{Expiry, SessionManagerLayer};
27 use tower_sessions_sqlx_store::PostgresStore;
28 use makenotwork::db::UserId;
29
30 use self::client::TestClient;
31 use self::db::TestDb;
32 use self::storage::InMemoryStorage;
33
34 /// Record a test's wall-clock duration to a shared timing file.
35 /// Call at the end of a test with the test name and start instant.
36 /// Results are appended to `/tmp/mnw-test-timing.csv` for analysis.
37 #[allow(dead_code)]
38 pub fn record_test_timing(name: &str, start: std::time::Instant) {
39 let elapsed_ms = start.elapsed().as_millis();
40 let line = format!("{},{}\n", name, elapsed_ms);
41 use std::io::Write;
42 if let Ok(mut f) = std::fs::OpenOptions::new()
43 .create(true)
44 .append(true)
45 .open("/tmp/mnw-test-timing.csv")
46 {
47 let _ = f.write_all(line.as_bytes());
48 }
49 }
50
51 /// Result of setting up a test creator with project and item.
52 #[allow(dead_code)]
53 pub struct CreatorSetup {
54 pub user_id: UserId,
55 pub project_id: String,
56 pub item_id: String,
57 pub slug: String,
58 }
59
60 /// Options for customizing a test harness build.
61 #[derive(Default)]
62 pub struct BuildOptions {
63 pub storage: Option<Arc<InMemoryStorage>>,
64 pub synckit_storage: Option<Arc<InMemoryStorage>>,
65 pub stripe_client: Option<Arc<dyn PaymentProvider>>,
66 pub scanner: Option<Arc<ScanPipeline>>,
67 pub admin_user_id: Option<UserId>,
68 pub existing_db: Option<TestDb>,
69 pub postmark_webhook_token: Option<String>,
70 pub postmark_broadcast_webhook_token: Option<String>,
71 pub git_repos_path: Option<String>,
72 pub build_trigger_token: Option<String>,
73 pub postmark_inbound_webhook_token: Option<String>,
74 pub mt_base_url: Option<String>,
75 pub internal_shared_secret: Option<String>,
76 pub cli_service_token: Option<String>,
77 pub mock_email: Option<Arc<email::MockEmailTransport>>,
78 }
79
80 /// Full test harness: isolated database, in-process app, cookie-aware client.
81 #[allow(dead_code)]
82 pub struct TestHarness {
83 pub client: TestClient,
84 pub db: PgPool,
85 pub storage: Option<Arc<InMemoryStorage>>,
86 /// Mock email transport, if configured. Use `.sent()` to inspect sent emails.
87 pub mock_email: Option<Arc<email::MockEmailTransport>>,
88 /// Mock payment provider, if configured. Use `.checkouts()` to inspect created sessions.
89 pub mock_stripe: Option<Arc<stripe::MockPaymentProvider>>,
90 /// Pieces needed to drain the scan worker synchronously from tests
91 /// (`drain_scan_jobs`). `None` when the harness wasn't built with a scanner.
92 scan_deps: Option<ScanDeps>,
93 _test_db: TestDb,
94 }
95
96 struct ScanDeps {
97 s3: Arc<dyn makenotwork::storage::StorageBackend>,
98 pipeline: Arc<ScanPipeline>,
99 semaphore: Arc<tokio::sync::Semaphore>,
100 }
101
102 impl TestHarness {
103 /// Spin up a fresh database, build the app, and return a ready-to-use harness.
104 pub async fn new() -> Self {
105 Self::build(BuildOptions::default()).await
106 }
107
108 /// Harness with in-memory storage backend.
109 #[allow(dead_code)]
110 pub async fn with_storage() -> Self {
111 let mem = Arc::new(InMemoryStorage::new());
112 Self::build(BuildOptions { storage: Some(mem), ..Default::default() }).await
113 }
114
115 /// Harness with SyncKit in-memory storage backend (for OTA tests).
116 #[allow(dead_code)]
117 pub async fn with_synckit_storage() -> Self {
118 let mem = Arc::new(InMemoryStorage::new());
119 Self::build(BuildOptions { synckit_storage: Some(mem), ..Default::default() }).await
120 }
121
122 /// Harness with in-memory storage + file scanning pipeline.
123 #[allow(dead_code)]
124 pub async fn with_storage_and_scanner() -> Self {
125 let mem = Arc::new(InMemoryStorage::new());
126 let scanner = Self::no_op_scanner();
127 Self::build(BuildOptions {
128 storage: Some(mem),
129 scanner: Some(Arc::new(scanner)),
130 ..Default::default()
131 }).await
132 }
133
134 /// Harness with admin user + in-memory storage + file scanning pipeline.
135 /// Returns (harness, admin_user_id).
136 #[allow(dead_code)]
137 pub async fn with_admin_storage_and_scanner() -> (Self, UserId) {
138 let test_db = TestDb::new().await;
139 let pool = test_db.pool.clone();
140 let admin_id = Self::insert_admin_user(&pool).await;
141
142 let mem = Arc::new(InMemoryStorage::new());
143 let scanner = Self::no_op_scanner();
144 let harness = Self::build(BuildOptions {
145 storage: Some(mem),
146 scanner: Some(Arc::new(scanner)),
147 admin_user_id: Some(admin_id),
148 existing_db: Some(test_db),
149 ..Default::default()
150 }).await;
151 (harness, admin_id)
152 }
153
154 /// Harness with Stripe client configured (fake key, known webhook secrets).
155 #[allow(dead_code)]
156 pub async fn with_stripe() -> Self {
157 let stripe_config = StripeConfig {
158 secret_key: "sk_test_fake_key_for_testing".to_string(),
159 webhook_secret: vec![stripe::TEST_WEBHOOK_SECRET.to_string()],
160 webhook_secret_v2: Some(stripe::TEST_WEBHOOK_SECRET_V2.to_string()),
161 };
162 let stripe_client: Arc<dyn PaymentProvider> = Arc::new(StripeClient::new(&stripe_config));
163 Self::build(BuildOptions {
164 stripe_client: Some(stripe_client),
165 ..Default::default()
166 }).await
167 }
168
169 /// Harness with mock Stripe + mock email for full payment flow testing.
170 /// Access mocks via `harness.mock_stripe` and `harness.mock_email`.
171 #[allow(dead_code)]
172 pub async fn with_mocks() -> Self {
173 let mock_stripe = Arc::new(stripe::MockPaymentProvider::new());
174 let mock_email = Arc::new(email::MockEmailTransport::new());
175 let mem = Arc::new(InMemoryStorage::new());
176 let mut harness = Self::build(BuildOptions {
177 storage: Some(mem),
178 stripe_client: Some(mock_stripe.clone() as Arc<dyn PaymentProvider>),
179 mock_email: Some(mock_email),
180 ..Default::default()
181 }).await;
182 harness.mock_stripe = Some(mock_stripe);
183 harness
184 }
185
186 /// Harness with admin user configured. Returns (harness, admin_user_id).
187 #[allow(dead_code)]
188 pub async fn with_admin() -> (Self, UserId) {
189 let test_db = TestDb::new().await;
190 let pool = test_db.pool.clone();
191 let admin_id = Self::insert_admin_user(&pool).await;
192
193 let harness = Self::build(BuildOptions {
194 admin_user_id: Some(admin_id),
195 existing_db: Some(test_db),
196 ..Default::default()
197 }).await;
198 (harness, admin_id)
199 }
200
201 /// Harness with Postmark webhook token configured.
202 #[allow(dead_code)]
203 pub async fn with_postmark() -> Self {
204 Self::build(BuildOptions {
205 postmark_webhook_token: Some("test-postmark-token".to_string()),
206 postmark_broadcast_webhook_token: Some("test-broadcast-token".to_string()),
207 ..Default::default()
208 }).await
209 }
210
211 /// Harness with git repos path configured.
212 #[allow(dead_code)]
213 pub async fn with_git_repos(path: String) -> Self {
214 Self::build(BuildOptions {
215 git_repos_path: Some(path),
216 ..Default::default()
217 }).await
218 }
219
220 /// Insert an admin user and return the ID.
221 async fn insert_admin_user(pool: &PgPool) -> UserId {
222 let password_hash = makenotwork::auth::hash_password("password123")
223 .expect("hash_password for admin");
224 sqlx::query_scalar(
225 "INSERT INTO users (username, email, password_hash, email_verified)
226 VALUES ('admin', 'admin@test.com', $1, true)
227 RETURNING id",
228 )
229 .bind(&password_hash)
230 .fetch_one(pool)
231 .await
232 .expect("Failed to insert admin user")
233 }
234
235 /// Create a no-op scan pipeline for tests.
236 fn no_op_scanner() -> ScanPipeline {
237 let scan_config = ScanConfig {
238 clamav_socket: None,
239 yara_rules_dir: "/nonexistent".to_string(),
240 malwarebazaar_enabled: false,
241 urlhaus_enabled: false,
242 abuse_ch_auth_key: None,
243 metadefender_api_key: None,
244 };
245 ScanPipeline::new(&scan_config).expect("ScanPipeline::new with no-op config")
246 }
247
248 /// Builder shared by all constructors. Public so workflow tests can use custom `BuildOptions`.
249 pub async fn build(opts: BuildOptions) -> Self {
250 let t0 = std::time::Instant::now();
251 let test_db = match opts.existing_db {
252 Some(db) => db,
253 None => TestDb::new().await,
254 };
255 let pool = test_db.pool.clone();
256
257 // Create session store (migration already applied in template DB)
258 let session_store = PostgresStore::new(pool.clone());
259 if !test_db.session_migrated {
260 session_store
261 .migrate()
262 .await
263 .expect("Failed to migrate session store");
264 }
265
266 let session_layer = SessionManagerLayer::new(session_store)
267 .with_secure(false)
268 .with_same_site(SameSite::Lax)
269 .with_expiry(Expiry::OnInactivity(CookieDuration::days(1)));
270
271 // Minimal config — no S3, no Stripe (those come from opts)
272 let config = Config {
273 host: "127.0.0.1".parse().unwrap(),
274 port: 0,
275 database_url: String::new(),
276 host_url: std::sync::Arc::from("http://localhost:3000"),
277 signing_secret: "test-signing-secret-for-integration-tests".to_string(),
278 storage: None,
279 synckit_storage: None,
280 stripe: None,
281 admin_user_id: opts.admin_user_id,
282 synckit_jwt_secret: Some("test-synckit-jwt-secret".to_string()),
283 scan: None,
284 git_repos_path: opts.git_repos_path,
285 postmark_webhook_token: opts.postmark_webhook_token,
286 postmark_broadcast_webhook_token: opts.postmark_broadcast_webhook_token,
287 git_ssh_host: None,
288 mt_base_url: None,
289 fan_plus_price_id: None,
290 creator_tier_prices: std::collections::HashMap::new(),
291 creator_tier_annual_prices: std::collections::HashMap::new(),
292 creator_tier_founder_prices: std::collections::HashMap::new(),
293 creator_tier_founder_annual_prices: std::collections::HashMap::new(),
294 creator_founder_window_open: false,
295 build_trigger_token: opts.build_trigger_token,
296 build_host_linux: None,
297 build_host_darwin: None,
298 cdn_base_url: None,
299 postmark_inbound_webhook_token: opts.postmark_inbound_webhook_token,
300 internal_shared_secret: opts.internal_shared_secret.clone(),
301 cli_service_token: opts.cli_service_token.clone(),
302 wam_url: None,
303 };
304
305 let mock_email_ref = opts.mock_email.clone();
306 let email = if let Some(ref mock) = opts.mock_email {
307 EmailClient::with_transport(mock.clone() as Arc<dyn makenotwork::email::EmailTransport>)
308 } else {
309 EmailClient::new(EmailConfig {
310 postmark_token: None,
311 from_address: "test@makenot.work".to_string(),
312 from_name: "Test".to_string(),
313 }, Some(pool.clone()))
314 };
315
316 let rp_origin = url::Url::parse(&config.host_url).expect("test HOST_URL");
317 let rp_id = rp_origin.host_str().expect("test HOST_URL host").to_string();
318 let webauthn = Arc::new(
319 webauthn_rs::WebauthnBuilder::new(&rp_id, &rp_origin)
320 .expect("WebauthnBuilder")
321 .rp_name("Test")
322 .build()
323 .expect("Webauthn"),
324 );
325
326 // Convert InMemoryStorage to trait object
327 let storage = opts.storage;
328 let s3 = storage.clone().map(|s| s as Arc<dyn makenotwork::storage::StorageBackend>);
329 let synckit_s3 = opts.synckit_storage.map(|s| s as Arc<dyn makenotwork::storage::StorageBackend>);
330
331 let state = AppState {
332 db: pool.clone(),
333 config,
334 s3,
335 synckit_s3,
336 stripe: opts.stripe_client,
337 email,
338 docs: Arc::new(DocLoader::load(std::path::Path::new("."), &docengine::DocLoaderConfig {
339 sections: vec![],
340 link_prefix: "/docs".to_string(),
341 unpublished_pattern: None,
342 examples_path: None,
343 pre_process: None,
344 })),
345 scanner: opts.scanner,
346 webauthn,
347 syntax: None,
348 started_at: chrono::Utc::now(),
349 start_instant: Instant::now(),
350 session_cache: Arc::new(dashmap::DashMap::new()),
351 mt_client: opts.mt_base_url.zip(opts.internal_shared_secret).map(
352 |(url, secret)| makenotwork::mt_client::MtClient::new(url, secret),
353 ),
354 wam: None,
355 domain_cache: Arc::new(dashmap::DashMap::new()),
356 restart_at: Arc::new(std::sync::atomic::AtomicI64::new(0)),
357 sync_notify: Arc::new(dashmap::DashMap::new()),
358 sse_connections: Arc::new(dashmap::DashMap::new()),
359 metrics_handle: None,
360 scan_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
361 caddy_ask_semaphore: Arc::new(tokio::sync::Semaphore::new(8)),
362 page_view_tx: makenotwork::db::page_views::spawn_batcher(pool.clone()),
363 bg: makenotwork::background::spawn_pool(),
364 };
365
366 // Capture scan deps before `build_app` consumes `state`.
367 let scan_deps = match (state.scanner.clone(), state.s3.clone()) {
368 (Some(pipeline), Some(s3)) => Some(ScanDeps {
369 s3,
370 pipeline,
371 semaphore: state.scan_semaphore.clone(),
372 }),
373 _ => None,
374 };
375
376 let app = build_app(state, session_layer);
377 let client = TestClient::new(app);
378
379 // Extract mock_stripe: if the stripe_client is a MockPaymentProvider,
380 // we stored the Arc in BuildOptions.stripe_client. We can't downcast the
381 // trait object, so with_mocks() stores the mock ref separately. For the
382 // general build path, mock_stripe is None.
383 let mock_stripe = None; // Set by with_mocks() post-build via direct field access
384
385 let build_ms = t0.elapsed().as_millis();
386 if build_ms > 1000 {
387 eprintln!("[test-harness] SLOW harness build: {}ms", build_ms);
388 }
389
390 TestHarness {
391 client,
392 db: pool,
393 storage,
394 mock_email: mock_email_ref,
395 mock_stripe,
396 scan_deps,
397 _test_db: test_db,
398 }
399 }
400
401 /// Sign up a new user via POST /join. Returns the user's ID.
402 pub async fn signup(&mut self, username: &str, email: &str, password: &str) -> UserId {
403 // Fetch a page first to establish session + CSRF
404 self.client.fetch_csrf_token().await;
405
406 let body = format!(
407 "username={}&email={}&password={}",
408 urlencoding::encode(username),
409 urlencoding::encode(email),
410 urlencoding::encode(password),
411 );
412
413 let resp = self.client.post_form("/join/step/account", &body).await;
414 assert!(
415 resp.status.is_success() || resp.status.is_redirection(),
416 "Signup failed with status {}: {}",
417 resp.status,
418 resp.text
419 );
420
421 // Login rotates the CSRF token — fetch the new one
422 self.client.fetch_csrf_token().await;
423
424 // Look up the user in the database
425 sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE username = $1")
426 .bind(username)
427 .fetch_one(&self.db)
428 .await
429 .expect("User not found after signup")
430 }
431
432 /// Grant creator permissions to a user via direct SQL.
433 pub async fn grant_creator(&self, user_id: UserId) {
434 sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1")
435 .bind(user_id)
436 .execute(&self.db)
437 .await
438 .expect("Failed to grant creator");
439 }
440
441 /// Trust a user for uploads via direct SQL.
442 pub async fn trust_user(&self, user_id: UserId) {
443 sqlx::query("UPDATE users SET upload_trusted = true WHERE id = $1")
444 .bind(user_id)
445 .execute(&self.db)
446 .await
447 .expect("Failed to trust user");
448 }
449
450 /// Give a user an active creator tier subscription via direct SQL.
451 /// Also syncs the denormalized `creator_tier` column on the users table.
452 pub async fn grant_tier(&self, user_id: UserId, tier: &str) {
453 sqlx::query(
454 r#"INSERT INTO creator_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, tier, status)
455 VALUES ($1, 'sub_test_' || $1::text, 'cus_test_' || $1::text, $2, 'active')
456 ON CONFLICT (user_id) DO UPDATE SET tier = $2, status = 'active'"#,
457 )
458 .bind(user_id)
459 .bind(tier)
460 .execute(&self.db)
461 .await
462 .expect("Failed to grant tier");
463
464 sqlx::query("UPDATE users SET creator_tier = $2 WHERE id = $1")
465 .bind(user_id)
466 .bind(tier)
467 .execute(&self.db)
468 .await
469 .expect("Failed to sync creator_tier");
470 }
471
472 /// Suspend a user via direct SQL.
473 #[allow(dead_code)]
474 pub async fn suspend_user(&self, user_id: UserId) {
475 sqlx::query("UPDATE users SET suspended_at = NOW(), suspension_reason = 'test suspension' WHERE id = $1")
476 .bind(user_id)
477 .execute(&self.db)
478 .await
479 .expect("Failed to suspend user");
480 }
481
482 /// POST a single login attempt and return the response. Refreshes the
483 /// CSRF token first so the new Manual-posture `/login` (Phase 2) accepts
484 /// the form even when a previous `/logout` invalidated the cached token.
485 /// Use this for negative-path login tests (lockout, suspended, wrong
486 /// password) that need to inspect the response rather than asserting
487 /// success like `login()` does.
488 pub async fn failed_login_attempt(&mut self, login: &str, password: &str) -> client::TestResponse {
489 self.client.fetch_csrf_token().await;
490 let body = format!(
491 "login={}&password={}",
492 urlencoding::encode(login),
493 urlencoding::encode(password),
494 );
495 self.client.post_form("/login", &body).await
496 }
497
498 /// Synchronously drain queued scan jobs by running the worker loop in-
499 /// process until the queue is empty. Mirrors the production worker pool
500 /// without spawning a background task — integration tests call this
501 /// between upload-confirm and any assertion on `scan_status`.
502 pub async fn drain_scan_jobs(&self) {
503 let Some(deps) = &self.scan_deps else { return };
504 let ctx = makenotwork::scanning::worker::WorkerContext {
505 db: self.db.clone(),
506 s3: deps.s3.clone(),
507 pipeline: deps.pipeline.clone(),
508 scan_semaphore: deps.semaphore.clone(),
509 wam: None,
510 };
511 // Hard cap to avoid an infinite loop if a job re-enqueues itself.
512 for _ in 0..256 {
513 match makenotwork::scanning::worker::process_next_for_test(&ctx).await {
514 Ok(true) => continue,
515 Ok(false) => return,
516 Err(e) => panic!("scan worker drain failed: {e}"),
517 }
518 }
519 panic!("drain_scan_jobs did not terminate within 256 iterations");
520 }
521
522 /// Log in as an existing user via POST /login. The client's session
523 /// cookies are updated automatically.
524 pub async fn login(&mut self, login: &str, password: &str) {
525 // Fetch CSRF token first
526 self.client.fetch_csrf_token().await;
527
528 let body = format!(
529 "login={}&password={}",
530 urlencoding::encode(login),
531 urlencoding::encode(password),
532 );
533
534 let resp = self.client.post_form("/login", &body).await;
535 assert!(
536 resp.status.is_success() || resp.status.is_redirection(),
537 "Login failed with status {}: {}",
538 resp.status,
539 resp.text
540 );
541
542 // Login rotates the CSRF token — fetch the new one
543 self.client.fetch_csrf_token().await;
544 }
545
546 /// Create a test creator: signup, grant creator access, re-login.
547 /// Uses password "password123" and email "{username}@test.com".
548 pub async fn create_creator(&mut self, username: &str) -> UserId {
549 let user_id = self.signup(username, &format!("{}@test.com", username), "password123").await;
550 self.grant_creator(user_id).await;
551 self.client.post_form("/logout", "").await;
552 self.login(username, "password123").await;
553 user_id
554 }
555
556 /// Create a test creator with a project and one item. Creator is logged in afterward.
557 /// Project slug: "{username}-proj". Returns all created IDs.
558 pub async fn create_creator_with_item(
559 &mut self,
560 username: &str,
561 item_type: &str,
562 price_cents: i64,
563 ) -> CreatorSetup {
564 let user_id = self.create_creator(username).await;
565
566 let slug = format!("{}-proj", username);
567 let resp = self
568 .client
569 .post_form("/api/projects", &format!("slug={}&title=Test+Project", slug))
570 .await;
571 assert!(resp.status.is_success(), "Create project failed: {}", resp.text);
572 let project: serde_json::Value = resp.json();
573 let project_id = project["id"].as_str().unwrap().to_string();
574
575 let resp = self
576 .client
577 .post_form(
578 &format!("/api/projects/{}/items", project_id),
579 &format!("title=Test+Item&item_type={}&price_cents={}", item_type, price_cents),
580 )
581 .await;
582 assert!(resp.status.is_success(), "Create item failed: {}", resp.text);
583 let item: serde_json::Value = resp.json();
584 let item_id = item["id"].as_str().unwrap().to_string();
585
586 CreatorSetup { user_id, project_id, item_id, slug }
587 }
588
589 /// Connect a user's Stripe account via direct SQL.
590 /// Sets stripe_account_id, stripe_charges_enabled, and stripe_onboarding_complete.
591 /// Use after `create_creator()` for tests that need a Stripe-connected seller.
592 pub async fn connect_stripe(&self, user_id: UserId, account_id: &str) {
593 sqlx::query(
594 "UPDATE users SET stripe_account_id = $2, stripe_charges_enabled = true, \
595 stripe_onboarding_complete = true, stripe_payouts_enabled = true WHERE id = $1",
596 )
597 .bind(user_id)
598 .bind(account_id)
599 .execute(&self.db)
600 .await
601 .expect("Failed to connect Stripe");
602 }
603
604 /// Create a test creator with Stripe connected. Shorthand for `create_creator` + `grant_tier` + `connect_stripe`.
605 /// Returns the user ID. Creator is logged in afterward.
606 pub async fn create_creator_with_stripe(&mut self, username: &str) -> UserId {
607 let user_id = self.create_creator(username).await;
608 self.grant_tier(user_id, "small_files").await;
609 self.connect_stripe(user_id, &format!("acct_mock_{}", username)).await;
610 user_id
611 }
612
613 /// Batch-create buyer accounts. Returns a Vec of user IDs.
614 /// Each buyer gets username "buyer{n}", email "buyer{n}@test.com", password "password123".
615 /// The last buyer is left logged in.
616 pub async fn create_buyers(&mut self, count: usize) -> Vec<UserId> {
617 let mut ids = Vec::with_capacity(count);
618 for i in 0..count {
619 let username = format!("buyer{}", i);
620 let id = self.signup(&username, &format!("{}@test.com", username), "password123").await;
621 ids.push(id);
622 }
623 ids
624 }
625
626 /// Publish both a project and an item.
627 pub async fn publish_project_and_item(&mut self, project_id: &str, item_id: &str) {
628 self.client
629 .put_json(
630 &format!("/api/projects/{}", project_id),
631 r#"{"is_public": true}"#,
632 )
633 .await;
634 self.client
635 .put_form(&format!("/api/items/{}", item_id), "is_public=true")
636 .await;
637 }
638 }
639