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>
345 lines
10 KiB
Go
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)
|
|
}
|
|
}
|