228 lines
6.7 KiB
Go
228 lines
6.7 KiB
Go
|
|
package main
|
|||
|
|
|
|||
|
|
// 团队协作本地单测:徽标计数、共享打标置脏、资料校验、历史节点与时间解析。
|
|||
|
|
// 团队远端流程见 team_integration_test.go(需要本机 MySQL)。
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"path/filepath"
|
|||
|
|
"strings"
|
|||
|
|
"testing"
|
|||
|
|
"time"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
func newBadgeApp(t *testing.T) (*App, *Store) {
|
|||
|
|
t.Helper()
|
|||
|
|
s, e := OpenStore(filepath.Join(t.TempDir(), "team.db"))
|
|||
|
|
if e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
t.Cleanup(func() { s.db.Close() })
|
|||
|
|
a := NewApp()
|
|||
|
|
a.store = s
|
|||
|
|
return a, s
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestNavBadgesLocalCounts(t *testing.T) {
|
|||
|
|
a, s := newBadgeApp(t)
|
|||
|
|
b, e := a.GetNavBadges()
|
|||
|
|
if e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
if b.Unread != 0 || b.TodosOpen != 0 || b.TicketsActive != 0 || b.TodayDue != 0 || b.CalendarDot {
|
|||
|
|
t.Fatalf("empty db should have zero badges: %+v", b)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
today := time.Now().Format("2006-01-02")
|
|||
|
|
if _, e := a.SaveTodo(Todo{Title: "今天到期", DueAt: today}); e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
if _, e := a.SaveTodo(Todo{Title: "已逾期", DueAt: "2020-01-01"}); e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
done, e := a.SaveTodo(Todo{Title: "已完成", DueAt: today})
|
|||
|
|
if e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
if e := s.SetTodoStatus(done.ID, "done"); e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
pid, e := s.SaveProject(0, ProjectInput{Name: "BadgeProj", Path: t.TempDir()})
|
|||
|
|
if e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
if _, e := a.SaveTicket(Ticket{Title: "进行中的工单", ProjectID: pid.ID, StartAt: today, DueAt: today + "T23:00", Status: "in_progress"}); e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
if _, e := s.AddMessage("todo_due", "提醒", "", "", 0); e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
b, e = a.GetNavBadges()
|
|||
|
|
if e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
if b.Unread != 1 {
|
|||
|
|
t.Fatalf("unread want 1, got %d", b.Unread)
|
|||
|
|
}
|
|||
|
|
if b.TodosOpen != 2 { // 完成的不算
|
|||
|
|
t.Fatalf("todosOpen want 2, got %d", b.TodosOpen)
|
|||
|
|
}
|
|||
|
|
if b.TicketsActive != 1 {
|
|||
|
|
t.Fatalf("ticketsActive want 1, got %d", b.TicketsActive)
|
|||
|
|
}
|
|||
|
|
if b.TodayDue != 3 { // 逾期 todo + 今天 todo + 今天工单
|
|||
|
|
t.Fatalf("todayDue want 3, got %d", b.TodayDue)
|
|||
|
|
}
|
|||
|
|
if !b.CalendarDot {
|
|||
|
|
t.Fatal("calendarDot should be on")
|
|||
|
|
}
|
|||
|
|
if b.TeamAssigned != 0 { // 未登录不读缓存
|
|||
|
|
t.Fatalf("teamAssigned want 0 when logged out, got %d", b.TeamAssigned)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSetItemTeamMarksDirty(t *testing.T) {
|
|||
|
|
a, s := newBadgeApp(t)
|
|||
|
|
todo, e := a.SaveTodo(Todo{Title: "可共享"})
|
|||
|
|
if e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
// 清脏位模拟已同步状态
|
|||
|
|
if _, e := s.db.Exec(`UPDATE todos SET dirty=0`); e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
var before string
|
|||
|
|
_ = s.db.QueryRow(`SELECT updated_at FROM todos WHERE id=?`, todo.ID).Scan(&before)
|
|||
|
|
|
|||
|
|
if e := a.SetTodoTeam(todo.ID, 7); e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
var teamID, dirty int64
|
|||
|
|
var after string
|
|||
|
|
if e := s.db.QueryRow(`SELECT team_id,dirty,updated_at FROM todos WHERE id=?`, todo.ID).Scan(&teamID, &dirty, &after); e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
if teamID != 7 || dirty != 1 {
|
|||
|
|
t.Fatalf("want team_id=7 dirty=1, got %d %d", teamID, dirty)
|
|||
|
|
}
|
|||
|
|
if after <= before {
|
|||
|
|
t.Fatalf("updated_at should advance for LWW: %q -> %q", before, after)
|
|||
|
|
}
|
|||
|
|
// 列表返回 teamId,取消共享归零
|
|||
|
|
list, e := a.ListTodos("all", 0)
|
|||
|
|
if e != nil || len(list) != 1 || list[0].TeamID != 7 {
|
|||
|
|
t.Fatalf("ListTodos should expose teamId=7: %v %+v", e, list)
|
|||
|
|
}
|
|||
|
|
if e := a.SetTodoTeam(todo.ID, 0); e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
if e := a.SetTodoTeam(9999, 1); e == nil || e.Error() != "NOT_FOUND" {
|
|||
|
|
t.Fatalf("missing todo should NOT_FOUND, got %v", e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSanitizeProfile(t *testing.T) {
|
|||
|
|
p := UserProfile{
|
|||
|
|
Nickname: " 年糕崽崽 ",
|
|||
|
|
Title: strings.Repeat("长", 60),
|
|||
|
|
Email: "a@b.co",
|
|||
|
|
Bio: " hi ",
|
|||
|
|
TechTags: []string{" Go ", "go", "Vue", "", strings.Repeat("x", 40)},
|
|||
|
|
}
|
|||
|
|
if e := sanitizeProfile(&p); e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
if p.Nickname != "年糕崽崽" || len([]rune(p.Title)) != 48 || p.Bio != "hi" {
|
|||
|
|
t.Fatalf("trim/clamp failed: %+v", p)
|
|||
|
|
}
|
|||
|
|
// 标签去重(大小写不敏感)+ 截断
|
|||
|
|
if len(p.TechTags) != 3 || p.TechTags[0] != "Go" || p.TechTags[1] != "Vue" || len(p.TechTags[2]) != 24 {
|
|||
|
|
t.Fatalf("tags dedupe/clip failed: %v", p.TechTags)
|
|||
|
|
}
|
|||
|
|
bad := UserProfile{Email: "not-an-email"}
|
|||
|
|
if e := sanitizeProfile(&bad); e == nil || e.Error() != "PROFILE_EMAIL_INVALID" {
|
|||
|
|
t.Fatalf("want PROFILE_EMAIL_INVALID, got %v", e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestProfileLocalRoundtrip(t *testing.T) {
|
|||
|
|
a, _ := newBadgeApp(t)
|
|||
|
|
saved, e := a.SaveMyProfile(UserProfile{Nickname: "小李", TechTags: []string{"Go", "Vue"}})
|
|||
|
|
if e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
if saved.UpdatedAt == "" {
|
|||
|
|
t.Fatal("save should stamp updatedAt")
|
|||
|
|
}
|
|||
|
|
got, e := a.GetMyProfile()
|
|||
|
|
if e != nil || got.Nickname != "小李" || len(got.TechTags) != 2 {
|
|||
|
|
t.Fatalf("roundtrip failed: %v %+v", e, got)
|
|||
|
|
}
|
|||
|
|
if parsed := parseTechTags(`["a","b"]`); len(parsed) != 2 {
|
|||
|
|
t.Fatalf("parseTechTags: %v", parsed)
|
|||
|
|
}
|
|||
|
|
if parsed := parseTechTags(`{bad json`); len(parsed) != 0 {
|
|||
|
|
t.Fatalf("bad json should yield empty: %v", parsed)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestAppendHistoryNodeAndClock(t *testing.T) {
|
|||
|
|
h := appendHistoryNode("", "open", "2026-08-13T01:00:00Z")
|
|||
|
|
h = appendHistoryNode(h, "doing", "2026-08-13T02:00:00Z")
|
|||
|
|
if !strings.HasPrefix(h, `[{"status":"open"`) || !strings.Contains(h, `"doing"`) || !strings.HasSuffix(h, "]") {
|
|||
|
|
t.Fatalf("history chain broken: %s", h)
|
|||
|
|
}
|
|||
|
|
if got := appendHistoryNode("garbage", "done", "2026-08-13T03:00:00Z"); !strings.HasPrefix(got, `[{"status":"done"`) {
|
|||
|
|
t.Fatalf("garbage history should reset: %s", got)
|
|||
|
|
}
|
|||
|
|
for _, ok := range []string{"00:00", "21:00", "23:59"} {
|
|||
|
|
if _, valid := parseClockOK(ok); !valid {
|
|||
|
|
t.Fatalf("%s should be valid", ok)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for _, bad := range []string{"", "24:00", "9:70", "abc", "12"} {
|
|||
|
|
if _, valid := parseClockOK(bad); valid {
|
|||
|
|
t.Fatalf("%s should be invalid", bad)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestTeamRoleRank(t *testing.T) {
|
|||
|
|
if !(teamRoleRank("owner") > teamRoleRank("admin") && teamRoleRank("admin") > teamRoleRank("member") && teamRoleRank("member") > teamRoleRank("")) {
|
|||
|
|
t.Fatal("role ranking broken")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestMigrateFromV7AddsTeamID 模拟 v7 老库升级:删掉 team_id 列并回退版本号,
|
|||
|
|
// 重开库后迁移必须补回该列(防止只加迁移块忘了升 schemaVersion)。
|
|||
|
|
func TestMigrateFromV7AddsTeamID(t *testing.T) {
|
|||
|
|
path := filepath.Join(t.TempDir(), "old.db")
|
|||
|
|
s, e := OpenStore(path)
|
|||
|
|
if e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
for _, q := range []string{
|
|||
|
|
`ALTER TABLE todos DROP COLUMN team_id`,
|
|||
|
|
`ALTER TABLE tickets DROP COLUMN team_id`,
|
|||
|
|
`DELETE FROM schema_migrations WHERE version>=8`,
|
|||
|
|
`INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(7,'2026-01-01')`,
|
|||
|
|
} {
|
|||
|
|
if _, e := s.db.Exec(q); e != nil {
|
|||
|
|
t.Fatal(e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
s.db.Close()
|
|||
|
|
|
|||
|
|
s2, e := OpenStore(path)
|
|||
|
|
if e != nil {
|
|||
|
|
t.Fatalf("reopen v7 db: %v", e)
|
|||
|
|
}
|
|||
|
|
defer s2.db.Close()
|
|||
|
|
for _, table := range []string{"todos", "tickets"} {
|
|||
|
|
ok, e := s2.columnExists(table, "team_id")
|
|||
|
|
if e != nil || !ok {
|
|||
|
|
t.Fatalf("%s.team_id should be restored by migration (err=%v)", table, e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|