284 lines
9.7 KiB
Go
284 lines
9.7 KiB
Go
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)
|
||
}
|
||
}
|