Skip to main content

max / makenotwork

16.0 KB · 480 lines History Blame Raw
1 use crate::harness::TestHarness;
2
3 #[tokio::test]
4 async fn track_thread_happy_path() {
5 let mut h = TestHarness::new().await;
6 let user_id = h.login_as("tracker").await;
7 let comm_id = h.create_community("Test", "test").await;
8 let cat_id = h.create_category(comm_id, "General", "general").await;
9 h.add_membership(user_id, comm_id, "member").await;
10
11 let thread_id = h
12 .create_thread_with_post(cat_id, user_id, "Track Me", "Content")
13 .await;
14
15 let thread_url = format!("/p/test/general/{thread_id}");
16 h.client.get(&thread_url).await;
17
18 let track_url = format!("/p/test/general/{thread_id}/track");
19 let resp = h.client.post_form(&track_url, "").await;
20 assert!(
21 resp.status.is_redirection(),
22 "Expected redirect, got {}",
23 resp.status
24 );
25
26 let tracked = mt_db::queries::is_thread_tracked(&h.db, user_id, thread_id)
27 .await
28 .unwrap();
29 assert!(tracked, "Thread should be tracked");
30 }
31
32 #[tokio::test]
33 async fn untrack_thread() {
34 let mut h = TestHarness::new().await;
35 let user_id = h.login_as("untracker").await;
36 let comm_id = h.create_community("Test", "test").await;
37 let cat_id = h.create_category(comm_id, "General", "general").await;
38 h.add_membership(user_id, comm_id, "member").await;
39
40 let thread_id = h
41 .create_thread_with_post(cat_id, user_id, "Untrack Me", "Content")
42 .await;
43
44 // Track first
45 mt_db::mutations::track_thread(&h.db, user_id, thread_id)
46 .await
47 .unwrap();
48
49 let thread_url = format!("/p/test/general/{thread_id}");
50 h.client.get(&thread_url).await;
51
52 let untrack_url = format!("/p/test/general/{thread_id}/untrack");
53 let resp = h.client.post_form(&untrack_url, "").await;
54 assert!(
55 resp.status.is_redirection(),
56 "Expected redirect, got {}",
57 resp.status
58 );
59
60 let tracked = mt_db::queries::is_thread_tracked(&h.db, user_id, thread_id)
61 .await
62 .unwrap();
63 assert!(!tracked, "Thread should not be tracked");
64 }
65
66 #[tokio::test]
67 async fn track_thread_via_wrong_community_slug_404s() {
68 // C1: a thread in community A must not be trackable through community B's slug.
69 let mut h = TestHarness::new().await;
70 let user_id = h.login_as("crossslug").await;
71 let comm_a = h.create_community("Alpha", "alpha").await;
72 let cat_a = h.create_category(comm_a, "General", "general").await;
73 h.add_membership(user_id, comm_a, "member").await;
74 // A second community whose slug the attacker will borrow.
75 let comm_b = h.create_community("Beta", "beta").await;
76 h.create_category(comm_b, "General", "general").await;
77 h.add_membership(user_id, comm_b, "member").await;
78
79 let thread_id = h
80 .create_thread_with_post(cat_a, user_id, "In Alpha", "Content")
81 .await;
82
83 // Track the alpha thread through beta's slug → 404 (mismatch is not-found).
84 let wrong = format!("/p/beta/general/{thread_id}/track");
85 let resp = h.client.post_form(&wrong, "").await;
86 assert_eq!(
87 resp.status,
88 axum::http::StatusCode::NOT_FOUND,
89 "got {}",
90 resp.status
91 );
92
93 let tracked = mt_db::queries::is_thread_tracked(&h.db, user_id, thread_id)
94 .await
95 .unwrap();
96 assert!(
97 !tracked,
98 "no tracking row may be created via a foreign slug"
99 );
100 }
101
102 #[tokio::test]
103 async fn banned_user_cannot_track_thread() {
104 // A banned user must not accrue tracking state in the community they're banned from.
105 let mut h = TestHarness::new().await;
106 let owner_id = h.login_as("owner").await;
107 let comm_id = h.create_community("Test", "test").await;
108 let cat_id = h.create_category(comm_id, "General", "general").await;
109 h.add_membership(owner_id, comm_id, "owner").await;
110 let thread_id = h
111 .create_thread_with_post(cat_id, owner_id, "Topic", "Content")
112 .await;
113
114 // Switch to the victim, who is a member then gets banned.
115 let banned_id = h.login_as("banned").await;
116 h.add_membership(banned_id, comm_id, "member").await;
117 h.ban_user(comm_id, banned_id, owner_id, "ban").await;
118
119 let track_url = format!("/p/test/general/{thread_id}/track");
120 let resp = h.client.post_form(&track_url, "").await;
121 assert_eq!(
122 resp.status,
123 axum::http::StatusCode::FORBIDDEN,
124 "got {}",
125 resp.status
126 );
127
128 let tracked = mt_db::queries::is_thread_tracked(&h.db, banned_id, thread_id)
129 .await
130 .unwrap();
131 assert!(!tracked, "banned user must not accrue tracking state");
132 }
133
134 #[tokio::test]
135 async fn read_position_updates_on_view() {
136 let mut h = TestHarness::new().await;
137 let user_id = h.login_as("readpos").await;
138 let comm_id = h.create_community("Test", "test").await;
139 let cat_id = h.create_category(comm_id, "General", "general").await;
140 h.add_membership(user_id, comm_id, "member").await;
141
142 let thread_id = h
143 .create_thread_with_post(cat_id, user_id, "Read Pos", "First post")
144 .await;
145
146 // Track the thread
147 mt_db::mutations::track_thread(&h.db, user_id, thread_id)
148 .await
149 .unwrap();
150
151 // View thread, should update read position. The bump is now spawned off the
152 // response path, so poll briefly for it rather than reading immediately.
153 let thread_url = format!("/p/test/general/{thread_id}");
154 h.client.get(&thread_url).await;
155
156 let mut last_read: Option<uuid::Uuid> = None;
157 for _ in 0..50 {
158 let row: Option<(Option<uuid::Uuid>,)> = sqlx::query_as(
159 "SELECT last_read_post_id FROM tracked_threads WHERE user_id = $1 AND thread_id = $2",
160 )
161 .bind(user_id)
162 .bind(thread_id)
163 .fetch_optional(&h.db)
164 .await
165 .unwrap();
166 assert!(row.is_some(), "Tracking row should exist");
167 last_read = row.unwrap().0;
168 if last_read.is_some() {
169 break;
170 }
171 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
172 }
173 assert!(
174 last_read.is_some(),
175 "last_read_post_id should be set after viewing"
176 );
177 }
178
179 /// Regression (Run #3 re-verify, read-position monotonicity): the read position
180 /// only moves forward. The view handler bumps to the last post on the page being
181 /// viewed, so revisiting page 1 of a long thread must not drag the position back
182 /// and resurrect already-read posts as unread.
183 #[tokio::test]
184 async fn read_position_never_moves_backward() {
185 let mut h = TestHarness::new().await;
186 let user_id = h.login_as("monotonic").await;
187 let comm_id = h.create_community("Test", "test").await;
188 let cat_id = h.create_category(comm_id, "General", "general").await;
189 h.add_membership(user_id, comm_id, "member").await;
190
191 let thread_id = h
192 .create_thread_with_post(cat_id, user_id, "Monotonic", "First post")
193 .await;
194
195 let first_post: uuid::Uuid = sqlx::query_scalar(
196 "SELECT id FROM posts WHERE thread_id = $1 ORDER BY created_at ASC LIMIT 1",
197 )
198 .bind(thread_id)
199 .fetch_one(&h.db)
200 .await
201 .unwrap();
202
203 let later_post =
204 mt_db::mutations::create_post(&h.db, thread_id, user_id, "Later", "<p>Later</p>")
205 .await
206 .unwrap();
207
208 mt_db::mutations::track_thread(&h.db, user_id, thread_id)
209 .await
210 .unwrap();
211
212 let read_position = async || -> Option<uuid::Uuid> {
213 sqlx::query_scalar(
214 "SELECT last_read_post_id FROM tracked_threads WHERE user_id = $1 AND thread_id = $2",
215 )
216 .bind(user_id)
217 .bind(thread_id)
218 .fetch_one(&h.db)
219 .await
220 .unwrap()
221 };
222
223 // Forward from NULL: the first bump always takes.
224 mt_db::mutations::update_read_position(&h.db, user_id, thread_id, later_post)
225 .await
226 .unwrap();
227 assert_eq!(read_position().await, Some(later_post));
228
229 // Backward: viewing an earlier page must leave the position alone.
230 mt_db::mutations::update_read_position(&h.db, user_id, thread_id, first_post)
231 .await
232 .unwrap();
233 assert_eq!(
234 read_position().await,
235 Some(later_post),
236 "read position must not move backward to an earlier post"
237 );
238
239 // And the unread count stays settled rather than re-surfacing read posts.
240 let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0)
241 .await
242 .unwrap();
243 assert_eq!(tracked.len(), 1);
244 assert_eq!(
245 tracked[0].unread_count, 0,
246 "a backward bump must not resurrect already-read posts as unread"
247 );
248
249 // Re-bumping the same post is a no-op, not an error.
250 mt_db::mutations::update_read_position(&h.db, user_id, thread_id, later_post)
251 .await
252 .unwrap();
253 assert_eq!(read_position().await, Some(later_post));
254 }
255
256 #[tokio::test]
257 async fn unread_count_tracking() {
258 let mut h = TestHarness::new().await;
259 let user_id = h.login_as("unreadcount").await;
260 let comm_id = h.create_community("Test", "test").await;
261 let cat_id = h.create_category(comm_id, "General", "general").await;
262 h.add_membership(user_id, comm_id, "member").await;
263
264 let thread_id = h
265 .create_thread_with_post(cat_id, user_id, "Unread Count", "First post")
266 .await;
267
268 // Track and view to set read position
269 mt_db::mutations::track_thread(&h.db, user_id, thread_id)
270 .await
271 .unwrap();
272 let thread_url = format!("/p/test/general/{thread_id}");
273 h.client.get(&thread_url).await;
274
275 // Add a new post by another user
276 let other_id = uuid::Uuid::new_v4();
277 sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)")
278 .bind(other_id)
279 .bind("poster2")
280 .execute(&h.db)
281 .await
282 .unwrap();
283 mt_db::mutations::create_post(&h.db, thread_id, other_id, "New reply", "<p>New reply</p>")
284 .await
285 .unwrap();
286
287 // Check tracked threads, should show unread
288 let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0)
289 .await
290 .unwrap();
291 assert_eq!(tracked.len(), 1);
292 assert!(tracked[0].unread_count > 0, "Should have unread posts");
293 }
294
295 /// `posts.deleted_at` is dormant today (migration 031), so this sets it directly
296 /// rather than through a handler. That is the point: the filter has to already be
297 /// in place on the day an author-delete path ships, or the badge starts counting
298 /// posts the reader cannot open. `removed_at` covers the mod's action and is
299 /// asserted alongside it so neither predicate can be dropped alone.
300 #[tokio::test]
301 async fn unread_count_excludes_removed_and_deleted_posts() {
302 let mut h = TestHarness::new().await;
303 let user_id = h.login_as("unreadfilter").await;
304 let comm_id = h.create_community("Test", "test").await;
305 let cat_id = h.create_category(comm_id, "General", "general").await;
306 h.add_membership(user_id, comm_id, "member").await;
307
308 let thread_id = h
309 .create_thread_with_post(cat_id, user_id, "Filtered", "First post")
310 .await;
311
312 mt_db::mutations::track_thread(&h.db, user_id, thread_id)
313 .await
314 .unwrap();
315 h.client.get(&format!("/p/test/general/{thread_id}")).await;
316
317 let other_id = uuid::Uuid::new_v4();
318 sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)")
319 .bind(other_id)
320 .bind("filterposter")
321 .execute(&h.db)
322 .await
323 .unwrap();
324
325 let removed = mt_db::mutations::create_post(&h.db, thread_id, other_id, "gone", "<p>gone</p>")
326 .await
327 .unwrap();
328 let deleted = mt_db::mutations::create_post(&h.db, thread_id, other_id, "bye", "<p>bye</p>")
329 .await
330 .unwrap();
331
332 let unread_before = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0)
333 .await
334 .unwrap()[0]
335 .unread_count;
336 assert_eq!(unread_before, 2, "both replies start unread");
337
338 sqlx::query("UPDATE posts SET removed_at = now() WHERE id = $1")
339 .bind(removed)
340 .execute(&h.db)
341 .await
342 .unwrap();
343 sqlx::query("UPDATE posts SET deleted_at = now() WHERE id = $1")
344 .bind(deleted)
345 .execute(&h.db)
346 .await
347 .unwrap();
348
349 let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0)
350 .await
351 .unwrap();
352 assert_eq!(
353 tracked[0].unread_count, 0,
354 "a mod-removed and an author-deleted post must both drop out of the badge"
355 );
356 }
357
358 /// The mention flag reads posts too, so it carries the same filter as the unread
359 /// count. A badge pointing at a removed post sends the reader to a thread with
360 /// nothing to find.
361 #[tokio::test]
362 async fn mention_flag_excludes_removed_and_deleted_posts() {
363 let mut h = TestHarness::new().await;
364 let user_id = h.login_as("mentionfilter").await;
365 let comm_id = h.create_community("Test", "test").await;
366 let cat_id = h.create_category(comm_id, "General", "general").await;
367 h.add_membership(user_id, comm_id, "member").await;
368
369 let thread_id = h
370 .create_thread_with_post(cat_id, user_id, "Mentioned", "First post")
371 .await;
372 mt_db::mutations::track_thread(&h.db, user_id, thread_id)
373 .await
374 .unwrap();
375
376 let other_id = uuid::Uuid::new_v4();
377 sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)")
378 .bind(other_id)
379 .bind("mentioner")
380 .execute(&h.db)
381 .await
382 .unwrap();
383
384 let post_id = mt_db::mutations::create_post(
385 &h.db,
386 thread_id,
387 other_id,
388 "@mentionfilter hi",
389 "<p>@mentionfilter hi</p>",
390 )
391 .await
392 .unwrap();
393 sqlx::query("INSERT INTO post_mentions (post_id, mentioned_user_id) VALUES ($1, $2)")
394 .bind(post_id)
395 .bind(user_id)
396 .execute(&h.db)
397 .await
398 .unwrap();
399
400 let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0)
401 .await
402 .unwrap();
403 assert!(tracked[0].has_mention, "live mention must flag");
404
405 sqlx::query("UPDATE posts SET removed_at = now() WHERE id = $1")
406 .bind(post_id)
407 .execute(&h.db)
408 .await
409 .unwrap();
410
411 let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0)
412 .await
413 .unwrap();
414 assert!(
415 !tracked[0].has_mention,
416 "a mention inside a removed post must not flag"
417 );
418 }
419
420 #[tokio::test]
421 async fn stop_tracking_all() {
422 let mut h = TestHarness::new().await;
423 let user_id = h.login_as("stopall").await;
424 let comm_id = h.create_community("Test", "test").await;
425 let cat_id = h.create_category(comm_id, "General", "general").await;
426 h.add_membership(user_id, comm_id, "member").await;
427
428 let t1 = h
429 .create_thread_with_post(cat_id, user_id, "Thread 1", "content")
430 .await;
431 let t2 = h
432 .create_thread_with_post(cat_id, user_id, "Thread 2", "content")
433 .await;
434
435 mt_db::mutations::track_thread(&h.db, user_id, t1)
436 .await
437 .unwrap();
438 mt_db::mutations::track_thread(&h.db, user_id, t2)
439 .await
440 .unwrap();
441
442 h.client.get("/tracked").await;
443 let resp = h.client.post_form("/tracked/stop-all", "").await;
444 assert!(
445 resp.status.is_redirection(),
446 "Expected redirect, got {}",
447 resp.status
448 );
449
450 let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0)
451 .await
452 .unwrap();
453 assert_eq!(tracked.len(), 0, "All tracked threads should be removed");
454 }
455
456 #[tokio::test]
457 async fn track_requires_login() {
458 let mut h = TestHarness::new().await;
459 let user_id = h.login_as("trackloginuser").await;
460 let comm_id = h.create_community("Test", "test").await;
461 let cat_id = h.create_category(comm_id, "General", "general").await;
462 h.add_membership(user_id, comm_id, "member").await;
463
464 let thread_id = h
465 .create_thread_with_post(cat_id, user_id, "Login Track", "content")
466 .await;
467
468 // New harness without login
469 let mut h2 = TestHarness::new().await;
470 h2.client.get("/").await;
471
472 let track_url = format!("/p/test/general/{thread_id}/track");
473 let resp = h2.client.post_form(&track_url, "").await;
474 assert!(
475 resp.status.is_redirection(),
476 "Expected redirect to login, got {}",
477 resp.status
478 );
479 }
480