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) } }