Files
code-utils/team_integration_test.go
2026-08-14 07:52:01 +08:00

284 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}