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>
267 lines
8.3 KiB
Go
267 lines
8.3 KiB
Go
package youtrack_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"task/internal/youtrack"
|
|
)
|
|
|
|
func TestListIssues(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/issues" {
|
|
t.Fatalf("path = %s", r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer tok" {
|
|
t.Fatalf("auth = %q", got)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[
|
|
{
|
|
"idReadable": "PROJ-1",
|
|
"summary": "Fix bug",
|
|
"description": "details",
|
|
"updated": 1700000000000,
|
|
"customFields": [
|
|
{"name": "State", "$type": "StateIssueCustomField", "value": {"name": "In Progress", "$type": "StateBundleElement"}}
|
|
]
|
|
}
|
|
]`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()}
|
|
issues, err := c.ListIssues(context.Background(), "project: PROJ")
|
|
if err != nil {
|
|
t.Fatalf("ListIssues: %v", err)
|
|
}
|
|
if len(issues) != 1 {
|
|
t.Fatalf("len = %d", len(issues))
|
|
}
|
|
iss := issues[0]
|
|
if iss.IDReadable != "PROJ-1" || iss.Summary != "Fix bug" || iss.State != "In Progress" {
|
|
t.Fatalf("issue = %+v", iss)
|
|
}
|
|
if c.IssueURL("PROJ-1") != srv.URL+"/issue/PROJ-1" {
|
|
t.Fatalf("url = %s", c.IssueURL("PROJ-1"))
|
|
}
|
|
}
|
|
|
|
func TestListIssuesHTTPError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "nope", http.StatusUnauthorized)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := &youtrack.Client{BaseURL: srv.URL, Token: "bad", HTTPClient: srv.Client()}
|
|
_, err := c.ListIssues(context.Background(), "project: X")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
}
|
|
|
|
func TestCreateAndUpdateIssueWithRenamedStateField(t *testing.T) {
|
|
var statePosts []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/PROJ":
|
|
_, _ = w.Write([]byte(`{"id":"0-1","shortName":"PROJ"}`))
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/issues":
|
|
body, _ := io.ReadAll(r.Body)
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
t.Fatalf("body: %v", err)
|
|
}
|
|
if _, hasCF := payload["customFields"]; hasCF {
|
|
t.Fatal("create must not send customFields for State")
|
|
}
|
|
_, _ = w.Write([]byte(`{"idReadable":"PROJ-9","summary":"New","description":"body"}`))
|
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/"):
|
|
// Field is named "Status", not "State".
|
|
_, _ = w.Write([]byte(`{
|
|
"idReadable":"PROJ-9","summary":"New","description":"body",
|
|
"customFields":[{
|
|
"id":"92-1","name":"Status","$type":"StateIssueCustomField",
|
|
"value":{"name":"Submitted"}
|
|
}]
|
|
}`))
|
|
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/issues/"):
|
|
body, _ := io.ReadAll(r.Body)
|
|
var payload map[string]any
|
|
_ = json.Unmarshal(body, &payload)
|
|
if cfs, ok := payload["customFields"].([]any); ok {
|
|
statePosts = append(statePosts, cfs[0].(map[string]any))
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if payload["summary"] == "Updated" {
|
|
_, _ = w.Write([]byte(`{"idReadable":"PROJ-9","summary":"Updated","description":"new"}`))
|
|
return
|
|
}
|
|
t.Fatalf("unexpected post body: %s", body)
|
|
default:
|
|
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()}
|
|
created, err := c.CreateIssue(context.Background(), "PROJ", "New", "body", "Open")
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue: %v", err)
|
|
}
|
|
if created.IDReadable != "PROJ-9" {
|
|
t.Fatalf("created = %+v", created)
|
|
}
|
|
|
|
updated, err := c.UpdateIssue(context.Background(), "PROJ-9", "Updated", "new", "In Progress")
|
|
if err != nil {
|
|
t.Fatalf("UpdateIssue: %v", err)
|
|
}
|
|
if updated.Summary != "Updated" {
|
|
t.Fatalf("updated = %+v", updated)
|
|
}
|
|
if len(statePosts) < 1 {
|
|
t.Fatal("expected state field posts")
|
|
}
|
|
for _, p := range statePosts {
|
|
if p["name"] != "Status" {
|
|
t.Fatalf("expected field name Status, got %v", p["name"])
|
|
}
|
|
if p["$type"] != "StateIssueCustomField" {
|
|
t.Fatalf("$type = %v", p["$type"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetStateMachineEvent(t *testing.T) {
|
|
var posted 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:
|
|
_, _ = w.Write([]byte(`{
|
|
"idReadable":"TAS-1","summary":"x",
|
|
"customFields":[{
|
|
"id":"82-11","name":"Stage","$type":"StateMachineIssueCustomField",
|
|
"value":{"name":"Open"},
|
|
"possibleEvents":[
|
|
{"id":"start","presentation":"Start progress","$type":"Event"},
|
|
{"id":"fix","presentation":"Fixed","$type":"Event"}
|
|
]
|
|
}]
|
|
}`))
|
|
case r.Method == http.MethodPost:
|
|
body, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(body, &posted)
|
|
w.WriteHeader(http.StatusOK)
|
|
default:
|
|
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()}
|
|
if err := c.SetState(context.Background(), "TAS-1", "In Progress"); err != nil {
|
|
t.Fatalf("SetState: %v", err)
|
|
}
|
|
cfs := posted["customFields"].([]any)
|
|
cf := cfs[0].(map[string]any)
|
|
if cf["name"] != "Stage" || cf["$type"] != "StateMachineIssueCustomField" {
|
|
t.Fatalf("cf = %+v", cf)
|
|
}
|
|
ev := cf["event"].(map[string]any)
|
|
if ev["id"] != "start" {
|
|
t.Fatalf("event = %+v (want fuzzy match to Start progress)", ev)
|
|
}
|
|
}
|
|
|
|
func TestToIssueReadsStatusField(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":"TAS-1","summary":"x",
|
|
"customFields":[{
|
|
"id":"1","name":"Status","$type":"StateIssueCustomField",
|
|
"value":{"name":"In Progress"}
|
|
}]
|
|
}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()}
|
|
iss, err := c.GetIssue(context.Background(), "TAS-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if iss.State != "In Progress" {
|
|
t.Fatalf("state = %q", iss.State)
|
|
}
|
|
}
|
|
|
|
func TestCreateAndListWorkItems(t *testing.T) {
|
|
var created 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 && strings.Contains(r.URL.Path, "/timeTracking/workItems"):
|
|
_, _ = w.Write([]byte(`[{"id":"wi-1","date":1700000000000,"text":"x","duration":{"minutes":30}}]`))
|
|
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/timeTracking/workItems"):
|
|
body, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(body, &created)
|
|
_, _ = w.Write([]byte(`{"id":"wi-2","date":1700000000000,"text":"note","duration":{"minutes":45}}`))
|
|
default:
|
|
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()}
|
|
list, err := c.ListWorkItems(context.Background(), "TAS-1")
|
|
if err != nil || len(list) != 1 || list[0].Minutes != 30 {
|
|
t.Fatalf("list = %+v err=%v", list, err)
|
|
}
|
|
wi, err := c.CreateWorkItem(context.Background(), "TAS-1", 45, time.UnixMilli(1700000000000), "note")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if wi.ID != "wi-2" {
|
|
t.Fatalf("wi = %+v", wi)
|
|
}
|
|
dur := created["duration"].(map[string]any)
|
|
if int(dur["minutes"].(float64)) != 45 {
|
|
t.Fatalf("created = %+v", created)
|
|
}
|
|
}
|
|
|
|
func TestApplyCommand(t *testing.T) {
|
|
var got string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/commands" {
|
|
t.Fatalf("path %s", r.URL.Path)
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
var payload map[string]any
|
|
_ = json.Unmarshal(body, &payload)
|
|
got = fmt.Sprint(payload["query"])
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()}
|
|
if err := c.ApplyCommand(context.Background(), "TAS-1", "Status Open"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != "Status Open" {
|
|
t.Fatalf("query = %q", got)
|
|
}
|
|
}
|