Files
code-utils/team_integration_test.go

284 lines
9.7 KiB
Go
Raw Normal View History

2026-08-14 07:52:01 +08:00
package main
// 团队协作全链路集成测试三用户boss=owner、alice=admin、bob=member
// 建团队 → 邀请 → 角色权限校验 → 任务指派/流转/催办 → 通知拉取转本地消息 →
// 共享个人待办 → 日报提交/看板可见性/催交 → 无 Key 摘要报 AI_NO_KEY。
// 复用 sync_integration_test.go 的 itDDL需要本机 MySQLroot/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") {
// 未配置 KeyAI_KEY_MISSING 先于权限校验返回
t.Fatalf("digest without key want AI_KEY_MISSING, got %v", e)
}
// —— 成员管理收尾owner 升 bob 为 adminadmin 移除成员,成员退出 ——
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)
}
}