Skip to main content

max / multithreaded

Fix link preview body size cap, test and moderation improvements Link preview body could exceed MAX_BODY_SIZE because chunks were appended without clamping. Added chunk slicing to remaining capacity and proper stream exhaustion handling. Also includes flagging/moderation route fixes and test improvements from prior sessions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-23 01:24 UTC
Commit: 9d042fe68eb97385cc40039d54791fbcdd813afb
Parent: 3936225
9 files changed, +830 insertions, -36 deletions
@@ -126,8 +126,12 @@ pub async fn fetch_og_metadata(
126 126 let mut body = Vec::new();
127 127 let mut stream = resp;
128 128 while body.len() < MAX_BODY_SIZE {
129 - let chunk = stream.chunk().await.ok()??;
130 - body.extend_from_slice(&chunk);
129 + let chunk = match stream.chunk().await.ok()? {
130 + Some(c) => c,
131 + None => break,
132 + };
133 + let remaining = MAX_BODY_SIZE - body.len();
134 + body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
131 135 }
132 136
133 137 let html = String::from_utf8_lossy(&body);
@@ -61,10 +61,10 @@ pub(super) async fn flag_post_handler(
61 61
62 62 let detail = form.detail.as_deref().filter(|d| !d.trim().is_empty());
63 63
64 - if let Some(d) = detail {
65 - if d.len() > 1024 {
66 - return Err((StatusCode::UNPROCESSABLE_ENTITY, "Flag detail too long (max 1024 bytes).").into_response());
67 - }
64 + if let Some(d) = detail
65 + && d.len() > 1024
66 + {
67 + return Err((StatusCode::UNPROCESSABLE_ENTITY, "Flag detail too long (max 1024 bytes).").into_response());
68 68 }
69 69
70 70 mt_db::mutations::insert_flag(&state.db, post_id, user.user_id, &form.reason, detail)
@@ -247,10 +247,10 @@ pub(super) async fn ban_user_handler(
247 247 let expires_at = parse_duration(&form.duration);
248 248 let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty());
249 249
250 - if let Some(r) = reason {
251 - if r.len() > 1024 {
252 - return Err((StatusCode::UNPROCESSABLE_ENTITY, "Reason too long (max 1024 bytes).").into_response());
253 - }
250 + if let Some(r) = reason
251 + && r.len() > 1024
252 + {
253 + return Err((StatusCode::UNPROCESSABLE_ENTITY, "Reason too long (max 1024 bytes).").into_response());
254 254 }
255 255
256 256 mt_db::mutations::create_community_ban(
@@ -332,10 +332,10 @@ pub(super) async fn mute_user_handler(
332 332 let expires_at = parse_duration(&form.duration);
333 333 let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty());
334 334
335 - if let Some(r) = reason {
336 - if r.len() > 1024 {
337 - return Err((StatusCode::UNPROCESSABLE_ENTITY, "Reason too long (max 1024 bytes).").into_response());
338 - }
335 + if let Some(r) = reason
336 + && r.len() > 1024
337 + {
338 + return Err((StatusCode::UNPROCESSABLE_ENTITY, "Reason too long (max 1024 bytes).").into_response());
339 339 }
340 340
341 341 mt_db::mutations::create_community_ban(
M src/seed.rs +11 -10
@@ -1,5 +1,6 @@
1 1 //! Seed initial forum data for development. Run with `--seed` flag.
2 2
3 + use mt_core::types::CommunityRole;
3 4 use sqlx::PgPool;
4 5 use uuid::Uuid;
5 6
@@ -65,14 +66,14 @@ pub async fn run(pool: &PgPool) {
65 66 // ── Memberships ────────────────────────────────────────────────────
66 67
67 68 for user in &users {
68 - seed_membership(pool, user.id, music_id, "member").await;
69 + seed_membership(pool, user.id, music_id, CommunityRole::Member).await;
69 70 }
70 71 // admin owns all, maxmj is moderator of music
71 - seed_membership_upsert(pool, users[0].id, rust_id, "owner").await;
72 - seed_membership_upsert(pool, users[0].id, music_id, "owner").await;
73 - seed_membership_upsert(pool, users[0].id, selfhosted_id, "owner").await;
74 - seed_membership_upsert(pool, users[1].id, rust_id, "member").await;
75 - seed_membership_upsert(pool, users[1].id, music_id, "moderator").await;
72 + seed_membership_upsert(pool, users[0].id, rust_id, CommunityRole::Owner).await;
73 + seed_membership_upsert(pool, users[0].id, music_id, CommunityRole::Owner).await;
74 + seed_membership_upsert(pool, users[0].id, selfhosted_id, CommunityRole::Owner).await;
75 + seed_membership_upsert(pool, users[1].id, rust_id, CommunityRole::Member).await;
76 + seed_membership_upsert(pool, users[1].id, music_id, CommunityRole::Moderator).await;
76 77
77 78 // ── Rust community: a few threads (unchanged from before) ──────────
78 79
@@ -930,7 +931,7 @@ async fn seed_category(
930 931 .expect("failed to seed category")
931 932 }
932 933
933 - async fn seed_membership(pool: &PgPool, user_id: Uuid, community_id: Uuid, role: &str) {
934 + async fn seed_membership(pool: &PgPool, user_id: Uuid, community_id: Uuid, role: CommunityRole) {
934 935 sqlx::query(
935 936 "INSERT INTO memberships (user_id, community_id, role)
936 937 VALUES ($1, $2, $3)
@@ -938,13 +939,13 @@ async fn seed_membership(pool: &PgPool, user_id: Uuid, community_id: Uuid, role:
938 939 )
939 940 .bind(user_id)
940 941 .bind(community_id)
941 - .bind(role)
942 + .bind(role.as_str())
942 943 .execute(pool)
943 944 .await
944 945 .expect("failed to seed membership");
945 946 }
946 947
947 - async fn seed_membership_upsert(pool: &PgPool, user_id: Uuid, community_id: Uuid, role: &str) {
948 + async fn seed_membership_upsert(pool: &PgPool, user_id: Uuid, community_id: Uuid, role: CommunityRole) {
948 949 sqlx::query(
949 950 "INSERT INTO memberships (user_id, community_id, role)
950 951 VALUES ($1, $2, $3)
@@ -952,7 +953,7 @@ async fn seed_membership_upsert(pool: &PgPool, user_id: Uuid, community_id: Uuid
952 953 )
953 954 .bind(user_id)
954 955 .bind(community_id)
955 - .bind(role)
956 + .bind(role.as_str())
956 957 .execute(pool)
957 958 .await
958 959 .expect("failed to seed membership");
@@ -203,3 +203,125 @@ async fn admin_can_unsuspend_user(_pool: sqlx::PgPool) {
203 203 .unwrap();
204 204 assert!(!suspended);
205 205 }
206 +
207 + #[sqlx::test]
208 + async fn admin_search_finds_users(_pool: sqlx::PgPool) {
209 + let admin_id = Uuid::new_v4();
210 + let mut h = TestHarness::new_with_admin(admin_id).await;
211 +
212 + sqlx::query(
213 + "INSERT INTO users (mnw_account_id, username, display_name)
214 + VALUES ($1, 'admin', 'Admin') ON CONFLICT DO NOTHING",
215 + )
216 + .bind(admin_id)
217 + .execute(&h.db)
218 + .await
219 + .unwrap();
220 + h.client.get("/").await;
221 + let body = serde_json::json!({ "user_id": admin_id.to_string(), "username": "admin" });
222 + h.client
223 + .post_json("/_test/login", &body.to_string())
224 + .await;
225 +
226 + // Create a searchable user
227 + let target_id = Uuid::new_v4();
228 + sqlx::query(
229 + "INSERT INTO users (mnw_account_id, username, display_name)
230 + VALUES ($1, 'findableuser', 'Findable User')",
231 + )
232 + .bind(target_id)
233 + .execute(&h.db)
234 + .await
235 + .unwrap();
236 +
237 + let resp = h.client.get("/_admin?q=findableuser").await;
238 + assert_eq!(resp.status, axum::http::StatusCode::OK);
239 + assert!(
240 + resp.text.contains("findableuser"),
241 + "Search results should include matching user"
242 + );
243 + }
244 +
245 + #[sqlx::test]
246 + async fn admin_invalid_uuid_returns_400(_pool: sqlx::PgPool) {
247 + let admin_id = Uuid::new_v4();
248 + let mut h = TestHarness::new_with_admin(admin_id).await;
249 +
250 + sqlx::query(
251 + "INSERT INTO users (mnw_account_id, username, display_name)
252 + VALUES ($1, 'admin', 'Admin') ON CONFLICT DO NOTHING",
253 + )
254 + .bind(admin_id)
255 + .execute(&h.db)
256 + .await
257 + .unwrap();
258 + h.client.get("/").await;
259 + let body = serde_json::json!({ "user_id": admin_id.to_string(), "username": "admin" });
260 + h.client
261 + .post_json("/_test/login", &body.to_string())
262 + .await;
263 +
264 + let resp = h
265 + .client
266 + .post_form("/_admin/communities/not-a-uuid/suspend", "reason=test")
267 + .await;
268 + // parse_uuid returns 404 (hides admin routes from probing)
269 + assert_eq!(resp.status, axum::http::StatusCode::NOT_FOUND);
270 + }
271 +
272 + #[sqlx::test]
273 + async fn admin_suspend_creates_mod_log_entry(_pool: sqlx::PgPool) {
274 + let admin_id = Uuid::new_v4();
275 + let mut h = TestHarness::new_with_admin(admin_id).await;
276 +
277 + sqlx::query(
278 + "INSERT INTO users (mnw_account_id, username, display_name)
279 + VALUES ($1, 'admin', 'Admin') ON CONFLICT DO NOTHING",
280 + )
281 + .bind(admin_id)
282 + .execute(&h.db)
283 + .await
284 + .unwrap();
285 + h.client.get("/").await;
286 + let body = serde_json::json!({ "user_id": admin_id.to_string(), "username": "admin" });
287 + h.client
288 + .post_json("/_test/login", &body.to_string())
289 + .await;
290 +
291 + let community_id = h.create_community("Test", "test").await;
292 +
293 + h.client
294 + .post_form(
295 + &format!("/_admin/communities/{community_id}/suspend"),
296 + "reason=policy+violation",
297 + )
298 + .await;
299 +
300 + // Verify mod_log entry exists
301 + let count: i64 = sqlx::query_scalar(
302 + "SELECT COUNT(*) FROM mod_log WHERE action = 'suspend_community' AND actor_id = $1",
303 + )
304 + .bind(admin_id)
305 + .fetch_one(&h.db)
306 + .await
307 + .unwrap();
308 + assert_eq!(count, 1, "Should have a mod_log entry for suspend_community");
309 + }
310 +
311 + #[sqlx::test]
312 + async fn non_admin_post_to_suspend_returns_404(_pool: sqlx::PgPool) {
313 + let admin_id = Uuid::new_v4();
314 + let mut h = TestHarness::new_with_admin(admin_id).await;
315 + let _user = h.login_as("regular").await;
316 +
317 + let community_id = h.create_community("Test", "test").await;
318 +
319 + let resp = h
320 + .client
321 + .post_form(
322 + &format!("/_admin/communities/{community_id}/suspend"),
323 + "reason=test",
324 + )
325 + .await;
326 + assert_eq!(resp.status, axum::http::StatusCode::NOT_FOUND);
327 + }
@@ -1,4 +1,5 @@
1 1 use crate::harness::TestHarness;
2 + use axum::http::StatusCode;
2 3
3 4 #[tokio::test]
4 5 async fn unauthenticated_sees_login_link() {
@@ -47,3 +48,159 @@ async fn logout_clears_session() {
47 48 "Expected 'Login' link after logout"
48 49 );
49 50 }
51 +
52 + #[tokio::test]
53 + async fn login_redirect_includes_pkce_and_state() {
54 + let mut h = TestHarness::new().await;
55 + let resp = h.client.get("/auth/login").await;
56 +
57 + assert!(resp.status.is_redirection());
58 + let location = resp
59 + .headers
60 + .get("location")
61 + .and_then(|v| v.to_str().ok())
62 + .expect("redirect should have location header");
63 +
64 + assert!(
65 + location.contains("client_id=test-client-id"),
66 + "URL should contain client_id"
67 + );
68 + assert!(
69 + location.contains("code_challenge="),
70 + "URL should contain code_challenge"
71 + );
72 + assert!(
73 + location.contains("code_challenge_method=S256"),
74 + "URL should contain S256 method"
75 + );
76 + assert!(
77 + location.contains("state="),
78 + "URL should contain state parameter"
79 + );
80 + assert!(
81 + location.contains("response_type=code"),
82 + "URL should contain response_type=code"
83 + );
84 + assert!(
85 + location.starts_with("http://127.0.0.1:9999/oauth/authorize"),
86 + "Should redirect to MNW OAuth endpoint"
87 + );
88 + }
89 +
90 + #[tokio::test]
91 + async fn callback_without_prior_login_rejects_state() {
92 + let mut h = TestHarness::new().await;
93 + // Establish session without going through login
94 + h.client.get("/").await;
95 +
96 + // Call callback directly — session has no stored state
97 + let resp = h
98 + .client
99 + .get("/auth/callback?code=fake&state=somestate")
100 + .await;
101 +
102 + assert!(resp.status.is_redirection());
103 + let location = resp
104 + .headers
105 + .get("location")
106 + .and_then(|v| v.to_str().ok())
107 + .expect("should have location header");
108 + assert!(
109 + location.contains("error=state_mismatch"),
110 + "Should redirect with state_mismatch error, got: {}",
111 + location
112 + );
113 + }
114 +
115 + #[tokio::test]
116 + async fn callback_with_wrong_state_rejects() {
117 + let mut h = TestHarness::new().await;
118 + // Login sets state + PKCE verifier in session
119 + h.client.get("/auth/login").await;
120 +
121 + // Call callback with wrong state
122 + let resp = h
123 + .client
124 + .get("/auth/callback?code=fake&state=wrong_state_value")
125 + .await;
126 +
127 + assert!(resp.status.is_redirection());
128 + let location = resp
129 + .headers
130 + .get("location")
131 + .and_then(|v| v.to_str().ok())
132 + .expect("should have location header");
133 + assert!(
134 + location.contains("error=state_mismatch"),
135 + "Should redirect with state_mismatch error, got: {}",
136 + location
137 + );
138 + }
139 +
140 + #[tokio::test]
141 + async fn callback_with_correct_state_fails_at_token_exchange() {
142 + let mut h = TestHarness::new().await;
143 +
144 + // Login to set state in session
145 + let login_resp = h.client.get("/auth/login").await;
146 + let location = login_resp
147 + .headers
148 + .get("location")
149 + .and_then(|v| v.to_str().ok())
150 + .expect("login should redirect");
151 +
152 + // Extract state from redirect URL
153 + let state_start = location.find("state=").expect("state in URL") + 6;
154 + let state_end = location[state_start..]
155 + .find('&')
156 + .map(|i| state_start + i)
157 + .unwrap_or(location.len());
158 + let state = &location[state_start..state_end];
159 +
160 + // Call callback with correct state — will try HTTP to 127.0.0.1:9999 (no server)
161 + let resp = h
162 + .client
163 + .get(&format!("/auth/callback?code=fake&state={}", state))
164 + .await;
165 +
166 + assert!(resp.status.is_redirection());
167 + let cb_location = resp
168 + .headers
169 + .get("location")
170 + .and_then(|v| v.to_str().ok())
171 + .expect("should have location header");
172 + assert!(
173 + cb_location.contains("error=token_request_failed"),
174 + "Should fail at token exchange, got: {}",
175 + cb_location
176 + );
177 + }
178 +
179 + #[tokio::test]
180 + async fn suspended_user_sees_error_page() {
181 + let mut h = TestHarness::new().await;
182 + let user_id = h.login_as("suspendeduser").await;
183 + let comm_id = h.create_community("Test", "test").await;
184 + let _cat_id = h.create_category(comm_id, "General", "general").await;
185 + h.add_membership(user_id, comm_id, "member").await;
186 +
187 + // Verify can access community page while not suspended
188 + let resp = h.client.get("/p/test").await;
189 + assert_eq!(resp.status, StatusCode::OK);
190 +
191 + // Suspend the user
192 + sqlx::query("UPDATE users SET suspended_at = now(), suspension_reason = 'test' WHERE mnw_account_id = $1")
193 + .bind(user_id)
194 + .execute(&h.db)
195 + .await
196 + .unwrap();
197 +
198 + // Suspended user can still browse with existing session (suspension blocks new logins)
199 + // but verify the page still loads (no crash)
200 + let resp = h.client.get("/p/test").await;
201 + assert!(
202 + resp.status == StatusCode::OK || resp.status == StatusCode::FORBIDDEN,
203 + "Should either allow (existing session) or block, got: {}",
204 + resp.status
205 + );
206 + }
@@ -9,6 +9,7 @@ mod internal_api;
9 9 mod link_previews;
10 10 mod mentions;
11 11 mod moderation;
12 + mod mutations;
12 13 mod pagination;
13 14 mod permissions;
14 15 mod profiles;
@@ -0,0 +1,740 @@
1 + //! Direct tests for mt_db::mutations functions.
2 + //!
3 + //! These tests exercise database mutation functions that lack integration
4 + //! test coverage through route-level tests.
5 +
6 + use crate::harness::TestHarness;
7 + use mt_core::types::BanType;
8 + use uuid::Uuid;
9 +
10 + // ============================================================================
11 + // Ban cleanup
12 + // ============================================================================
13 +
14 + #[tokio::test]
15 + async fn cleanup_expired_bans_removes_expired() {
16 + let h = TestHarness::new().await;
17 + let comm_id = h.create_community("Test", "test").await;
18 +
19 + let user1 = Uuid::new_v4();
20 + let user2 = Uuid::new_v4();
21 + let admin = Uuid::new_v4();
22 +
23 + for (id, name) in [(user1, "user1"), (user2, "user2"), (admin, "admin")] {
24 + sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
25 + .bind(id)
26 + .bind(name)
27 + .execute(&h.db)
28 + .await
29 + .unwrap();
30 + }
31 +
32 + // Create expired ban
33 + sqlx::query(
34 + "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type, expires_at)
35 + VALUES ($1, $2, $3, 'ban', now() - interval '1 day')",
36 + )
37 + .bind(comm_id)
38 + .bind(user1)
39 + .bind(admin)
40 + .execute(&h.db)
41 + .await
42 + .unwrap();
43 +
44 + // Create active ban
45 + sqlx::query(
46 + "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type, expires_at)
47 + VALUES ($1, $2, $3, 'ban', now() + interval '1 day')",
48 + )
49 + .bind(comm_id)
50 + .bind(user2)
51 + .bind(admin)
52 + .execute(&h.db)
53 + .await
54 + .unwrap();
55 +
56 + let cleaned = mt_db::mutations::cleanup_expired_bans(&h.db, comm_id)
57 + .await
58 + .unwrap();
59 + assert_eq!(cleaned, 1, "Should remove 1 expired ban");
60 +
61 + let remaining: i64 =
62 + sqlx::query_scalar("SELECT COUNT(*) FROM community_bans WHERE community_id = $1")
63 + .bind(comm_id)
64 + .fetch_one(&h.db)
65 + .await
66 + .unwrap();
67 + assert_eq!(remaining, 1, "Should keep 1 active ban");
68 + }
69 +
70 + #[tokio::test]
71 + async fn cleanup_expired_bans_keeps_permanent() {
72 + let h = TestHarness::new().await;
73 + let comm_id = h.create_community("Test", "test").await;
74 +
75 + let user = Uuid::new_v4();
76 + let admin = Uuid::new_v4();
77 +
78 + for (id, name) in [(user, "user"), (admin, "admin")] {
79 + sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
80 + .bind(id)
81 + .bind(name)
82 + .execute(&h.db)
83 + .await
84 + .unwrap();
85 + }
86 +
87 + // Create permanent ban (no expires_at)
88 + mt_db::mutations::create_community_ban(
89 + &h.db,
90 + comm_id,
91 + user,
92 + admin,
93 + BanType::Ban,
94 + Some("permanent"),
95 + None,
96 + )
97 + .await
98 + .unwrap();
99 +
100 + let cleaned = mt_db::mutations::cleanup_expired_bans(&h.db, comm_id)
101 + .await
102 + .unwrap();
103 + assert_eq!(cleaned, 0, "Should not remove permanent bans");
104 + }
105 +
106 + #[tokio::test]
107 + async fn create_community_ban_upserts_on_conflict() {
108 + let h = TestHarness::new().await;
109 + let comm_id = h.create_community("Test", "test").await;
110 +
111 + let user = Uuid::new_v4();
112 + let admin = Uuid::new_v4();
113 +
114 + for (id, name) in [(user, "user"), (admin, "admin")] {
115 + sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
116 + .bind(id)
117 + .bind(name)
118 + .execute(&h.db)
119 + .await
120 + .unwrap();
121 + }
122 +
123 + // First ban
124 + let id1 = mt_db::mutations::create_community_ban(
125 + &h.db,
126 + comm_id,
127 + user,
128 + admin,
129 + BanType::Ban,
130 + Some("first reason"),
131 + None,
132 + )
133 + .await
134 + .unwrap();
135 +
136 + // Second ban (same user+type) should upsert, not duplicate
137 + let id2 = mt_db::mutations::create_community_ban(
138 + &h.db,
139 + comm_id,
140 + user,
141 + admin,
142 + BanType::Ban,
143 + Some("updated reason"),
144 + None,
145 + )
146 + .await
147 + .unwrap();
148 +
149 + assert_eq!(id1, id2, "Upsert should return same ban ID");
150 +
151 + // Verify reason updated
152 + let reason: Option<String> = sqlx::query_scalar(
153 + "SELECT reason FROM community_bans WHERE id = $1",
154 + )
155 + .bind(id1)
156 + .fetch_one(&h.db)
157 + .await
158 + .unwrap();
159 + assert_eq!(reason.as_deref(), Some("updated reason"));
160 + }
161 +
162 + // ============================================================================
163 + // Category mutations
164 + // ============================================================================
165 +
166 + #[tokio::test]
167 + async fn swap_category_order_atomic() {
168 + let h = TestHarness::new().await;
169 + let comm_id = h.create_community("Test", "test").await;
170 +
171 + let cat_a = mt_db::mutations::create_category(
172 + &h.db, comm_id, "Alpha", "alpha", None, 0,
173 + )
174 + .await
175 + .unwrap();
176 + let cat_b = mt_db::mutations::create_category(
177 + &h.db, comm_id, "Beta", "beta", None, 1,
178 + )
179 + .await
180 + .unwrap();
181 +
182 + mt_db::mutations::swap_category_order(&h.db, cat_a, 0, cat_b, 1)
183 + .await
184 + .unwrap();
185 +
186 + let order_a: i32 =
187 + sqlx::query_scalar("SELECT sort_order FROM categories WHERE id = $1")
188 + .bind(cat_a)
189 + .fetch_one(&h.db)
190 + .await
191 + .unwrap();
192 + let order_b: i32 =
193 + sqlx::query_scalar("SELECT sort_order FROM categories WHERE id = $1")
194 + .bind(cat_b)
195 + .fetch_one(&h.db)
196 + .await
197 + .unwrap();
198 +
199 + assert_eq!(order_a, 1, "Alpha should now have order 1");
200 + assert_eq!(order_b, 0, "Beta should now have order 0");
201 + }
202 +
203 + #[tokio::test]
204 + async fn get_category_id_by_slugs_found() {
205 + let h = TestHarness::new().await;
206 + let comm_id = h.create_community("Test", "test").await;
207 + let cat_id = h.create_category(comm_id, "General", "general").await;
208 +
209 + let found = mt_db::mutations::get_category_id_by_slugs(&h.db, "test", "general")
210 + .await
211 + .unwrap();
212 + assert_eq!(found, Some(cat_id));
213 + }
214 +
215 + #[tokio::test]
216 + async fn get_category_id_by_slugs_not_found() {
217 + let h = TestHarness::new().await;
218 + let _comm_id = h.create_community("Test", "test").await;
219 +
220 + let found = mt_db::mutations::get_category_id_by_slugs(&h.db, "test", "nonexistent")
221 + .await
222 + .unwrap();
223 + assert_eq!(found, None);
224 +
225 + let found = mt_db::mutations::get_category_id_by_slugs(&h.db, "nosuchcommunity", "general")
226 + .await
227 + .unwrap();
228 + assert_eq!(found, None);
229 + }
230 +
231 + #[tokio::test]
232 + async fn update_category_updates_fields() {
233 + let h = TestHarness::new().await;
234 + let comm_id = h.create_community("Test", "test").await;
235 + let cat_id = mt_db::mutations::create_category(
236 + &h.db, comm_id, "Old Name", "oldslug", Some("Old desc"), 0,
237 + )
238 + .await
239 + .unwrap();
240 +
241 + mt_db::mutations::update_category(&h.db, cat_id, "New Name", Some("New desc"))
242 + .await
243 + .unwrap();
244 +
245 + let (name, desc): (String, Option<String>) = sqlx::query_as(
246 + "SELECT name, description FROM categories WHERE id = $1",
247 + )
248 + .bind(cat_id)
249 + .fetch_one(&h.db)
250 + .await
251 + .unwrap();
252 +
253 + assert_eq!(name, "New Name");
254 + assert_eq!(desc.as_deref(), Some("New desc"));
255 + }
256 +
257 + // ============================================================================
258 + // Membership mutations
259 + // ============================================================================
260 +
261 + #[tokio::test]
262 + async fn ensure_membership_idempotent() {
263 + let h = TestHarness::new().await;
264 + let comm_id = h.create_community("Test", "test").await;
265 +
266 + let user = Uuid::new_v4();
267 + sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
268 + .bind(user)
269 + .bind("testuser")
270 + .execute(&h.db)
271 + .await
272 + .unwrap();
273 +
274 + // First call creates membership
275 + mt_db::mutations::ensure_membership(&h.db, user, comm_id)
276 + .await
277 + .unwrap();
278 +
279 + // Second call should succeed (ON CONFLICT DO NOTHING)
280 + mt_db::mutations::ensure_membership(&h.db, user, comm_id)
281 + .await
282 + .unwrap();
283 +
284 + let count: i64 = sqlx::query_scalar(
285 + "SELECT COUNT(*) FROM memberships WHERE user_id = $1 AND community_id = $2",
286 + )
287 + .bind(user)
288 + .bind(comm_id)
289 + .fetch_one(&h.db)
290 + .await
291 + .unwrap();
292 + assert_eq!(count, 1, "Should have exactly one membership row");
293 + }
294 +
295 + #[tokio::test]
296 + async fn ensure_membership_with_role_idempotent() {
297 + let h = TestHarness::new().await;
298 + let comm_id = h.create_community("Test", "test").await;
299 +
300 + let user = Uuid::new_v4();
301 + sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
302 + .bind(user)
303 + .bind("roleuser")
304 + .execute(&h.db)
305 + .await
306 + .unwrap();
307 +
308 + mt_db::mutations::ensure_membership_with_role(&h.db, user, comm_id, "moderator")
309 + .await
310 + .unwrap();
311 +
312 + // Second call with same role should succeed
313 + mt_db::mutations::ensure_membership_with_role(&h.db, user, comm_id, "moderator")
314 + .await
315 + .unwrap();
316 +
317 + // Verify role is preserved (DO NOTHING means first write wins)
318 + let role: String = sqlx::query_scalar(
319 + "SELECT role FROM memberships WHERE user_id = $1 AND community_id = $2",
320 + )
321 + .bind(user)
322 + .bind(comm_id)
323 + .fetch_one(&h.db)
324 + .await
325 + .unwrap();
326 + assert_eq!(role, "moderator");
327 + }
328 +
329 + // ============================================================================
330 + // Thread mutations
331 + // ============================================================================
332 +
333 + #[tokio::test]
334 + async fn soft_delete_sets_deleted_at() {
335 + let h = TestHarness::new().await;
336 + let comm_id = h.create_community("Test", "test").await;
337 + let cat_id = h.create_category(comm_id, "General", "general").await;
338 +
339 + let author = Uuid::new_v4();
340 + sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
341 + .bind(author)
342 + .bind("author")
343 + .execute(&h.db)
344 + .await
345 + .unwrap();
346 +
347 + let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Delete Me")
348 + .await
349 + .unwrap();
350 +
351 + // Verify not deleted
352 + let deleted: bool = sqlx::query_scalar(
353 + "SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1",
354 + )
355 + .bind(thread_id)
356 + .fetch_one(&h.db)
357 + .await
358 + .unwrap();
359 + assert!(!deleted, "Should not be deleted initially");
360 +
361 + mt_db::mutations::soft_delete_thread(&h.db, thread_id)
362 + .await
363 + .unwrap();
364 +
365 + let deleted: bool = sqlx::query_scalar(
366 + "SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1",
367 + )
368 + .bind(thread_id)
369 + .fetch_one(&h.db)
370 + .await
371 + .unwrap();
372 + assert!(deleted, "Should be soft-deleted");
373 + }
374 +
375 + #[tokio::test]
376 + async fn create_post_bumps_thread_activity() {
377 + let h = TestHarness::new().await;
378 + let comm_id = h.create_community("Test", "test").await;
379 + let cat_id = h.create_category(comm_id, "General", "general").await;
380 +
381 + let author = Uuid::new_v4();
382 + sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
383 + .bind(author)
384 + .bind("author")
385 + .execute(&h.db)
386 + .await
387 + .unwrap();
388 +
389 + let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Activity Test")
390 + .await
391 + .unwrap();
392 +
393 + // Set activity to a known old time
394 + sqlx::query(
395 + "UPDATE threads SET last_activity_at = '2000-01-01'::timestamptz WHERE id = $1",
396 + )
397 + .bind(thread_id)
398 + .execute(&h.db)
399 + .await
400 + .unwrap();
401 +
402 + // Create post should bump last_activity_at
403 + mt_db::mutations::create_post(&h.db, thread_id, author, "test", "<p>test</p>")
404 + .await
405 + .unwrap();
406 +
407 + let recent: bool = sqlx::query_scalar(
408 + "SELECT last_activity_at > '2020-01-01'::timestamptz FROM threads WHERE id = $1",
409 + )
410 + .bind(thread_id)
411 + .fetch_one(&h.db)
412 + .await
413 + .unwrap();
414 + assert!(recent, "last_activity_at should be updated to recent time");
415 + }
416 +
417 + // ============================================================================
418 + // Endorsement mutations
419 + // ============================================================================
420 +
421 + #[tokio::test]
422 + async fn toggle_endorsement_db_roundtrip() {
423 + let h = TestHarness::new().await;
424 + let comm_id = h.create_community("Test", "test").await;
425 + let cat_id = h.create_category(comm_id, "General", "general").await;
426 +
427 + let author = Uuid::new_v4();
428 + let endorser = Uuid::new_v4();
429 + for (id, name) in [(author, "author"), (endorser, "endorser")] {
430 + sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
431 + .bind(id)
432 + .bind(name)
433 + .execute(&h.db)
434 + .await
435 + .unwrap();
436 + }
437 +
438 + let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Test")
439 + .await
440 + .unwrap();
441 + let post_id = mt_db::mutations::create_post(
442 + &h.db, thread_id, author, "content", "<p>content</p>",
443 + )
444 + .await
445 + .unwrap();
446 +
447 + // First toggle: endorse
448 + let result = mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser)
449 + .await
450 + .unwrap();
451 + assert!(result, "First toggle should endorse (return true)");
452 +
453 + // Second toggle: un-endorse
454 + let result = mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser)
455 + .await
456 + .unwrap();
457 + assert!(!result, "Second toggle should un-endorse (return false)");
458 +
459 + // Third toggle: endorse again
460 + let result = mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser)
461 + .await
462 + .unwrap();
463 + assert!(result, "Third toggle should endorse again (return true)");
464 + }
465 +
466 + // ============================================================================
467 + // Flag mutations
468 + // ============================================================================
469 +
470 + #[tokio::test]
471 + async fn insert_flag_idempotent_per_user() {
472 + let h = TestHarness::new().await;
473 + let comm_id = h.create_community("Test", "test").await;
474 + let cat_id = h.create_category(comm_id, "General", "general").await;
475 +
476 + let author = Uuid::new_v4();
477 + let flagger = Uuid::new_v4();
478 + for (id, name) in [(author, "author"), (flagger, "flagger")] {
479 + sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
480 + .bind(id)
481 + .bind(name)
482 + .execute(&h.db)
483 + .await
484 + .unwrap();
485 + }
486 +
487 + let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Test")
488 + .await
489 + .unwrap();
490 + let post_id = mt_db::mutations::create_post(
491 + &h.db, thread_id, author, "content", "<p>content</p>",
492 + )
493 + .await
494 + .unwrap();
495 +
496 + // First flag
497 + mt_db::mutations::insert_flag(&h.db, post_id, flagger, "spam", None)
498 + .await
499 + .unwrap();
500 +
Lines truncated
M todo.md +21 -12
@@ -1,6 +1,6 @@
1 1 # Multithreaded — Todo
2 2
3 - Done: All pre-beta phases (0-11, 13-24). 239 tests (167 integration + 56 unit lib + 16 unit mt-core). v0.3.0. Audit grade: A (Run 8). Deployed to hetzner+astra (forums.makenot.work). All 20 migrations applied. S3 image uploads configured. MNW Forums tab integration live (MT_BASE_URL set).
3 + Done: All pre-beta phases (0-11, 13-24). 249 tests (187 integration + 35 unit lib + 16 unit mt-core + 11 unit mt-db). v0.3.1. Audit grade: A (Run 10). Deployed to hetzner+astra (forums.makenot.work). All 21 migrations applied. S3 image uploads configured. MNW Forums tab integration live (MT_BASE_URL set). Internal API live (INTERNAL_SHARED_SECRET configured on both MNW+MT, 2026-03-22). Default categories auto-provisioned (Items, Blog, Devlog, Discussion). Zero audit cold spots.
4 4
5 5 Completed work archived in [todo_done.md](todo_done.md).
6 6
@@ -24,6 +24,13 @@ Run 8 audit items resolved. Moved to `todo_done.md`.
24 24
25 25 ---
26 26
27 + ## Bug Hunt Fixes (2026-03-22)
28 +
29 + ### Done
30 + - [x] Link preview body could exceed MAX_BODY_SIZE — `body.extend_from_slice(&chunk)` appended full chunks without clamping. Added chunk slicing to remaining capacity and proper stream exhaustion handling (`link_preview.rs`).
31 +
32 + ---
33 +
27 34 ## TagTree Integration (2026-03-21)
28 35
29 36 ### Done
@@ -39,20 +46,22 @@ Run 8 audit items resolved. Moved to `todo_done.md`.
39 46 MT becomes the social backbone for MNW. Design doc: `docs/internal/strategy/platform-integration.md`.
40 47
41 48 ### Internal API
42 - - [ ] `POST /internal/communities` — create community for an MNW project (HMAC-SHA256 auth)
43 - - [ ] `POST /internal/threads` — create thread linked to MNW item/blog post
44 - - [ ] `POST /internal/posts` — create system post (e.g., "this thread discusses [item]")
45 - - [ ] `GET /internal/threads/{id}/stats` — comment count for embedding in MNW UI
46 - - [ ] Auth middleware: `X-Internal-Signature` header with HMAC-SHA256(timestamp + body, shared_secret)
47 - - [ ] `communities.project_id` nullable FK (links back to MNW project) — migration 021
48 - - [ ] `threads.external_ref` nullable (stores MNW item/blog ID for linking) — migration 021
49 +
50 + #### Done
51 + - [x] `POST /internal/communities` — create community for an MNW project (HMAC-SHA256 auth)
52 + - [x] `POST /internal/threads` — create thread linked to MNW item/blog post
53 + - [x] `POST /internal/posts` — create system post (e.g., "this thread discusses [item]")
54 + - [x] `GET /internal/threads/{id}/stats` — comment count for embedding in MNW UI
55 + - [x] Auth middleware: `X-Internal-Signature` header with HMAC-SHA256(timestamp + body, shared_secret)
56 + - [x] `communities.project_id` nullable FK (links back to MNW project) — migration 021
57 + - [x] `threads.external_ref` nullable (stores MNW item/blog ID for linking) — migration 021
49 58
50 59 ### Default Categories
51 60 Auto-provisioned when MNW creates a community:
52 - - [ ] Items (comments on items)
53 - - [ ] Blog (comments on blog posts)
54 - - [ ] Devlog (developer updates)
55 - - [ ] Discussion (general)
61 + - [x] Items (comments on items)
62 + - [x] Blog (comments on blog posts)
63 + - [x] Devlog (developer updates)
64 + - [x] Discussion (general)
56 65 - [ ] Issues (git issue tracker replacement — see MNW G8-issues)
57 66 - [ ] Patches (inbound email patches — see MNW G7B-patches)
58 67 - [ ] Crashes (crash reports from DS2)