@@ -9,13 +9,38 @@ import (
99 "sync"
1010 "testing"
1111
12+ "github.com/Mininglamp-OSS/octo-lib/config"
1213 "github.com/Mininglamp-OSS/octo-lib/pkg/util"
1314 "github.com/Mininglamp-OSS/octo-lib/pkg/wkhttp"
1415 "github.com/Mininglamp-OSS/octo-lib/testutil"
1516 convext "github.com/Mininglamp-OSS/octo-server/modules/conversation_ext"
17+ redis "github.com/go-redis/redis"
1618 "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/require"
1720)
1821
22+ // resetUIDRateLimit clears the per-uid token-bucket keys
23+ // (ratelimit:uid:{uid}) so subsequent HTTP calls in this test start from a
24+ // full bucket. Without this, tests that came earlier in the same go test
25+ // binary will have consumed tokens, and a later high-burst test (e.g.
26+ // TestCategory_CreateLimit, which makes 20+ category POSTs back-to-back)
27+ // fails with HTTP 429 even though the per-test logic is correct. See
28+ // pkg/wkhttp/ratelimit_helper.go SharedUIDRateLimiter for the bucket key
29+ // scheme. Pattern mirrors modules/space/api_email_invite_public_test.go's
30+ // resetSpaceInviteRateLimit.
31+ func resetUIDRateLimit (t * testing.T , ctx * config.Context ) {
32+ t .Helper ()
33+ rdsClient := redis .NewClient (& redis.Options {
34+ Addr : ctx .GetConfig ().DB .RedisAddr ,
35+ Password : ctx .GetConfig ().DB .RedisPass ,
36+ })
37+ defer rdsClient .Close ()
38+ keys , err := rdsClient .Keys ("ratelimit:uid:*" ).Result ()
39+ if err == nil && len (keys ) > 0 {
40+ _ = rdsClient .Del (keys ... ).Err ()
41+ }
42+ }
43+
1944// ---------- helpers ----------
2045
2146func resetDefaultCategoryName () {
@@ -427,6 +452,263 @@ func TestCategory_MoveGroupToCategory(t *testing.T) {
427452 assert .Nil (t , setting2 .CategoryID )
428453}
429454
455+ // TestCategory_MoveGroupOutOfCategory_ClearsAutoFollowThreads is the
456+ // regression test for issue #151 review #3 (yujiawei). When a user moves a
457+ // group out of any category, the auto_follow_threads flag on the
458+ // user_conversation_ext row (which the new sidebar materialization may have
459+ // set to 1) must be cleared in the same transaction. Without this cleanup,
460+ // selectEligibleForFanoutTx would keep this user eligible for OnThreadCreated
461+ // fan-out — the read side (buildFollowItems) drops the group because
462+ // CategoryID is now nil, but the write side only checks auto_follow_threads.
463+ //
464+ // Repro before fix:
465+ // 1. group g placed in category c, no ext row yet (default-followed).
466+ // 2. /v1/sidebar/sync follow tab materializes (uid, space, g) with
467+ // auto_follow_threads=1, group_unfollowed=0.
468+ // 3. User moves g out of category via PUT /v1/groups/g/category {"":""}.
469+ // 4. ext row still has auto_follow_threads=1 — fan-out continues.
470+ //
471+ // After fix the move-out branch in api.go calls ClearAutoFollowThreadsTx
472+ // in the same tx, restoring the read/write contract.
473+ func TestCategory_MoveGroupOutOfCategory_ClearsAutoFollowThreads (t * testing.T ) {
474+ s , ctx := testutil .NewTestServer ()
475+ f := New (ctx )
476+
477+ err := testutil .CleanAllTables (ctx )
478+ assert .NoError (t , err )
479+ resetUIDRateLimit (t , ctx )
480+
481+ spaceID := "space-move-clr-001"
482+ seedSpaceAndMember (t , f , spaceID , 0 )
483+ route := s .GetRoute ()
484+
485+ // 1. Set up: category + group + group-in-category.
486+ wc := createCategory (t , route , spaceID , "工作" )
487+ require .Equal (t , http .StatusOK , wc .Code )
488+ cat := parseJSON (t , wc )
489+ catID := cat ["category_id" ].(string )
490+
491+ groupNo := "group-move-clr-001"
492+ seedGroup (t , f , groupNo , spaceID )
493+
494+ wm := doRequest (t , route , "PUT" , "/v1/groups/" + groupNo + "/category" , map [string ]string {
495+ "category_id" : catID ,
496+ })
497+ require .Equal (t , http .StatusOK , wm .Code )
498+
499+ // 2. Simulate the sidebar materialization step (would normally fire on
500+ // /v1/sidebar/sync). Insert ext row with auto_follow_threads=1,
501+ // matching what MaterializeDefaultFollowedGroups writes.
502+ _ , err = f .db .session .InsertBySql (
503+ "INSERT INTO user_conversation_ext (uid, space_id, target_type, target_id, group_unfollowed, auto_follow_threads) " +
504+ "VALUES (?, ?, 2, ?, 0, 1)" ,
505+ testutil .UID , spaceID , groupNo ,
506+ ).Exec ()
507+ require .NoError (t , err , "seed materialized ext row" )
508+
509+ // Precondition sanity check.
510+ var preAutoFollow int
511+ _ , err = f .db .session .SelectBySql (
512+ "SELECT auto_follow_threads FROM user_conversation_ext" +
513+ " WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?" ,
514+ testutil .UID , spaceID , groupNo ,
515+ ).Load (& preAutoFollow )
516+ require .NoError (t , err )
517+ require .Equal (t , 1 , preAutoFollow , "precondition: row is materialized auto_follow_threads=1" )
518+
519+ // 3. Move group OUT of category.
520+ wm2 := doRequest (t , route , "PUT" , "/v1/groups/" + groupNo + "/category" , map [string ]string {
521+ "category_id" : "" ,
522+ })
523+ require .Equal (t , http .StatusOK , wm2 .Code )
524+
525+ // 4. Assert auto_follow_threads is now 0 — the actual regression fix.
526+ var postAutoFollow int
527+ _ , err = f .db .session .SelectBySql (
528+ "SELECT auto_follow_threads FROM user_conversation_ext" +
529+ " WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?" ,
530+ testutil .UID , spaceID , groupNo ,
531+ ).Load (& postAutoFollow )
532+ require .NoError (t , err )
533+ assert .Equal (t , 0 , postAutoFollow ,
534+ "auto_follow_threads must be cleared after move-out (issue #151 review #3); " +
535+ "otherwise selectEligibleForFanoutTx would still target this user" )
536+
537+ // 5. Other flags MUST be preserved — uncategorize is NOT a full unfollow.
538+ var groupUnfollowed int
539+ _ , err = f .db .session .SelectBySql (
540+ "SELECT group_unfollowed FROM user_conversation_ext" +
541+ " WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?" ,
542+ testutil .UID , spaceID , groupNo ,
543+ ).Load (& groupUnfollowed )
544+ require .NoError (t , err )
545+ assert .Equal (t , 0 , groupUnfollowed ,
546+ "group_unfollowed must NOT be set — uncategorize ≠ explicit unfollow; " +
547+ "the cleanup only revokes auto-subscribe to NEW threads, not all subscriptions" )
548+ }
549+
550+ // TestCategory_MoveGroupBetweenCategories_PreservesAutoFollowThreads pins the
551+ // non-regression: moving a group from category A to category B preserves the
552+ // implicit follow, so auto_follow_threads stays 1. Without this guard, a
553+ // future change might over-eagerly clear in every move and break the
554+ // "default-followed across category-to-category move" contract.
555+ func TestCategory_MoveGroupBetweenCategories_PreservesAutoFollowThreads (t * testing.T ) {
556+ s , ctx := testutil .NewTestServer ()
557+ f := New (ctx )
558+
559+ err := testutil .CleanAllTables (ctx )
560+ assert .NoError (t , err )
561+ resetUIDRateLimit (t , ctx )
562+
563+ spaceID := "space-move-keep-001"
564+ seedSpaceAndMember (t , f , spaceID , 0 )
565+ route := s .GetRoute ()
566+
567+ // Two categories, one group, materialized ext row.
568+ wcA := createCategory (t , route , spaceID , "工作A" )
569+ require .Equal (t , http .StatusOK , wcA .Code )
570+ catA := parseJSON (t , wcA )["category_id" ].(string )
571+ wcB := createCategory (t , route , spaceID , "工作B" )
572+ require .Equal (t , http .StatusOK , wcB .Code )
573+ catB := parseJSON (t , wcB )["category_id" ].(string )
574+
575+ groupNo := "group-move-keep-001"
576+ seedGroup (t , f , groupNo , spaceID )
577+ require .Equal (t , http .StatusOK , doRequest (t , route , "PUT" , "/v1/groups/" + groupNo + "/category" , map [string ]string {"category_id" : catA }).Code )
578+ _ , err = f .db .session .InsertBySql (
579+ "INSERT INTO user_conversation_ext (uid, space_id, target_type, target_id, group_unfollowed, auto_follow_threads) " +
580+ "VALUES (?, ?, 2, ?, 0, 1)" ,
581+ testutil .UID , spaceID , groupNo ,
582+ ).Exec ()
583+ require .NoError (t , err )
584+
585+ // Move A → B.
586+ w := doRequest (t , route , "PUT" , "/v1/groups/" + groupNo + "/category" , map [string ]string {"category_id" : catB })
587+ require .Equal (t , http .StatusOK , w .Code )
588+
589+ var autoFollow int
590+ _ , err = f .db .session .SelectBySql (
591+ "SELECT auto_follow_threads FROM user_conversation_ext" +
592+ " WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?" ,
593+ testutil .UID , spaceID , groupNo ,
594+ ).Load (& autoFollow )
595+ require .NoError (t , err )
596+ assert .Equal (t , 1 , autoFollow ,
597+ "category A→B move must NOT clear auto_follow_threads — the group is " +
598+ "still in the follow tab, just under a different category" )
599+ }
600+
601+ // TestCategory_MoveGroupBackIntoCategory_RestoresAutoFollowThreads pins
602+ // issue #151 review #4 (an9xyz) symptom #1: a default-followed group that
603+ // has been materialized and then moved out must have auto_follow_threads
604+ // restored to 1 when it is moved back into any category. Otherwise the
605+ // sidebar materialization branch skips the existing groupExts entry,
606+ // the group reappears in the follow tab via buildFollowItems, but
607+ // selectEligibleForFanoutTx still excludes the user (=0) — phantom missing
608+ // fan-out.
609+ func TestCategory_MoveGroupBackIntoCategory_RestoresAutoFollowThreads (t * testing.T ) {
610+ s , ctx := testutil .NewTestServer ()
611+ f := New (ctx )
612+
613+ err := testutil .CleanAllTables (ctx )
614+ assert .NoError (t , err )
615+ resetUIDRateLimit (t , ctx )
616+
617+ spaceID := "space-move-back-001"
618+ seedSpaceAndMember (t , f , spaceID , 0 )
619+ route := s .GetRoute ()
620+
621+ wc := createCategory (t , route , spaceID , "工作" )
622+ require .Equal (t , http .StatusOK , wc .Code )
623+ catID := parseJSON (t , wc )["category_id" ].(string )
624+
625+ groupNo := "group-move-back-001"
626+ seedGroup (t , f , groupNo , spaceID )
627+
628+ // Cycle: move into category → simulate sidebar materialization → move
629+ // out (clears auto_follow_threads) → move back into the SAME category.
630+ require .Equal (t , http .StatusOK , doRequest (t , route , "PUT" , "/v1/groups/" + groupNo + "/category" , map [string ]string {"category_id" : catID }).Code )
631+ _ , err = f .db .session .InsertBySql (
632+ "INSERT INTO user_conversation_ext (uid, space_id, target_type, target_id, group_unfollowed, auto_follow_threads) " +
633+ "VALUES (?, ?, 2, ?, 0, 1)" ,
634+ testutil .UID , spaceID , groupNo ,
635+ ).Exec ()
636+ require .NoError (t , err , "simulate sidebar materialization" )
637+ require .Equal (t , http .StatusOK , doRequest (t , route , "PUT" , "/v1/groups/" + groupNo + "/category" , map [string ]string {"category_id" : "" }).Code ,
638+ "move out — must clear auto_follow_threads" )
639+
640+ var afterOut int
641+ _ , err = f .db .session .SelectBySql (
642+ "SELECT auto_follow_threads FROM user_conversation_ext" +
643+ " WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?" ,
644+ testutil .UID , spaceID , groupNo ,
645+ ).Load (& afterOut )
646+ require .NoError (t , err )
647+ require .Equal (t , 0 , afterOut , "precondition: move-out cleared auto_follow_threads" )
648+
649+ // Move BACK into the same category. Sidebar materialization would skip
650+ // this row (groupExts hit), so the move-in path itself must restore =1.
651+ require .Equal (t , http .StatusOK , doRequest (t , route , "PUT" , "/v1/groups/" + groupNo + "/category" , map [string ]string {"category_id" : catID }).Code )
652+
653+ var afterIn int
654+ _ , err = f .db .session .SelectBySql (
655+ "SELECT auto_follow_threads FROM user_conversation_ext" +
656+ " WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?" ,
657+ testutil .UID , spaceID , groupNo ,
658+ ).Load (& afterIn )
659+ require .NoError (t , err )
660+ assert .Equal (t , 1 , afterIn ,
661+ "move-in must restore auto_follow_threads=1 on an existing ext row " +
662+ "(issue #151 review #4 symptom #1); sidebar materialization would " +
663+ "otherwise skip the existing row, leaving OnThreadCreated fan-out " +
664+ "disabled even though the group is back in the follow tab" )
665+ }
666+
667+ // TestCategory_MoveFirstTimeIntoCategory_NoOpRestore ensures the move-in
668+ // restore call is a safe no-op when no ext row has been materialized yet —
669+ // sidebar materialization at the next /v1/sidebar/sync creates the row with
670+ // auto_follow_threads=1 anyway, and the move-in handler must not
671+ // short-circuit any subsequent paths or write inappropriate rows.
672+ func TestCategory_MoveFirstTimeIntoCategory_NoOpRestore (t * testing.T ) {
673+ s , ctx := testutil .NewTestServer ()
674+ f := New (ctx )
675+
676+ err := testutil .CleanAllTables (ctx )
677+ assert .NoError (t , err )
678+ resetUIDRateLimit (t , ctx )
679+
680+ spaceID := "space-move-first-001"
681+ seedSpaceAndMember (t , f , spaceID , 0 )
682+ route := s .GetRoute ()
683+
684+ wc := createCategory (t , route , spaceID , "工作" )
685+ require .Equal (t , http .StatusOK , wc .Code )
686+ catID := parseJSON (t , wc )["category_id" ].(string )
687+
688+ groupNo := "group-move-first-001"
689+ seedGroup (t , f , groupNo , spaceID )
690+
691+ // Move into category for the first time — no ext row exists yet.
692+ require .Equal (t , http .StatusOK , doRequest (t , route , "PUT" , "/v1/groups/" + groupNo + "/category" , map [string ]string {"category_id" : catID }).Code )
693+
694+ // No row should have been written by the move-in path — sidebar's
695+ // MaterializeDefaultFollowedGroups is the canonical materialization site
696+ // and stays solely responsible for creating ext rows. Letting the
697+ // move-in path INSERT here would race with the unique key and silently
698+ // pick whichever flag set ends up committing first.
699+ var count int
700+ _ , err = f .db .session .SelectBySql (
701+ "SELECT COUNT(*) FROM user_conversation_ext" +
702+ " WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?" ,
703+ testutil .UID , spaceID , groupNo ,
704+ ).Load (& count )
705+ require .NoError (t , err )
706+ assert .Equal (t , 0 , count ,
707+ "first-time move-in must NOT create an ext row — sidebar materialization " +
708+ "is the single materialization site; RestoreAutoFollowThreadsTx is " +
709+ "strictly UPDATE" )
710+ }
711+
430712// ---------- Validation / Error Tests ----------
431713
432714func TestCategory_CreateLimit (t * testing.T ) {
0 commit comments