Files
task-master/internal/ytsync/youtrack_test.go
T
skrueckenandCursor 98d2253f44
CI / test-and-build (push) Failing after 1m19s
CI / demo (push) Skipped
Add YouTrack pull/push sync with project settings and time tracking.
Wire manual Y menu and CLI sync for issues and work items, persist
per-project credentials, and keep local edits until an explicit push.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 00:17:51 +02:00

345 lines
10 KiB
Go

package ytsync_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"task/internal/project"
"task/internal/ticket"
"task/internal/youtrack"
"task/internal/ytsync"
)
func TestMapStatus(t *testing.T) {
cases := map[string]ticket.Status{
"": ticket.StatusTodo,
"Open": ticket.StatusTodo,
"In Progress": ticket.StatusInProgress,
"Fixed": ticket.StatusDone,
"Done": ticket.StatusDone,
"Obsolete": ticket.StatusDone,
}
for in, want := range cases {
if got := ytsync.MapStatus(in); got != want {
t.Fatalf("MapStatus(%q) = %s, want %s", in, got, want)
}
}
}
func TestMapState(t *testing.T) {
if ytsync.MapState(ticket.StatusTodo) != "Open" {
t.Fatal("todo")
}
if ytsync.MapState(ticket.StatusInProgress) != "In Progress" {
t.Fatal("wip")
}
if ytsync.MapState(ticket.StatusDone) != "Fixed" {
t.Fatal("done")
}
}
func TestNeedsPush(t *testing.T) {
now := time.Now().UTC()
local := ticket.Ticket{Title: "x"}
if !ytsync.NeedsPush(local) {
t.Fatal("local without external id should push")
}
synced := ticket.Ticket{
Source: ticket.SourceYouTrack, ExternalID: "P-1",
UpdatedAt: now, SyncedAt: now,
}
if ytsync.NeedsPush(synced) {
t.Fatal("freshly synced should not push")
}
dirty := synced
dirty.UpdatedAt = now.Add(time.Minute)
if !ytsync.NeedsPush(dirty) {
t.Fatal("dirty should push")
}
}
func TestPullCreateAndUpdate(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
if calls == 1 {
_, _ = w.Write([]byte(`[
{"idReadable":"P-1","summary":"One","description":"d1","updated":1,
"customFields":[{"name":"State","value":{"name":"Open"}}]}
]`))
return
}
_, _ = w.Write([]byte(`[
{"idReadable":"P-1","summary":"One updated","description":"d2","updated":2,
"customFields":[{"name":"State","value":{"name":"In Progress"}}]},
{"idReadable":"P-2","summary":"Two","description":"","updated":2,
"customFields":[{"name":"State","value":{"name":"Fixed"}}]}
]`))
}))
defer srv.Close()
store := ticket.NewJSONStore(t.TempDir())
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
r1, err := syncer.Pull(context.Background(), "inbox", yt)
if err != nil {
t.Fatalf("pull1: %v", err)
}
if r1.Created != 1 || r1.Updated != 0 {
t.Fatalf("r1 = %+v", r1)
}
r2, err := syncer.Pull(context.Background(), "inbox", yt)
if err != nil {
t.Fatalf("pull2: %v", err)
}
if r2.Created != 1 || r2.Updated != 1 {
t.Fatalf("r2 = %+v", r2)
}
list, err := store.List("inbox")
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("len = %d", len(list))
}
byExt := map[string]ticket.Ticket{}
for _, tk := range list {
byExt[tk.ExternalID] = tk
}
if byExt["P-1"].Title != "One updated" || byExt["P-1"].Status != ticket.StatusInProgress {
t.Fatalf("P-1 = %+v", byExt["P-1"])
}
if byExt["P-2"].Status != ticket.StatusDone || byExt["P-2"].Source != ticket.SourceYouTrack {
t.Fatalf("P-2 = %+v", byExt["P-2"])
}
}
func TestPushCreateAndUpdate(t *testing.T) {
var createdBody, updatedBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/admin/projects/P":
_, _ = w.Write([]byte(`{"id":"0-1","shortName":"P"}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/issues":
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &createdBody)
_, _ = w.Write([]byte(`{"idReadable":"P-10","summary":"Local","description":"d"}`))
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/issues/P-10"):
_, _ = w.Write([]byte(`{
"idReadable":"P-10","summary":"Local","description":"d",
"customFields":[{"id":"1","name":"State","$type":"StateIssueCustomField","value":{"name":"Open"}}]
}`))
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/api/issues/P-10"):
b, _ := io.ReadAll(r.Body)
var payload map[string]any
_ = json.Unmarshal(b, &payload)
if _, ok := payload["customFields"]; ok {
// state set
w.WriteHeader(http.StatusOK)
return
}
_ = json.Unmarshal(b, &updatedBody)
_, _ = w.Write([]byte(`{"idReadable":"P-10","summary":"Local2","description":"d2"}`))
case r.Method == http.MethodGet && r.URL.Path == "/api/issues":
_, _ = w.Write([]byte(`[]`))
default:
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
store := ticket.NewJSONStore(t.TempDir())
local, err := store.Create(ticket.Ticket{
Title: "Local", Description: "d", ProjectID: "inbox", Status: ticket.StatusTodo,
})
if err != nil {
t.Fatal(err)
}
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
push, err := syncer.Push(context.Background(), "inbox", yt)
if err != nil {
t.Fatalf("push: %v", err)
}
if push.Created != 1 {
t.Fatalf("push = %+v", push)
}
if createdBody["summary"] != "Local" {
t.Fatalf("created body = %+v", createdBody)
}
got, err := store.Get(local.ID)
if err != nil {
t.Fatal(err)
}
if got.ExternalID != "P-10" || got.Source != ticket.SourceYouTrack {
t.Fatalf("linked = %+v", got)
}
if ytsync.NeedsPush(got) {
t.Fatalf("should not need push after create: updated=%v synced=%v", got.UpdatedAt, got.SyncedAt)
}
got.Title = "Local2"
got.Status = ticket.StatusInProgress
got.UpdatedAt = time.Now().UTC().Add(time.Minute)
if err := store.Update(got); err != nil {
t.Fatal(err)
}
got, _ = store.Get(local.ID)
if !ytsync.NeedsPush(got) {
t.Fatal("expected dirty after local edit")
}
push2, err := syncer.Push(context.Background(), "inbox", yt)
if err != nil {
t.Fatalf("push2: %v", err)
}
if push2.Updated != 1 {
t.Fatalf("push2 = %+v", push2)
}
if updatedBody["summary"] != "Local2" {
t.Fatalf("updated body = %+v", updatedBody)
}
}
func TestPushTimeEntries(t *testing.T) {
var workPosts int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/timeTracking/workItems"):
workPosts++
_, _ = w.Write([]byte(`{"id":"wi-9","date":1,"duration":{"minutes":15},"text":"n"}`))
default:
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
dir := t.TempDir()
store := ticket.NewJSONStore(dir)
timeStore := ticket.NewJSONTimeStore(dir)
tk, err := store.Create(ticket.Ticket{
Title: "T", ProjectID: "inbox", Status: ticket.StatusTodo,
Source: ticket.SourceYouTrack, ExternalID: "TAS-1",
})
if err != nil {
t.Fatal(err)
}
if err := store.MarkSynced(tk.ID); err != nil {
t.Fatal(err)
}
_, err = timeStore.Create(ticket.TimeEntry{
TicketID: tk.ID, ProjectID: "inbox",
Duration: 15 * time.Minute, Source: ticket.SourceManual, Note: "n",
})
if err != nil {
t.Fatal(err)
}
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
syncer := &ytsync.YouTrackSyncer{Store: store, TimeStore: timeStore, Client: client}
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "TAS"}
res, err := syncer.Push(context.Background(), "inbox", yt)
if err != nil {
t.Fatal(err)
}
if res.TimePushed != 1 || workPosts != 1 {
t.Fatalf("res=%+v posts=%d", res, workPosts)
}
entries, _ := timeStore.ListByTicket(tk.ID)
if entries[0].ExternalID != "wi-9" {
t.Fatalf("entry = %+v", entries[0])
}
}
func TestPullKeepsStatusWhenRemoteStateEmpty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.Contains(r.URL.Path, "/timeTracking/") {
_, _ = w.Write([]byte(`[]`))
return
}
// No State custom field → empty remote state must not wipe local WIP.
_, _ = w.Write([]byte(`[
{"idReadable":"P-1","summary":"One","description":"d","updated":2,"customFields":[]}
]`))
}))
defer srv.Close()
store := ticket.NewJSONStore(t.TempDir())
now := time.Now().UTC()
_, err := store.Create(ticket.Ticket{
Title: "One", ProjectID: "inbox", Status: ticket.StatusInProgress,
Source: ticket.SourceYouTrack, ExternalID: "P-1",
UpdatedAt: now, SyncedAt: now,
})
if err != nil {
t.Fatal(err)
}
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
if _, err := syncer.Pull(context.Background(), "inbox", yt); err != nil {
t.Fatal(err)
}
list, _ := store.List("inbox")
if list[0].Status != ticket.StatusInProgress {
t.Fatalf("status = %s, want in_progress", list[0].Status)
}
}
func TestPullSkipsDirtyLocal(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"idReadable":"P-1","summary":"Remote","description":"","updated":2,
"customFields":[{"name":"State","value":{"name":"Open"}}]}
]`))
}))
defer srv.Close()
store := ticket.NewJSONStore(t.TempDir())
now := time.Now().UTC()
_, err := store.Create(ticket.Ticket{
Title: "Local edit", ProjectID: "inbox", Status: ticket.StatusTodo,
Source: ticket.SourceYouTrack, ExternalID: "P-1",
UpdatedAt: now.Add(time.Hour), SyncedAt: now,
})
if err != nil {
t.Fatal(err)
}
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
res, err := syncer.Pull(context.Background(), "inbox", yt)
if err != nil {
t.Fatal(err)
}
if res.Skipped != 1 || res.Updated != 0 {
t.Fatalf("res = %+v", res)
}
list, _ := store.List("inbox")
if list[0].Title != "Local edit" {
t.Fatalf("should keep local title, got %q", list[0].Title)
}
}