Skip to main content

max / multithreaded

8.5 KB · 279 lines History Blame Raw
1 //! Test harness for in-process integration tests.
2
3 pub mod client;
4 pub mod db;
5
6 use multithreaded::{csrf, routes, AppState, config::Config};
7 use sqlx::PgPool;
8 use tower_sessions::cookie::SameSite;
9 use tower_sessions::{Expiry, SessionManagerLayer};
10 use tower_sessions_sqlx_store::PostgresStore;
11 use uuid::Uuid;
12
13 use self::client::TestClient;
14 use self::db::TestDb;
15
16 pub struct TestHarness {
17 pub client: TestClient,
18 pub db: PgPool,
19 _test_db: TestDb,
20 }
21
22 impl TestHarness {
23 pub async fn new() -> Self {
24 let test_db = TestDb::new().await;
25 let pool = test_db.pool.clone();
26
27 let session_store = PostgresStore::new(pool.clone());
28 session_store
29 .migrate()
30 .await
31 .expect("Failed to migrate session store");
32
33 let session_layer = SessionManagerLayer::new(session_store)
34 .with_secure(false)
35 .with_same_site(SameSite::Lax)
36 .with_expiry(Expiry::OnInactivity(
37 tower_sessions::cookie::time::Duration::days(1),
38 ));
39
40 let config = Config {
41 mnw_base_url: "http://127.0.0.1:9999".into(),
42 oauth_client_id: "test-client-id".to_string(),
43 oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(),
44 platform_admin_id: None,
45 cookie_secure: false,
46 s3: None,
47 internal_shared_secret: None,
48 };
49
50 let state = AppState {
51 db: pool.clone(),
52 config,
53 http: reqwest::Client::new(),
54 preview_http: multithreaded::link_preview::build_preview_client(),
55 s3: None,
56 };
57
58 // Build the app with a /_test/login route for setting sessions without OAuth
59 let test_login = axum::Router::new()
60 .route("/_test/login", axum::routing::post(test_login_handler))
61 .with_state(state.clone());
62
63 let app = routes::forum_routes(state)
64 .merge(test_login)
65 .layer(axum::middleware::from_fn(csrf::csrf_middleware))
66 .layer(session_layer);
67
68 let client = TestClient::new(app);
69
70 TestHarness {
71 client,
72 db: pool,
73 _test_db: test_db,
74 }
75 }
76
77 /// Log in as a user by username. Creates the user if needed. Returns the user's UUID.
78 pub async fn login_as(&mut self, username: &str) -> Uuid {
79 let user_id = Uuid::new_v4();
80
81 // Insert user into the database
82 sqlx::query(
83 "INSERT INTO users (mnw_account_id, username, display_name)
84 VALUES ($1, $2, $3)
85 ON CONFLICT (mnw_account_id) DO NOTHING",
86 )
87 .bind(user_id)
88 .bind(username)
89 .bind(username)
90 .execute(&self.db)
91 .await
92 .expect("Failed to insert test user");
93
94 // GET a page to establish session + CSRF token
95 self.client.get("/").await;
96
97 // POST to /_test/login to set session (exempt from CSRF)
98 let body = serde_json::json!({
99 "user_id": user_id.to_string(),
100 "username": username,
101 });
102 self.client
103 .post_json("/_test/login", &body.to_string())
104 .await;
105
106 user_id
107 }
108
109 /// Create a community via direct SQL. Returns the community ID.
110 pub async fn create_community(&self, name: &str, slug: &str) -> Uuid {
111 sqlx::query_scalar(
112 "INSERT INTO communities (name, slug)
113 VALUES ($1, $2)
114 ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
115 RETURNING id",
116 )
117 .bind(name)
118 .bind(slug)
119 .fetch_one(&self.db)
120 .await
121 .expect("Failed to create community")
122 }
123
124 /// Create a category via direct SQL. Returns the category ID.
125 pub async fn create_category(
126 &self,
127 community_id: Uuid,
128 name: &str,
129 slug: &str,
130 ) -> Uuid {
131 sqlx::query_scalar(
132 "INSERT INTO categories (community_id, name, slug, sort_order)
133 VALUES ($1, $2, $3, 0)
134 ON CONFLICT (community_id, slug) DO UPDATE SET name = EXCLUDED.name
135 RETURNING id",
136 )
137 .bind(community_id)
138 .bind(name)
139 .bind(slug)
140 .fetch_one(&self.db)
141 .await
142 .expect("Failed to create category")
143 }
144
145 /// Add a membership via direct SQL.
146 pub async fn add_membership(&self, user_id: Uuid, community_id: Uuid, role: &str) {
147 sqlx::query(
148 "INSERT INTO memberships (user_id, community_id, role)
149 VALUES ($1, $2, $3)
150 ON CONFLICT (user_id, community_id) DO UPDATE SET role = $3",
151 )
152 .bind(user_id)
153 .bind(community_id)
154 .bind(role)
155 .execute(&self.db)
156 .await
157 .expect("Failed to add membership");
158 }
159
160 /// Create a harness with a specific platform admin user ID.
161 pub async fn new_with_admin(admin_id: Uuid) -> Self {
162 let harness = Self::new().await;
163 // Rebuild with admin config — we need to reconstruct the app
164 // because Config is cloned into AppState.
165 drop(harness);
166
167 let test_db = TestDb::new().await;
168 let pool = test_db.pool.clone();
169
170 let session_store = PostgresStore::new(pool.clone());
171 session_store
172 .migrate()
173 .await
174 .expect("Failed to migrate session store");
175
176 let session_layer = SessionManagerLayer::new(session_store)
177 .with_secure(false)
178 .with_same_site(SameSite::Lax)
179 .with_expiry(Expiry::OnInactivity(
180 tower_sessions::cookie::time::Duration::days(1),
181 ));
182
183 let config = Config {
184 mnw_base_url: "http://127.0.0.1:9999".into(),
185 oauth_client_id: "test-client-id".to_string(),
186 oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(),
187 platform_admin_id: Some(admin_id),
188 cookie_secure: false,
189 s3: None,
190 internal_shared_secret: None,
191 };
192
193 let state = AppState {
194 db: pool.clone(),
195 config,
196 http: reqwest::Client::new(),
197 preview_http: multithreaded::link_preview::build_preview_client(),
198 s3: None,
199 };
200
201 let test_login = axum::Router::new()
202 .route("/_test/login", axum::routing::post(test_login_handler))
203 .with_state(state.clone());
204
205 let app = routes::forum_routes(state)
206 .merge(test_login)
207 .layer(axum::middleware::from_fn(csrf::csrf_middleware))
208 .layer(session_layer);
209
210 let client = TestClient::new(app);
211
212 TestHarness {
213 client,
214 db: pool,
215 _test_db: test_db,
216 }
217 }
218
219 /// Ban a user in a community via direct SQL.
220 pub async fn ban_user(&self, community_id: Uuid, user_id: Uuid, banned_by: Uuid, ban_type: &str) {
221 sqlx::query(
222 "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type)
223 VALUES ($1, $2, $3, $4)
224 ON CONFLICT (community_id, user_id, ban_type) DO NOTHING",
225 )
226 .bind(community_id)
227 .bind(user_id)
228 .bind(banned_by)
229 .bind(ban_type)
230 .execute(&self.db)
231 .await
232 .expect("Failed to ban user");
233 }
234
235 /// Create a thread with an initial post via direct SQL. Returns thread ID.
236 pub async fn create_thread_with_post(
237 &self,
238 category_id: Uuid,
239 author_id: Uuid,
240 title: &str,
241 body: &str,
242 ) -> Uuid {
243 let thread_id = mt_db::mutations::create_thread(&self.db, category_id, author_id, title)
244 .await
245 .expect("Failed to create thread");
246
247 mt_db::mutations::create_post(
248 &self.db,
249 thread_id,
250 author_id,
251 body,
252 &format!("<p>{}</p>", body),
253 )
254 .await
255 .expect("Failed to create post");
256
257 thread_id
258 }
259 }
260
261 /// Handler for `POST /_test/login` — sets session keys without OAuth.
262 async fn test_login_handler(
263 session: tower_sessions::Session,
264 axum::Json(payload): axum::Json<serde_json::Value>,
265 ) -> axum::http::StatusCode {
266 let user_id = payload["user_id"]
267 .as_str()
268 .and_then(|s| Uuid::parse_str(s).ok())
269 .expect("user_id required");
270 let username = payload["username"]
271 .as_str()
272 .expect("username required");
273
274 let _ = session.insert("user_id", user_id).await;
275 let _ = session.insert("username", username).await;
276
277 axum::http::StatusCode::OK
278 }
279