max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+209 insertions,
-72 deletions
| @@ -292,6 +292,131 @@ | |||
| 292 | 292 | assert!(tracked[0].unread_count > 0, "Should have unread posts"); | |
| 293 | 293 | } | |
| 294 | 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 | + | ||
| 295 | 420 | #[tokio::test] | |
| 296 | 421 | async fn stop_tracking_all() { | |
| 297 | 422 | let mut h = TestHarness::new().await; |
| @@ -174,16 +174,20 @@ | |||
| 174 | 174 | post_id: Uuid, | |
| 175 | 175 | community_id: Uuid, | |
| 176 | 176 | ) -> Result<Option<(Uuid, String)>, sqlx::Error> { | |
| 177 | - | // Only live posts can be quoted: a mod-removed post (or one in a soft-deleted | |
| 178 | - | // thread) must fail quote verification so its text cannot be re-surfaced into | |
| 179 | - | // a live post by quoting it back. | |
| 177 | + | // Only live posts can be quoted: a mod-removed post, an author-deleted one, | |
| 178 | + | // or one in a soft-deleted thread must fail quote verification so its text | |
| 179 | + | // cannot be re-surfaced into a live post by quoting it back. All three | |
| 180 | + | // conditions are spelled out because "live" is not one column: `removed_at` | |
| 181 | + | // is the mod action, `deleted_at` the author's, and either one alone would | |
| 182 | + | // let the other class through. | |
| 180 | 183 | sqlx::query!( | |
| 181 | 184 | "SELECT p.author_id, p.body_markdown | |
| 182 | 185 | FROM posts p | |
| 183 | 186 | JOIN threads t ON t.id = p.thread_id | |
| 184 | 187 | JOIN categories c ON c.id = t.category_id | |
| 185 | 188 | WHERE p.id = $1 AND c.community_id = $2 | |
| 186 | - | AND p.removed_at IS NULL AND t.deleted_at IS NULL", | |
| 189 | + | AND p.removed_at IS NULL AND p.deleted_at IS NULL | |
| 190 | + | AND t.deleted_at IS NULL", | |
| 187 | 191 | post_id, | |
| 188 | 192 | community_id, | |
| 189 | 193 | ) |
| @@ -35,6 +35,11 @@ | |||
| 35 | 35 | /// with a single LEFT JOIN rather than a per-row nested subquery, and the unread | |
| 36 | 36 | /// count excludes mod-removed posts (`p.removed_at IS NULL`) so the badge can't | |
| 37 | 37 | /// be inflated by removed content and can use the `idx_posts_not_removed` index. | |
| 38 | + | /// | |
| 39 | + | /// The mention flag carries the same filter as the unread count. Both point the | |
| 40 | + | /// reader at content, so a badge that outlives the post it names sends them to a | |
| 41 | + | /// thread with nothing to find. `deleted_at` is dormant today (migration 031), | |
| 42 | + | /// but the two predicates travel together everywhere else that reads posts. | |
| 38 | 43 | #[tracing::instrument(skip_all)] | |
| 39 | 44 | pub async fn list_tracked_threads( | |
| 40 | 45 | pool: &PgPool, | |
| @@ -52,6 +57,7 @@ | |||
| 52 | 57 | (SELECT COUNT(*) FROM posts p | |
| 53 | 58 | WHERE p.thread_id = tt.thread_id | |
| 54 | 59 | AND p.removed_at IS NULL | |
| 60 | + | AND p.deleted_at IS NULL | |
| 55 | 61 | AND (lrp.created_at IS NULL OR p.created_at > lrp.created_at) | |
| 56 | 62 | ) AS "unread_count!", | |
| 57 | 63 | EXISTS ( | |
| @@ -59,6 +65,8 @@ | |||
| 59 | 65 | JOIN posts p ON p.id = pm.post_id | |
| 60 | 66 | WHERE pm.mentioned_user_id = tt.user_id | |
| 61 | 67 | AND p.thread_id = tt.thread_id | |
| 68 | + | AND p.removed_at IS NULL | |
| 69 | + | AND p.deleted_at IS NULL | |
| 62 | 70 | ) AS "has_mention!", | |
| 63 | 71 | tt.tracked_at AS "tracked_at: chrono::DateTime<chrono::Utc>" | |
| 64 | 72 | FROM tracked_threads tt |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | { | |
| 2 | 2 | "db_name": "PostgreSQL", | |
| 3 | - | "query": "SELECT p.author_id, p.body_markdown\n FROM posts p\n JOIN threads t ON t.id = p.thread_id\n JOIN categories c ON c.id = t.category_id\n WHERE p.id = $1 AND c.community_id = $2\n AND p.removed_at IS NULL AND t.deleted_at IS NULL", | |
| 3 | + | "query": "SELECT p.author_id, p.body_markdown\n FROM posts p\n JOIN threads t ON t.id = p.thread_id\n JOIN categories c ON c.id = t.category_id\n WHERE p.id = $1 AND c.community_id = $2\n AND p.removed_at IS NULL AND p.deleted_at IS NULL\n AND t.deleted_at IS NULL", | |
| 4 | 4 | "describe": { | |
| 5 | 5 | "columns": [ | |
| 6 | 6 | { | |
| @@ -25,5 +25,5 @@ | |||
| 25 | 25 | false | |
| 26 | 26 | ] | |
| 27 | 27 | }, | |
| 28 | - | "hash": "88156f7230531c0aaa7ffdac3e70b0fc2655dfb176d446b93723a252ada349b0" | |
| 28 | + | "hash": "4d9de685a37677d8ec3211a14d314ee7dabd93d602f20c0d343ec9b79b51d2e1" | |
| 29 | 29 | } |
A
multithreaded/.sqlx/query-518a5972549d912715539420229c6286b58ccae08e2b1211207fdb4a6996321e.json
+66
| @@ -1,0 +1,66 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT tt.thread_id,\n t.title AS thread_title,\n co.name AS community_name,\n co.slug AS community_slug,\n cat.slug AS category_slug,\n (SELECT COUNT(*) FROM posts p\n WHERE p.thread_id = tt.thread_id\n AND p.removed_at IS NULL\n AND p.deleted_at IS NULL\n AND (lrp.created_at IS NULL OR p.created_at > lrp.created_at)\n ) AS \"unread_count!\",\n EXISTS (\n SELECT 1 FROM post_mentions pm\n JOIN posts p ON p.id = pm.post_id\n WHERE pm.mentioned_user_id = tt.user_id\n AND p.thread_id = tt.thread_id\n AND p.removed_at IS NULL\n AND p.deleted_at IS NULL\n ) AS \"has_mention!\",\n tt.tracked_at AS \"tracked_at: chrono::DateTime<chrono::Utc>\"\n FROM tracked_threads tt\n JOIN threads t ON t.id = tt.thread_id\n JOIN categories cat ON cat.id = t.category_id\n JOIN communities co ON co.id = cat.community_id\n LEFT JOIN posts lrp ON lrp.id = tt.last_read_post_id\n WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL\n ORDER BY t.last_activity_at DESC\n LIMIT $2 OFFSET $3", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "thread_id", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "thread_title", | |
| 14 | + | "type_info": "Text" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "community_name", | |
| 19 | + | "type_info": "Text" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "community_slug", | |
| 24 | + | "type_info": "Text" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "category_slug", | |
| 29 | + | "type_info": "Text" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "unread_count!", | |
| 34 | + | "type_info": "Int8" | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | "ordinal": 6, | |
| 38 | + | "name": "has_mention!", | |
| 39 | + | "type_info": "Bool" | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | "ordinal": 7, | |
| 43 | + | "name": "tracked_at: chrono::DateTime<chrono::Utc>", | |
| 44 | + | "type_info": "Timestamptz" | |
| 45 | + | } | |
| 46 | + | ], | |
| 47 | + | "parameters": { | |
| 48 | + | "Left": [ | |
| 49 | + | "Uuid", | |
| 50 | + | "Int8", | |
| 51 | + | "Int8" | |
| 52 | + | ] | |
| 53 | + | }, | |
| 54 | + | "nullable": [ | |
| 55 | + | false, | |
| 56 | + | false, | |
| 57 | + | false, | |
| 58 | + | false, | |
| 59 | + | false, | |
| 60 | + | null, | |
| 61 | + | null, | |
| 62 | + | false | |
| 63 | + | ] | |
| 64 | + | }, | |
| 65 | + | "hash": "518a5972549d912715539420229c6286b58ccae08e2b1211207fdb4a6996321e" | |
| 66 | + | } |
D
multithreaded/.sqlx/query-78349b35320856cf18eb7e66393675af5a14063b83dc8366c801994b67cdc5a7.json
-66
| @@ -1,66 +1,0 @@ | |||
| 1 | - | { | |
| 2 | - | "db_name": "PostgreSQL", | |
| 3 | - | "query": "SELECT tt.thread_id,\n t.title AS thread_title,\n co.name AS community_name,\n co.slug AS community_slug,\n cat.slug AS category_slug,\n (SELECT COUNT(*) FROM posts p\n WHERE p.thread_id = tt.thread_id\n AND p.removed_at IS NULL\n AND (lrp.created_at IS NULL OR p.created_at > lrp.created_at)\n ) AS \"unread_count!\",\n EXISTS (\n SELECT 1 FROM post_mentions pm\n JOIN posts p ON p.id = pm.post_id\n WHERE pm.mentioned_user_id = tt.user_id\n AND p.thread_id = tt.thread_id\n ) AS \"has_mention!\",\n tt.tracked_at AS \"tracked_at: chrono::DateTime<chrono::Utc>\"\n FROM tracked_threads tt\n JOIN threads t ON t.id = tt.thread_id\n JOIN categories cat ON cat.id = t.category_id\n JOIN communities co ON co.id = cat.community_id\n LEFT JOIN posts lrp ON lrp.id = tt.last_read_post_id\n WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL\n ORDER BY t.last_activity_at DESC\n LIMIT $2 OFFSET $3", | |
| 4 | - | "describe": { | |
| 5 | - | "columns": [ | |
| 6 | - | { | |
| 7 | - | "ordinal": 0, | |
| 8 | - | "name": "thread_id", | |
| 9 | - | "type_info": "Uuid" | |
| 10 | - | }, | |
| 11 | - | { | |
| 12 | - | "ordinal": 1, | |
| 13 | - | "name": "thread_title", | |
| 14 | - | "type_info": "Text" | |
| 15 | - | }, | |
| 16 | - | { | |
| 17 | - | "ordinal": 2, | |
| 18 | - | "name": "community_name", | |
| 19 | - | "type_info": "Text" | |
| 20 | - | }, | |
| 21 | - | { | |
| 22 | - | "ordinal": 3, | |
| 23 | - | "name": "community_slug", | |
| 24 | - | "type_info": "Text" | |
| 25 | - | }, | |
| 26 | - | { | |
| 27 | - | "ordinal": 4, | |
| 28 | - | "name": "category_slug", | |
| 29 | - | "type_info": "Text" | |
| 30 | - | }, | |
| 31 | - | { | |
| 32 | - | "ordinal": 5, | |
| 33 | - | "name": "unread_count!", | |
| 34 | - | "type_info": "Int8" | |
| 35 | - | }, | |
| 36 | - | { | |
| 37 | - | "ordinal": 6, | |
| 38 | - | "name": "has_mention!", | |
| 39 | - | "type_info": "Bool" | |
| 40 | - | }, | |
| 41 | - | { | |
| 42 | - | "ordinal": 7, | |
| 43 | - | "name": "tracked_at: chrono::DateTime<chrono::Utc>", | |
| 44 | - | "type_info": "Timestamptz" | |
| 45 | - | } | |
| 46 | - | ], | |
| 47 | - | "parameters": { | |
| 48 | - | "Left": [ | |
| 49 | - | "Uuid", | |
| 50 | - | "Int8", | |
| 51 | - | "Int8" | |
| 52 | - | ] | |
| 53 | - | }, | |
| 54 | - | "nullable": [ | |
| 55 | - | false, | |
| 56 | - | false, | |
| 57 | - | false, | |
| 58 | - | false, | |
| 59 | - | false, | |
| 60 | - | null, | |
| 61 | - | null, | |
| 62 | - | false | |
| 63 | - | ] | |
| 64 | - | }, | |
| 65 | - | "hash": "78349b35320856cf18eb7e66393675af5a14063b83dc8366c801994b67cdc5a7" | |
| 66 | - | } |