package main // 团队协作全链路集成测试(三用户:boss=owner、alice=admin、bob=member): // 建团队 → 邀请 → 角色权限校验 → 任务指派/流转/催办 → 通知拉取转本地消息 → // 共享个人待办 → 日报提交/看板可见性/催交 → 无 Key 摘要报 AI_NO_KEY。 // 复用 sync_integration_test.go 的 itDDL;需要本机 MySQL(root/root),不可达自动跳过。 import ( "context" "database/sql" "fmt" "path/filepath" "strings" "testing" "time" ) // newTeamITApp 建一个已注册并登录的客户端(独立本地库 + 独立机器码)。 func newTeamITApp(t *testing.T, dbName, user string) (*App, *Store) { t.Helper() s, e := OpenStore(filepath.Join(t.TempDir(), user+".db")) if e != nil { t.Fatal(e) } t.Cleanup(func() { s.db.Close() }) _ = s.SetMeta("machine_id", fmt.Sprintf("%016x", time.Now().UnixNano())) a := NewApp() a.store = s a.ctx = context.Background() if e = a.SaveSyncConfig(SyncConfig{Host: "127.0.0.1", Port: 3306, User: "root", Password: "root", Database: dbName}); e != nil { t.Fatal(e) } if e := a.SyncRegister(user, user+"-pass1"); e != nil { t.Fatalf("register %s: %v", user, e) } if _, e := a.SyncLogin(user, user+"-pass1"); e != nil { t.Fatalf("login %s: %v", user, e) } return a, s } func TestTeamFlowIntegration(t *testing.T) { boot, e := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/?timeout=2s") if e != nil { t.Skip("mysql driver: " + e.Error()) } defer boot.Close() if e = boot.Ping(); e != nil { t.Skip("local MySQL not available: " + e.Error()) } dbName := fmt.Sprintf("cc_team_it_%d", time.Now().UnixNano()) if _, e = boot.Exec("CREATE DATABASE " + dbName + " DEFAULT CHARSET utf8mb4"); e != nil { t.Skip("cannot create scratch db: " + e.Error()) } defer boot.Exec("DROP DATABASE " + dbName) remote, e := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/"+dbName+"?timeout=2s") if e != nil { t.Fatal(e) } defer remote.Close() for _, ddl := range itDDL { if _, e = remote.Exec(ddl); e != nil { t.Fatal(e) } } boss, _ := newTeamITApp(t, dbName, "boss") alice, _ := newTeamITApp(t, dbName, "alice") bob, bobStore := newTeamITApp(t, dbName, "bob") // —— 建团队 + 邀请 + 角色 —— team, e := boss.TeamCreate("先锋队") if e != nil || team.ID == 0 || team.Role != "owner" { t.Fatalf("create team: %v %+v", e, team) } if e := boss.TeamInvite(team.ID, "ghost", "member"); e == nil || e.Error() != "TEAM_USER_NOT_FOUND" { t.Fatalf("invite unknown user want TEAM_USER_NOT_FOUND, got %v", e) } if e := boss.TeamInvite(team.ID, "alice", "admin"); e != nil { t.Fatal(e) } if e := boss.TeamInvite(team.ID, "bob", "member"); e != nil { t.Fatal(e) } if e := boss.TeamInvite(team.ID, "bob", "member"); e == nil || e.Error() != "TEAM_ALREADY_MEMBER" { t.Fatalf("re-invite want TEAM_ALREADY_MEMBER, got %v", e) } teams, e := bob.TeamList() if e != nil || len(teams) != 1 || teams[0].Role != "member" || teams[0].Members != 3 { t.Fatalf("bob TeamList: %v %+v", e, teams) } if e := bob.TeamSwitch(team.ID); e != nil { t.Fatal(e) } if bobStore.Meta("current_team_id") != fmt.Sprint(team.ID) { t.Fatal("TeamSwitch should persist current_team_id") } // 资料同步后成员列表能看到昵称/技术栈。 if _, e := bob.SaveMyProfile(UserProfile{Nickname: "小北", TechTags: []string{"Go", "Vue"}}); e != nil { t.Fatal(e) } syncNowRetry(t, bob) members, e := alice.TeamMembers(team.ID) if e != nil || len(members) != 3 { t.Fatalf("members: %v %+v", e, members) } var bobSeen bool for _, m := range members { if m.Username == "bob" { bobSeen = m.Nickname == "小北" && len(m.TechTags) == 2 } } if !bobSeen { t.Fatalf("bob profile should be visible to teammates: %+v", members) } var bobID int64 if e := remote.QueryRow(`SELECT id FROM users WHERE username='bob'`).Scan(&bobID); e != nil { t.Fatal(e) } // —— 权限:member 不能建任务/邀请/改角色;owner 才能改角色 —— if _, e := bob.TeamTaskSave(TeamTask{TeamID: team.ID, Kind: "todo", Title: "越权"}); e == nil || e.Error() != "TEAM_FORBIDDEN" { t.Fatalf("member create task want TEAM_FORBIDDEN, got %v", e) } if e := bob.TeamInvite(team.ID, "alice", "member"); e == nil || e.Error() != "TEAM_FORBIDDEN" { t.Fatalf("member invite want TEAM_FORBIDDEN, got %v", e) } if e := alice.TeamSetRole(team.ID, bobID, "admin"); e == nil || e.Error() != "TEAM_FORBIDDEN" { t.Fatalf("admin set role want TEAM_FORBIDDEN (owner only), got %v", e) } if e := boss.TeamLeave(team.ID); e == nil || e.Error() != "TEAM_OWNER_CANNOT_LEAVE" { t.Fatalf("owner leave want TEAM_OWNER_CANNOT_LEAVE, got %v", e) } // —— 任务:admin 创建并指派给 bob → 催办 → bob 流转 —— task, e := alice.TeamTaskSave(TeamTask{TeamID: team.ID, Kind: "ticket", Title: "接入支付", Priority: "high", AssigneeID: bobID, DueAt: "2026-08-20"}) if e != nil || task.ID == 0 || task.Status != "open" { t.Fatalf("task save: %v %+v", e, task) } if e := alice.TeamTaskUrge(team.ID, task.ID); e != nil { t.Fatal(e) } // alice(非负责人、admin)也可流转;随后 bob 作为负责人完成。 if e := alice.TeamTaskSetStatus(team.ID, task.ID, "doing"); e != nil { t.Fatal(e) } if e := bob.TeamTaskSetStatus(team.ID, task.ID, "done"); e != nil { t.Fatal(e) } list, e := bob.TeamTaskList(team.ID, "mine") if e != nil || len(list) != 1 || list[0].Status != "done" || !strings.Contains(list[0].History, `"doing"`) { t.Fatalf("task list mine: %v %+v", e, list) } // —— 通知:bob 同步后收到 指派+催办 的本地消息;游标不回退 —— countTeamMsgs := func() (assign, urge, total int) { t.Helper() msgs, e := bob.ListMessages(100) if e != nil { t.Fatal(e) } for _, m := range msgs { if m.Kind != "team" { continue } total++ if strings.Contains(m.Body, "指派") { assign++ } if strings.Contains(m.Title, "催办") { urge++ } } return } syncNowRetry(t, bob) gotAssign, gotUrge, before := countTeamMsgs() if gotAssign == 0 || gotUrge == 0 { t.Fatalf("bob should receive assign+urge notices, got assign=%d urge=%d", gotAssign, gotUrge) } // 游标前进:再次同步不重复入库。 syncNowRetry(t, bob) if _, _, after := countTeamMsgs(); after != before { t.Fatalf("notices should not duplicate: %d -> %d", before, after) } // —— 共享个人待办:bob 标记 team_id → 同步上云 → 管理员可见,成员也可见 —— todo, e := bob.SaveTodo(Todo{Title: "私活变共享", DueAt: "2026-08-15"}) if e != nil { t.Fatal(e) } if e := bob.SetTodoTeam(todo.ID, team.ID); e != nil { t.Fatal(e) } syncNowRetry(t, bob) shared, e := alice.TeamSharedItems(team.ID) if e != nil || len(shared) != 1 || shared[0].Title != "私活变共享" || shared[0].Owner != "小北" { t.Fatalf("admin shared items: %v %+v", e, shared) } if e := alice.TeamUrgeShared(team.ID, shared[0].Kind, shared[0].UUID); e != nil { t.Fatal(e) } // 取消共享后云端行 team_id 归零,共享列表清空。 if e := bob.SetTodoTeam(todo.ID, 0); e != nil { t.Fatal(e) } syncNowRetry(t, bob) if shared, _ = alice.TeamSharedItems(team.ID); len(shared) != 0 { t.Fatalf("unshare should clear list, got %+v", shared) } // —— 日报:两人提交;看板可见性按角色;催交;无 Key 摘要报 AI_NO_KEY —— day := time.Now().Format("2006-01-02") if e := bob.TeamReportSubmit(team.ID, day, "完成支付接入联调"); e != nil { t.Fatal(e) } if e := alice.TeamReportSubmit(team.ID, day, "评审两个方案"); e != nil { t.Fatal(e) } if e := alice.TeamReportSubmit(team.ID, "2026/08/13", "坏日期"); e == nil || e.Error() != "TEAM_DATE_INVALID" { t.Fatalf("want TEAM_DATE_INVALID, got %v", e) } adminBoard, e := alice.TeamReportBoardGet(team.ID, day) if e != nil || len(adminBoard.Reports) != 2 || len(adminBoard.Missing) != 1 || adminBoard.Missing[0].Username != "boss" { t.Fatalf("admin board: %v %+v", e, adminBoard) } for _, r := range adminBoard.Reports { if r.Content == "" { t.Fatalf("admin should see all report contents: %+v", adminBoard.Reports) } } memberBoard, e := bob.TeamReportBoardGet(team.ID, day) if e != nil { t.Fatal(e) } for _, r := range memberBoard.Reports { if r.User != "小北" && r.Content != "" { t.Fatalf("member must not see others' content: %+v", memberBoard.Reports) } } var bossID int64 _ = remote.QueryRow(`SELECT id FROM users WHERE username='boss'`).Scan(&bossID) if e := alice.TeamReportUrge(team.ID, bossID, day); e != nil { t.Fatal(e) } if e := bob.TeamDigestGenerate(team.ID, day); e == nil || !strings.Contains(e.Error(), "AI_KEY_MISSING") { // 未配置 Key:AI_KEY_MISSING 先于权限校验返回 t.Fatalf("digest without key want AI_KEY_MISSING, got %v", e) } // —— 成员管理收尾:owner 升 bob 为 admin,admin 移除成员,成员退出 —— if e := boss.TeamSetRole(team.ID, bobID, "admin"); e != nil { t.Fatal(e) } if e := bob.TeamRename(team.ID, "先锋二队"); e == nil || e.Error() != "TEAM_FORBIDDEN" { t.Fatalf("rename is owner-only, got %v", e) } if e := boss.TeamRename(team.ID, "先锋二队"); e != nil { t.Fatal(e) } if e := boss.TeamSetDigestTime(team.ID, "25:00"); e == nil { t.Fatal("bad digest time should be rejected") } if e := boss.TeamSetDigestTime(team.ID, "20:30"); e != nil { t.Fatal(e) } if e := alice.TeamLeave(team.ID); e != nil { t.Fatal(e) } if teams, _ := alice.TeamList(); len(teams) != 0 { t.Fatalf("alice left, TeamList should be empty: %+v", teams) } if e := boss.TeamDissolve(team.ID); e != nil { t.Fatal(e) } var n int _ = remote.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id=?`, team.ID).Scan(&n) if n != 0 { t.Fatalf("dissolve should clear members, got %d", n) } }