package main // 团队协作本地单测:徽标计数、共享打标置脏、资料校验、历史节点与时间解析。 // 团队远端流程见 team_integration_test.go(需要 TEST_API_URL)。 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) } } }