| 1 |
|
| 2 |
|
| 3 |
pub mod client; |
| 4 |
pub mod db; |
| 5 |
pub mod email; |
| 6 |
pub mod storage; |
| 7 |
pub mod stripe; |
| 8 |
|
| 9 |
|
| 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 |
|
| 35 |
|
| 36 |
|
| 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 |
|
| 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 |
|
| 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 |
pub cdn_base_url: Option<String>, |
| 79 |
|
| 80 |
|
| 81 |
pub access_gate: makenotwork::config::AccessGate, |
| 82 |
|
| 83 |
pub sso: Option<makenotwork::config::SsoConfig>, |
| 84 |
} |
| 85 |
|
| 86 |
|
| 87 |
#[allow(dead_code)] |
| 88 |
pub struct TestHarness { |
| 89 |
pub client: TestClient, |
| 90 |
pub db: PgPool, |
| 91 |
pub storage: Option<Arc<InMemoryStorage>>, |
| 92 |
|
| 93 |
pub mock_email: Option<Arc<email::MockEmailTransport>>, |
| 94 |
|
| 95 |
pub mock_stripe: Option<Arc<stripe::MockPaymentProvider>>, |
| 96 |
|
| 97 |
|
| 98 |
scan_deps: Option<ScanDeps>, |
| 99 |
_test_db: TestDb, |
| 100 |
} |
| 101 |
|
| 102 |
struct ScanDeps { |
| 103 |
s3: Arc<dyn makenotwork::storage::StorageBackend>, |
| 104 |
pipeline: Arc<ScanPipeline>, |
| 105 |
semaphore: Arc<tokio::sync::Semaphore>, |
| 106 |
} |
| 107 |
|
| 108 |
impl TestHarness { |
| 109 |
|
| 110 |
pub async fn new() -> Self { |
| 111 |
Self::build(BuildOptions::default()).await |
| 112 |
} |
| 113 |
|
| 114 |
|
| 115 |
#[allow(dead_code)] |
| 116 |
pub async fn with_storage() -> Self { |
| 117 |
let mem = Arc::new(InMemoryStorage::new()); |
| 118 |
Self::build(BuildOptions { storage: Some(mem), ..Default::default() }).await |
| 119 |
} |
| 120 |
|
| 121 |
|
| 122 |
#[allow(dead_code)] |
| 123 |
pub async fn with_synckit_storage() -> Self { |
| 124 |
let mem = Arc::new(InMemoryStorage::new()); |
| 125 |
Self::build(BuildOptions { synckit_storage: Some(mem), ..Default::default() }).await |
| 126 |
} |
| 127 |
|
| 128 |
|
| 129 |
#[allow(dead_code)] |
| 130 |
pub async fn with_storage_and_scanner() -> Self { |
| 131 |
let mem = Arc::new(InMemoryStorage::new()); |
| 132 |
let scanner = Self::no_op_scanner(); |
| 133 |
Self::build(BuildOptions { |
| 134 |
storage: Some(mem), |
| 135 |
scanner: Some(Arc::new(scanner)), |
| 136 |
..Default::default() |
| 137 |
}).await |
| 138 |
} |
| 139 |
|
| 140 |
|
| 141 |
|
| 142 |
#[allow(dead_code)] |
| 143 |
pub async fn with_admin_storage_and_scanner() -> (Self, UserId) { |
| 144 |
let test_db = TestDb::new().await; |
| 145 |
let pool = test_db.pool.clone(); |
| 146 |
let admin_id = Self::insert_admin_user(&pool).await; |
| 147 |
|
| 148 |
let mem = Arc::new(InMemoryStorage::new()); |
| 149 |
let scanner = Self::no_op_scanner(); |
| 150 |
let harness = Self::build(BuildOptions { |
| 151 |
storage: Some(mem), |
| 152 |
scanner: Some(Arc::new(scanner)), |
| 153 |
admin_user_id: Some(admin_id), |
| 154 |
existing_db: Some(test_db), |
| 155 |
..Default::default() |
| 156 |
}).await; |
| 157 |
(harness, admin_id) |
| 158 |
} |
| 159 |
|
| 160 |
|
| 161 |
#[allow(dead_code)] |
| 162 |
pub async fn with_stripe() -> Self { |
| 163 |
let stripe_config = StripeConfig { |
| 164 |
secret_key: "sk_test_fake_key_for_testing".to_string(), |
| 165 |
webhook_secret: vec![stripe::TEST_WEBHOOK_SECRET.to_string()], |
| 166 |
webhook_secret_v2: Some(stripe::TEST_WEBHOOK_SECRET_V2.to_string()), |
| 167 |
}; |
| 168 |
let stripe_client: Arc<dyn PaymentProvider> = Arc::new(StripeClient::new(&stripe_config)); |
| 169 |
Self::build(BuildOptions { |
| 170 |
stripe_client: Some(stripe_client), |
| 171 |
..Default::default() |
| 172 |
}).await |
| 173 |
} |
| 174 |
|
| 175 |
|
| 176 |
|
| 177 |
#[allow(dead_code)] |
| 178 |
pub async fn with_mocks() -> Self { |
| 179 |
let mock_stripe = Arc::new(stripe::MockPaymentProvider::new()); |
| 180 |
let mock_email = Arc::new(email::MockEmailTransport::new()); |
| 181 |
let mem = Arc::new(InMemoryStorage::new()); |
| 182 |
let mut harness = Self::build(BuildOptions { |
| 183 |
storage: Some(mem), |
| 184 |
stripe_client: Some(mock_stripe.clone() as Arc<dyn PaymentProvider>), |
| 185 |
mock_email: Some(mock_email), |
| 186 |
..Default::default() |
| 187 |
}).await; |
| 188 |
harness.mock_stripe = Some(mock_stripe); |
| 189 |
harness |
| 190 |
} |
| 191 |
|
| 192 |
|
| 193 |
#[allow(dead_code)] |
| 194 |
pub async fn with_admin() -> (Self, UserId) { |
| 195 |
let test_db = TestDb::new().await; |
| 196 |
let pool = test_db.pool.clone(); |
| 197 |
let admin_id = Self::insert_admin_user(&pool).await; |
| 198 |
|
| 199 |
let harness = Self::build(BuildOptions { |
| 200 |
admin_user_id: Some(admin_id), |
| 201 |
existing_db: Some(test_db), |
| 202 |
..Default::default() |
| 203 |
}).await; |
| 204 |
(harness, admin_id) |
| 205 |
} |
| 206 |
|
| 207 |
|
| 208 |
#[allow(dead_code)] |
| 209 |
pub async fn with_postmark() -> Self { |
| 210 |
Self::build(BuildOptions { |
| 211 |
postmark_webhook_token: Some("test-postmark-token".to_string()), |
| 212 |
postmark_broadcast_webhook_token: Some("test-broadcast-token".to_string()), |
| 213 |
..Default::default() |
| 214 |
}).await |
| 215 |
} |
| 216 |
|
| 217 |
|
| 218 |
#[allow(dead_code)] |
| 219 |
pub async fn with_git_repos(path: String) -> Self { |
| 220 |
Self::build(BuildOptions { |
| 221 |
git_repos_path: Some(path), |
| 222 |
..Default::default() |
| 223 |
}).await |
| 224 |
} |
| 225 |
|
| 226 |
|
| 227 |
async fn insert_admin_user(pool: &PgPool) -> UserId { |
| 228 |
let password_hash = makenotwork::auth::hash_password("password123") |
| 229 |
.expect("hash_password for admin"); |
| 230 |
sqlx::query_scalar( |
| 231 |
"INSERT INTO users (username, email, password_hash, email_verified) |
| 232 |
VALUES ('admin', 'admin@test.com', $1, true) |
| 233 |
RETURNING id", |
| 234 |
) |
| 235 |
.bind(&password_hash) |
| 236 |
.fetch_one(pool) |
| 237 |
.await |
| 238 |
.expect("Failed to insert admin user") |
| 239 |
} |
| 240 |
|
| 241 |
|
| 242 |
fn no_op_scanner() -> ScanPipeline { |
| 243 |
let scan_config = ScanConfig { |
| 244 |
clamav_socket: None, |
| 245 |
yara_rules_dir: "/nonexistent".to_string(), |
| 246 |
malwarebazaar_enabled: false, |
| 247 |
urlhaus_enabled: false, |
| 248 |
abuse_ch_auth_key: None, |
| 249 |
metadefender_api_key: None, |
| 250 |
yara_min_rule_files: 0, |
| 251 |
}; |
| 252 |
ScanPipeline::new(&scan_config).expect("ScanPipeline::new with no-op config") |
| 253 |
} |
| 254 |
|
| 255 |
|
| 256 |
pub async fn build(opts: BuildOptions) -> Self { |
| 257 |
let t0 = std::time::Instant::now(); |
| 258 |
let test_db = match opts.existing_db { |
| 259 |
Some(db) => db, |
| 260 |
None => TestDb::new().await, |
| 261 |
}; |
| 262 |
let pool = test_db.pool.clone(); |
| 263 |
|
| 264 |
|
| 265 |
let session_store = PostgresStore::new(pool.clone()); |
| 266 |
if !test_db.session_migrated { |
| 267 |
session_store |
| 268 |
.migrate() |
| 269 |
.await |
| 270 |
.expect("Failed to migrate session store"); |
| 271 |
} |
| 272 |
|
| 273 |
let session_layer = SessionManagerLayer::new(session_store) |
| 274 |
.with_secure(false) |
| 275 |
.with_same_site(SameSite::Lax) |
| 276 |
.with_expiry(Expiry::OnInactivity(CookieDuration::days(1))); |
| 277 |
|
| 278 |
|
| 279 |
let config = Config { |
| 280 |
host: "127.0.0.1".parse().unwrap(), |
| 281 |
port: 0, |
| 282 |
database_url: String::new(), |
| 283 |
host_url: std::sync::Arc::from("http://localhost:3000"), |
| 284 |
signing_secret: "test-signing-secret-for-integration-tests".to_string(), |
| 285 |
storage: None, |
| 286 |
synckit_storage: None, |
| 287 |
stripe: None, |
| 288 |
admin_user_id: opts.admin_user_id, |
| 289 |
synckit_jwt_secret: Some("test-synckit-jwt-secret".to_string()), |
| 290 |
scan: None, |
| 291 |
git_repos_path: opts.git_repos_path, |
| 292 |
postmark_webhook_token: opts.postmark_webhook_token, |
| 293 |
postmark_broadcast_webhook_token: opts.postmark_broadcast_webhook_token, |
| 294 |
git_ssh_host: None, |
| 295 |
mt_base_url: None, |
| 296 |
fan_plus_price_id: None, |
| 297 |
creator_tier_prices: std::collections::HashMap::new(), |
| 298 |
creator_tier_annual_prices: std::collections::HashMap::new(), |
| 299 |
creator_tier_founder_prices: std::collections::HashMap::new(), |
| 300 |
creator_tier_founder_annual_prices: std::collections::HashMap::new(), |
| 301 |
creator_founder_window_open: false, |
| 302 |
build_trigger_token: opts.build_trigger_token, |
| 303 |
build_host_linux: None, |
| 304 |
build_host_darwin: None, |
| 305 |
cdn_base_url: opts.cdn_base_url.clone(), |
| 306 |
postmark_inbound_webhook_token: opts.postmark_inbound_webhook_token, |
| 307 |
internal_shared_secret: opts.internal_shared_secret.clone(), |
| 308 |
cli_service_token: opts.cli_service_token.clone(), |
| 309 |
wam_url: None, |
| 310 |
access_gate: opts.access_gate, |
| 311 |
sso: opts.sso.clone(), |
| 312 |
}; |
| 313 |
|
| 314 |
let mock_email_ref = opts.mock_email.clone(); |
| 315 |
let email = if let Some(ref mock) = opts.mock_email { |
| 316 |
EmailClient::with_transport(mock.clone() as Arc<dyn makenotwork::email::EmailTransport>) |
| 317 |
} else { |
| 318 |
EmailClient::new(EmailConfig { |
| 319 |
postmark_token: None, |
| 320 |
from_address: "test@makenot.work".to_string(), |
| 321 |
from_name: "Test".to_string(), |
| 322 |
}, Some(pool.clone())) |
| 323 |
}; |
| 324 |
|
| 325 |
let rp_origin = url::Url::parse(&config.host_url).expect("test HOST_URL"); |
| 326 |
let rp_id = rp_origin.host_str().expect("test HOST_URL host").to_string(); |
| 327 |
let webauthn = Arc::new( |
| 328 |
webauthn_rs::WebauthnBuilder::new(&rp_id, &rp_origin) |
| 329 |
.expect("WebauthnBuilder") |
| 330 |
.rp_name("Test") |
| 331 |
.build() |
| 332 |
.expect("Webauthn"), |
| 333 |
); |
| 334 |
|
| 335 |
|
| 336 |
let storage = opts.storage; |
| 337 |
let s3 = storage.clone().map(|s| s as Arc<dyn makenotwork::storage::StorageBackend>); |
| 338 |
let synckit_s3 = opts.synckit_storage.map(|s| s as Arc<dyn makenotwork::storage::StorageBackend>); |
| 339 |
|
| 340 |
let state = AppState { |
| 341 |
db: pool.clone(), |
| 342 |
config, |
| 343 |
tier_prices: makenotwork::tier_prices::TierPrices::default(), |
| 344 |
cost_allocation: makenotwork::tier_prices::CostAllocation::default(), |
| 345 |
runway_config: makenotwork::tier_prices::RunwayConfig::default(), |
| 346 |
s3, |
| 347 |
synckit_s3, |
| 348 |
stripe: opts.stripe_client, |
| 349 |
email, |
| 350 |
docs: Arc::new(DocLoader::load(std::path::Path::new("."), &docengine::DocLoaderConfig { |
| 351 |
sections: vec![], |
| 352 |
link_prefix: "/docs".to_string(), |
| 353 |
unpublished_pattern: None, |
| 354 |
examples_path: None, |
| 355 |
pre_process: None, |
| 356 |
})), |
| 357 |
scanner: opts.scanner, |
| 358 |
webauthn, |
| 359 |
syntax: None, |
| 360 |
started_at: chrono::Utc::now(), |
| 361 |
start_instant: Instant::now(), |
| 362 |
session_cache: Arc::new(dashmap::DashMap::new()), |
| 363 |
mt_client: opts.mt_base_url.zip(opts.internal_shared_secret).map( |
| 364 |
|(url, secret)| makenotwork::mt_client::MtClient::new(url, secret), |
| 365 |
), |
| 366 |
wam: None, |
| 367 |
domain_cache: Arc::new(dashmap::DashMap::new()), |
| 368 |
restart_at: Arc::new(std::sync::atomic::AtomicI64::new(0)), |
| 369 |
sync_notify: Arc::new(dashmap::DashMap::new()), |
| 370 |
sse_connections: Arc::new(dashmap::DashMap::new()), |
| 371 |
metrics_handle: None, |
| 372 |
scan_semaphore: Arc::new(tokio::sync::Semaphore::new(4)), |
| 373 |
caddy_ask_semaphore: Arc::new(tokio::sync::Semaphore::new(8)), |
| 374 |
page_view_tx: makenotwork::db::page_views::spawn_batcher(pool.clone()), |
| 375 |
bg: makenotwork::background::spawn_pool(), |
| 376 |
}; |
| 377 |
|
| 378 |
|
| 379 |
let scan_deps = match (state.scanner.clone(), state.s3.clone()) { |
| 380 |
(Some(pipeline), Some(s3)) => Some(ScanDeps { |
| 381 |
s3, |
| 382 |
pipeline, |
| 383 |
semaphore: state.scan_semaphore.clone(), |
| 384 |
}), |
| 385 |
_ => None, |
| 386 |
}; |
| 387 |
|
| 388 |
let app = build_app(state, session_layer); |
| 389 |
let client = TestClient::new(app); |
| 390 |
|
| 391 |
|
| 392 |
|
| 393 |
|
| 394 |
|
| 395 |
let mock_stripe = None; |
| 396 |
|
| 397 |
let build_ms = t0.elapsed().as_millis(); |
| 398 |
if build_ms > 1000 { |
| 399 |
eprintln!("[test-harness] SLOW harness build: {}ms", build_ms); |
| 400 |
} |
| 401 |
|
| 402 |
TestHarness { |
| 403 |
client, |
| 404 |
db: pool, |
| 405 |
storage, |
| 406 |
mock_email: mock_email_ref, |
| 407 |
mock_stripe, |
| 408 |
scan_deps, |
| 409 |
_test_db: test_db, |
| 410 |
} |
| 411 |
} |
| 412 |
|
| 413 |
|
| 414 |
pub async fn signup(&mut self, username: &str, email: &str, password: &str) -> UserId { |
| 415 |
|
| 416 |
self.client.fetch_csrf_token().await; |
| 417 |
|
| 418 |
let body = format!( |
| 419 |
"username={}&email={}&password={}", |
| 420 |
urlencoding::encode(username), |
| 421 |
urlencoding::encode(email), |
| 422 |
urlencoding::encode(password), |
| 423 |
); |
| 424 |
|
| 425 |
let resp = self.client.post_form("/join/step/account", &body).await; |
| 426 |
assert!( |
| 427 |
resp.status.is_success() || resp.status.is_redirection(), |
| 428 |
"Signup failed with status {}: {}", |
| 429 |
resp.status, |
| 430 |
resp.text |
| 431 |
); |
| 432 |
|
| 433 |
|
| 434 |
self.client.fetch_csrf_token().await; |
| 435 |
|
| 436 |
|
| 437 |
sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE username = $1") |
| 438 |
.bind(username) |
| 439 |
.fetch_one(&self.db) |
| 440 |
.await |
| 441 |
.expect("User not found after signup") |
| 442 |
} |
| 443 |
|
| 444 |
|
| 445 |
pub async fn grant_creator(&self, user_id: UserId) { |
| 446 |
sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1") |
| 447 |
.bind(user_id) |
| 448 |
.execute(&self.db) |
| 449 |
.await |
| 450 |
.expect("Failed to grant creator"); |
| 451 |
} |
| 452 |
|
| 453 |
|
| 454 |
pub async fn trust_user(&self, user_id: UserId) { |
| 455 |
sqlx::query("UPDATE users SET upload_trusted = true WHERE id = $1") |
| 456 |
.bind(user_id) |
| 457 |
.execute(&self.db) |
| 458 |
.await |
| 459 |
.expect("Failed to trust user"); |
| 460 |
} |
| 461 |
|
| 462 |
|
| 463 |
|
| 464 |
pub async fn grant_tier(&self, user_id: UserId, tier: &str) { |
| 465 |
sqlx::query( |
| 466 |
r#"INSERT INTO creator_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, tier, status) |
| 467 |
VALUES ($1, 'sub_test_' || $1::text, 'cus_test_' || $1::text, $2, 'active') |
| 468 |
ON CONFLICT (user_id) DO UPDATE SET tier = $2, status = 'active'"#, |
| 469 |
) |
| 470 |
.bind(user_id) |
| 471 |
.bind(tier) |
| 472 |
.execute(&self.db) |
| 473 |
.await |
| 474 |
.expect("Failed to grant tier"); |
| 475 |
|
| 476 |
sqlx::query("UPDATE users SET creator_tier = $2 WHERE id = $1") |
| 477 |
.bind(user_id) |
| 478 |
.bind(tier) |
| 479 |
.execute(&self.db) |
| 480 |
.await |
| 481 |
.expect("Failed to sync creator_tier"); |
| 482 |
} |
| 483 |
|
| 484 |
|
| 485 |
#[allow(dead_code)] |
| 486 |
pub async fn suspend_user(&self, user_id: UserId) { |
| 487 |
sqlx::query("UPDATE users SET suspended_at = NOW(), suspension_reason = 'test suspension' WHERE id = $1") |
| 488 |
.bind(user_id) |
| 489 |
.execute(&self.db) |
| 490 |
.await |
| 491 |
.expect("Failed to suspend user"); |
| 492 |
} |
| 493 |
|
| 494 |
|
| 495 |
|
| 496 |
|
| 497 |
|
| 498 |
|
| 499 |
|
| 500 |
pub async fn failed_login_attempt(&mut self, login: &str, password: &str) -> client::TestResponse { |
| 501 |
self.client.fetch_csrf_token().await; |
| 502 |
let body = format!( |
| 503 |
"login={}&password={}", |
| 504 |
urlencoding::encode(login), |
| 505 |
urlencoding::encode(password), |
| 506 |
); |
| 507 |
self.client.post_form("/login", &body).await |
| 508 |
} |
| 509 |
|
| 510 |
|
| 511 |
|
| 512 |
|
| 513 |
|
| 514 |
pub async fn drain_scan_jobs(&self) { |
| 515 |
let Some(deps) = &self.scan_deps else { return }; |
| 516 |
let ctx = makenotwork::scanning::worker::WorkerContext { |
| 517 |
db: self.db.clone(), |
| 518 |
s3: deps.s3.clone(), |
| 519 |
pipeline: deps.pipeline.clone(), |
| 520 |
scan_semaphore: deps.semaphore.clone(), |
| 521 |
wam: None, |
| 522 |
}; |
| 523 |
|
| 524 |
for _ in 0..256 { |
| 525 |
match makenotwork::scanning::worker::process_next_for_test(&ctx).await { |
| 526 |
Ok(true) => continue, |
| 527 |
Ok(false) => return, |
| 528 |
Err(e) => panic!("scan worker drain failed: {e}"), |
| 529 |
} |
| 530 |
} |
| 531 |
panic!("drain_scan_jobs did not terminate within 256 iterations"); |
| 532 |
} |
| 533 |
|
| 534 |
|
| 535 |
|
| 536 |
pub async fn login(&mut self, login: &str, password: &str) { |
| 537 |
|
| 538 |
self.client.fetch_csrf_token().await; |
| 539 |
|
| 540 |
let body = format!( |
| 541 |
"login={}&password={}", |
| 542 |
urlencoding::encode(login), |
| 543 |
urlencoding::encode(password), |
| 544 |
); |
| 545 |
|
| 546 |
let resp = self.client.post_form("/login", &body).await; |
| 547 |
assert!( |
| 548 |
resp.status.is_success() || resp.status.is_redirection(), |
| 549 |
"Login failed with status {}: {}", |
| 550 |
resp.status, |
| 551 |
resp.text |
| 552 |
); |
| 553 |
|
| 554 |
|
| 555 |
self.client.fetch_csrf_token().await; |
| 556 |
} |
| 557 |
|
| 558 |
|
| 559 |
|
| 560 |
pub async fn create_creator(&mut self, username: &str) -> UserId { |
| 561 |
let user_id = self.signup(username, &format!("{}@test.com", username), "password123").await; |
| 562 |
self.grant_creator(user_id).await; |
| 563 |
self.client.post_form("/logout", "").await; |
| 564 |
self.login(username, "password123").await; |
| 565 |
user_id |
| 566 |
} |
| 567 |
|
| 568 |
|
| 569 |
|
| 570 |
pub async fn create_creator_with_item( |
| 571 |
&mut self, |
| 572 |
username: &str, |
| 573 |
item_type: &str, |
| 574 |
price_cents: i64, |
| 575 |
) -> CreatorSetup { |
| 576 |
let user_id = self.create_creator(username).await; |
| 577 |
|
| 578 |
let slug = format!("{}-proj", username); |
| 579 |
let resp = self |
| 580 |
.client |
| 581 |
.post_form("/api/projects", &format!("slug={}&title=Test+Project", slug)) |
| 582 |
.await; |
| 583 |
assert!(resp.status.is_success(), "Create project failed: {}", resp.text); |
| 584 |
let project: serde_json::Value = resp.json(); |
| 585 |
let project_id = project["id"].as_str().unwrap().to_string(); |
| 586 |
|
| 587 |
let resp = self |
| 588 |
.client |
| 589 |
.post_form( |
| 590 |
&format!("/api/projects/{}/items", project_id), |
| 591 |
&format!("title=Test+Item&item_type={}&price_cents={}", item_type, price_cents), |
| 592 |
) |
| 593 |
.await; |
| 594 |
assert!(resp.status.is_success(), "Create item failed: {}", resp.text); |
| 595 |
let item: serde_json::Value = resp.json(); |
| 596 |
let item_id = item["id"].as_str().unwrap().to_string(); |
| 597 |
|
| 598 |
CreatorSetup { user_id, project_id, item_id, slug } |
| 599 |
} |
| 600 |
|
| 601 |
|
| 602 |
|
| 603 |
|
| 604 |
pub async fn connect_stripe(&self, user_id: UserId, account_id: &str) { |
| 605 |
sqlx::query( |
| 606 |
"UPDATE users SET stripe_account_id = $2, stripe_charges_enabled = true, \ |
| 607 |
stripe_onboarding_complete = true, stripe_payouts_enabled = true WHERE id = $1", |
| 608 |
) |
| 609 |
.bind(user_id) |
| 610 |
.bind(account_id) |
| 611 |
.execute(&self.db) |
| 612 |
.await |
| 613 |
.expect("Failed to connect Stripe"); |
| 614 |
} |
| 615 |
|
| 616 |
|
| 617 |
pub async fn publish_project_and_item(&mut self, project_id: &str, item_id: &str) { |
| 618 |
self.client |
| 619 |
.put_json( |
| 620 |
&format!("/api/projects/{}", project_id), |
| 621 |
r#"{"is_public": true}"#, |
| 622 |
) |
| 623 |
.await; |
| 624 |
self.client |
| 625 |
.put_form(&format!("/api/items/{}", item_id), "is_public=true") |
| 626 |
.await; |
| 627 |
} |
| 628 |
} |
| 629 |
|