Files
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

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