diff --git a/internal/linear/client.go b/internal/linear/client.go index e6ef333..153062e 100644 --- a/internal/linear/client.go +++ b/internal/linear/client.go @@ -18,6 +18,19 @@ const ( networkTimeout = 30 * time.Second ) +// UploadHeader is a key/value pair required when uploading to a pre-signed URL. +type UploadHeader struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// FileUploadInfo holds the result of a Linear file upload request. +type FileUploadInfo struct { + UploadURL string + AssetURL string + Headers []UploadHeader +} + // Client is the Linear GraphQL API client. type Client struct { endpoint string @@ -410,6 +423,165 @@ func (c *Client) TransitionIssueState(ctx context.Context, issueID, targetStateN return nil } +// FetchIssueIDByIdentifier resolves a human-readable issue identifier (e.g. "ZYX-75") to its internal UUID. +func (c *Client) FetchIssueIDByIdentifier(ctx context.Context, identifier string) (string, error) { + query := `query($identifier: String!) { + issues(filter: { identifier: { eq: $identifier } }) { + nodes { + id + identifier + } + } + }` + + resp, err := c.doQuery(ctx, query, map[string]any{"identifier": identifier}) + if err != nil { + return "", fmt.Errorf("linear_api_request: fetch issue by identifier: %w", err) + } + + var result struct { + Data struct { + Issues struct { + Nodes []struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + } `json:"nodes"` + } `json:"issues"` + } `json:"data"` + Errors []graphqlError `json:"errors"` + } + if err := json.Unmarshal(resp, &result); err != nil { + return "", fmt.Errorf("linear_unknown_payload: %w", err) + } + if len(result.Errors) > 0 { + return "", fmt.Errorf("linear_graphql_errors: %s", result.Errors[0].Message) + } + if len(result.Data.Issues.Nodes) == 0 { + return "", fmt.Errorf("linear_issue_not_found: no issue with identifier %q", identifier) + } + + return result.Data.Issues.Nodes[0].ID, nil +} + +// RequestFileUpload requests a pre-signed upload URL from Linear for a file attachment. +func (c *Client) RequestFileUpload(ctx context.Context, filename, contentType string, size int) (*FileUploadInfo, error) { + mutation := `mutation($contentType: String!, $filename: String!, $size: Int!) { + fileUpload(contentType: $contentType, filename: $filename, size: $size) { + uploadFile { + uploadUrl + assetUrl + headers { + key + value + } + } + } + }` + + resp, err := c.doQuery(ctx, mutation, map[string]any{ + "contentType": contentType, + "filename": filename, + "size": size, + }) + if err != nil { + return nil, fmt.Errorf("linear_api_request: file upload: %w", err) + } + + var result struct { + Data struct { + FileUpload struct { + UploadFile struct { + UploadURL string `json:"uploadUrl"` + AssetURL string `json:"assetUrl"` + Headers []UploadHeader `json:"headers"` + } `json:"uploadFile"` + } `json:"fileUpload"` + } `json:"data"` + Errors []graphqlError `json:"errors"` + } + if err := json.Unmarshal(resp, &result); err != nil { + return nil, fmt.Errorf("linear_unknown_payload: %w", err) + } + if len(result.Errors) > 0 { + return nil, fmt.Errorf("linear_graphql_errors: %s", result.Errors[0].Message) + } + + f := result.Data.FileUpload.UploadFile + return &FileUploadInfo{ + UploadURL: f.UploadURL, + AssetURL: f.AssetURL, + Headers: f.Headers, + }, nil +} + +// UploadFileToURL uploads data to a pre-signed URL returned by RequestFileUpload. +func (c *Client) UploadFileToURL(ctx context.Context, info *FileUploadInfo, contentType string, size int64, data io.Reader) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPut, info.UploadURL, data) + if err != nil { + return fmt.Errorf("linear_file_upload: create request: %w", err) + } + req.ContentLength = size + req.Header.Set("Content-Type", contentType) + for _, h := range info.Headers { + req.Header.Set(h.Key, h.Value) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("linear_file_upload: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("linear_file_upload_status: %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// CreateAttachment creates an attachment on a Linear issue linking to the given URL. +func (c *Client) CreateAttachment(ctx context.Context, issueID, title, url string) error { + mutation := `mutation($issueId: String!, $title: String!, $url: String!) { + attachmentCreate(input: { issueId: $issueId, title: $title, url: $url }) { + success + attachment { + id + url + } + } + }` + + resp, err := c.doQuery(ctx, mutation, map[string]any{ + "issueId": issueID, + "title": title, + "url": url, + }) + if err != nil { + return fmt.Errorf("linear_api_request: create attachment: %w", err) + } + + var result struct { + Data struct { + AttachmentCreate struct { + Success bool `json:"success"` + } `json:"attachmentCreate"` + } `json:"data"` + Errors []graphqlError `json:"errors"` + } + if err := json.Unmarshal(resp, &result); err != nil { + return fmt.Errorf("linear_unknown_payload: %w", err) + } + if len(result.Errors) > 0 { + return fmt.Errorf("linear_graphql_errors: %s", result.Errors[0].Message) + } + if !result.Data.AttachmentCreate.Success { + return fmt.Errorf("linear_attachment_failed: attachmentCreate returned success=false") + } + + return nil +} + func (c *Client) fetchIssuePage(ctx context.Context, activeStates []string, cursor *string) ([]model.Issue, *string, error) { query := `query($projectSlug: String!, $stateNames: [String!]!, $first: Int!, $after: String) { issues( diff --git a/internal/linear/client_test.go b/internal/linear/client_test.go index 444a522..84ea01f 100644 --- a/internal/linear/client_test.go +++ b/internal/linear/client_test.go @@ -258,3 +258,195 @@ func TestCreateIssue_SuccessFalse(t *testing.T) { t.Errorf("expected %q, got %q", want, err.Error()) } } + +func TestFetchIssueIDByIdentifier_Success(t *testing.T) { + called := false + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + called = true + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + vars := req["variables"].(map[string]any) + if vars["identifier"] != "ZYX-75" { + t.Errorf("expected identifier=ZYX-75, got %v", vars["identifier"]) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "issues": map[string]any{ + "nodes": []map[string]any{ + {"id": "uuid-abc", "identifier": "ZYX-75"}, + }, + }, + }, + }) + }) + + id, err := client.FetchIssueIDByIdentifier(context.Background(), "ZYX-75") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != "uuid-abc" { + t.Errorf("expected uuid-abc, got %s", id) + } + if !called { + t.Fatal("handler was not called") + } +} + +func TestFetchIssueIDByIdentifier_NotFound(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "issues": map[string]any{"nodes": []any{}}, + }, + }) + }) + + _, err := client.FetchIssueIDByIdentifier(context.Background(), "ZYX-99") + if err == nil { + t.Fatal("expected error, got nil") + } + if want := `linear_issue_not_found: no issue with identifier "ZYX-99"`; err.Error() != want { + t.Errorf("expected %q, got %q", want, err.Error()) + } +} + +func TestRequestFileUpload_Success(t *testing.T) { + called := false + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + called = true + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + vars := req["variables"].(map[string]any) + if vars["filename"] != "recording.webm" { + t.Errorf("expected filename=recording.webm, got %v", vars["filename"]) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "fileUpload": map[string]any{ + "uploadFile": map[string]any{ + "uploadUrl": "https://s3.example.com/upload", + "assetUrl": "https://cdn.example.com/asset.webm", + "headers": []map[string]any{ + {"key": "x-amz-acl", "value": "public-read"}, + }, + }, + }, + }, + }) + }) + + info, err := client.RequestFileUpload(context.Background(), "recording.webm", "video/webm", 1024) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.UploadURL != "https://s3.example.com/upload" { + t.Errorf("unexpected UploadURL: %s", info.UploadURL) + } + if info.AssetURL != "https://cdn.example.com/asset.webm" { + t.Errorf("unexpected AssetURL: %s", info.AssetURL) + } + if len(info.Headers) != 1 || info.Headers[0].Key != "x-amz-acl" { + t.Errorf("unexpected headers: %+v", info.Headers) + } + if !called { + t.Fatal("handler was not called") + } +} + +func TestRequestFileUpload_GraphQLError(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]any{{"message": "unauthorized"}}, + }) + }) + + _, err := client.RequestFileUpload(context.Background(), "rec.webm", "video/webm", 512) + if err == nil { + t.Fatal("expected error, got nil") + } + if want := "linear_graphql_errors: unauthorized"; err.Error() != want { + t.Errorf("expected %q, got %q", want, err.Error()) + } +} + +func TestCreateAttachment_Success(t *testing.T) { + called := false + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + called = true + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + vars := req["variables"].(map[string]any) + if vars["issueId"] != "issue-123" { + t.Errorf("expected issueId=issue-123, got %v", vars["issueId"]) + } + if vars["url"] != "https://cdn.example.com/rec.webm" { + t.Errorf("expected url=..., got %v", vars["url"]) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "attachmentCreate": map[string]any{ + "success": true, + "attachment": map[string]any{ + "id": "att-1", + "url": "https://cdn.example.com/rec.webm", + }, + }, + }, + }) + }) + + err := client.CreateAttachment(context.Background(), "issue-123", "Screen recording", "https://cdn.example.com/rec.webm") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !called { + t.Fatal("handler was not called") + } +} + +func TestCreateAttachment_SuccessFalse(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "attachmentCreate": map[string]any{"success": false}, + }, + }) + }) + + err := client.CreateAttachment(context.Background(), "issue-123", "rec", "https://example.com/v.webm") + if err == nil { + t.Fatal("expected error, got nil") + } + if want := "linear_attachment_failed: attachmentCreate returned success=false"; err.Error() != want { + t.Errorf("expected %q, got %q", want, err.Error()) + } +} + +func TestCreateAttachment_GraphQLError(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]any{{"message": "not found"}}, + }) + }) + + err := client.CreateAttachment(context.Background(), "issue-123", "rec", "https://example.com/v.webm") + if err == nil { + t.Fatal("expected error, got nil") + } + if want := "linear_graphql_errors: not found"; err.Error() != want { + t.Errorf("expected %q, got %q", want, err.Error()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 067015e..7b08465 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,15 +1,18 @@ package server import ( + "bytes" "context" "encoding/json" "fmt" + "io" "log/slog" "net" "net/http" "strings" "time" + "github.com/jordan/go-symphony/internal/linear" "github.com/jordan/go-symphony/internal/orchestrator" ) @@ -19,18 +22,28 @@ type orchClient interface { TriggerPoll(ctx context.Context) } +// videoAttacher is the subset of the Linear client used for video attachment. +type videoAttacher interface { + FetchIssueIDByIdentifier(ctx context.Context, identifier string) (string, error) + RequestFileUpload(ctx context.Context, filename, contentType string, size int) (*linear.FileUploadInfo, error) + UploadFileToURL(ctx context.Context, info *linear.FileUploadInfo, contentType string, size int64, data io.Reader) error + CreateAttachment(ctx context.Context, issueID, title, url string) error +} + // Server is the optional HTTP observability server. type Server struct { orch orchClient + va videoAttacher // optional; nil disables the attach-video endpoint logger *slog.Logger srv *http.Server addr string } // New creates a new HTTP server. -func New(orch *orchestrator.Orchestrator, port int, logger *slog.Logger) *Server { +func New(orch *orchestrator.Orchestrator, va videoAttacher, port int, logger *slog.Logger) *Server { s := &Server{ orch: orch, + va: va, logger: logger, addr: fmt.Sprintf("127.0.0.1:%d", port), } @@ -40,6 +53,7 @@ func New(orch *orchestrator.Orchestrator, port int, logger *slog.Logger) *Server mux.HandleFunc("GET /api/v1/state", s.handleState) mux.HandleFunc("GET /api/v1/{identifier}", s.handleIssue) mux.HandleFunc("POST /api/v1/refresh", s.handleRefresh) + mux.HandleFunc("POST /api/v1/attach-video/{identifier}", s.handleAttachVideo) s.srv = &http.Server{ Addr: s.addr, @@ -97,6 +111,16 @@ th { background: #16213e; } .running { color: #4ecca3; } .retrying { color: #e94560; } .totals { background: #16213e; padding: 1em; border-radius: 8px; display: inline-block; margin: 1em 0; } +.recorder { background: #16213e; padding: 1em; border-radius: 8px; margin: 1em 0; } +.recorder h2 { margin: 0 0 0.75em; color: #4ecca3; } +.recorder label { display: block; margin-bottom: 0.4em; font-size: 0.9em; color: #aaa; } +.recorder input { background: #0d1117; border: 1px solid #444; color: #e0e0e0; padding: 4px 8px; border-radius: 4px; width: 180px; } +.recorder button { margin: 0.5em 0.4em 0.5em 0; padding: 6px 14px; border: none; border-radius: 4px; cursor: pointer; font-family: monospace; font-size: 0.9em; } +#rec-start { background: #4ecca3; color: #1a1a2e; } +#rec-stop { background: #e94560; color: #fff; } +#rec-download, #rec-attach { background: #0f3460; color: #e0e0e0; } +button:disabled { opacity: 0.4; cursor: not-allowed; } +#rec-status { margin-top: 0.6em; font-size: 0.85em; color: #aaa; min-height: 1.2em; } @@ -140,7 +164,75 @@ Runtime: %.1fs fmt.Fprintf(w, "\n") } - fmt.Fprintf(w, "

Generated at %s

", snap.GeneratedAt.Format(time.RFC3339)) + fmt.Fprintf(w, `
+

Screen Recording

+ + +
+ + + + +
+
+ +

Generated at %s

`, snap.GeneratedAt.Format(time.RFC3339)) } func (s *Server) handleState(w http.ResponseWriter, r *http.Request) { @@ -214,6 +306,84 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleAttachVideo(w http.ResponseWriter, r *http.Request) { + if s.va == nil { + writeJSON(w, http.StatusNotImplemented, errorResp("not_configured", "video attachment is not configured")) + return + } + + identifier := r.PathValue("identifier") + if identifier == "" { + writeJSON(w, http.StatusBadRequest, errorResp("bad_request", "missing identifier")) + return + } + + // Limit upload to 500 MB. + r.Body = http.MaxBytesReader(w, r.Body, 500<<20) + if err := r.ParseMultipartForm(500 << 20); err != nil { + writeJSON(w, http.StatusBadRequest, errorResp("bad_request", "invalid multipart form: "+err.Error())) + return + } + + file, header, err := r.FormFile("video") + if err != nil { + writeJSON(w, http.StatusBadRequest, errorResp("bad_request", "missing video field: "+err.Error())) + return + } + defer file.Close() + + // Buffer the file so we know its size before uploading. + var buf bytes.Buffer + if _, err = io.Copy(&buf, file); err != nil { + writeJSON(w, http.StatusInternalServerError, errorResp("read_error", "failed to read video: "+err.Error())) + return + } + size := buf.Len() + + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = "video/webm" + } + + ctx := r.Context() + + issueID, err := s.va.FetchIssueIDByIdentifier(ctx, identifier) + if err != nil { + writeJSON(w, http.StatusNotFound, errorResp("issue_not_found", err.Error())) + return + } + + uploadInfo, err := s.va.RequestFileUpload(ctx, header.Filename, contentType, size) + if err != nil { + writeJSON(w, http.StatusBadGateway, errorResp("upload_request_failed", err.Error())) + return + } + + if err := s.va.UploadFileToURL(ctx, uploadInfo, contentType, int64(size), &buf); err != nil { + writeJSON(w, http.StatusBadGateway, errorResp("upload_failed", err.Error())) + return + } + + title := header.Filename + if title == "" { + title = "Screen recording" + } + + if err := s.va.CreateAttachment(ctx, issueID, title, uploadInfo.AssetURL); err != nil { + writeJSON(w, http.StatusBadGateway, errorResp("attachment_failed", err.Error())) + return + } + + if s.logger != nil { + s.logger.Info("video attached to issue", "identifier", identifier, "url", uploadInfo.AssetURL) + } + writeJSON(w, http.StatusOK, map[string]any{ + "attachment_url": uploadInfo.AssetURL, + "issue_id": issueID, + "identifier": identifier, + }) +} + func errorResp(code, message string) map[string]any { return map[string]any{ "error": map[string]any{ diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 862bbf7..aae8043 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,14 +1,18 @@ package server import ( + "bytes" "context" + "fmt" "io" + "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "time" + "github.com/jordan/go-symphony/internal/linear" "github.com/jordan/go-symphony/internal/model" "github.com/jordan/go-symphony/internal/orchestrator" ) @@ -20,6 +24,31 @@ type fakeOrch struct { func (f *fakeOrch) Snapshot() orchestrator.StateSnapshot { return f.snap } func (f *fakeOrch) TriggerPoll(_ context.Context) {} +// fakeVA is a test implementation of videoAttacher. +type fakeVA struct { + issueID string + fetchErr error + uploadInfo *linear.FileUploadInfo + uploadReqErr error + uploadFileErr error + attachErr error + attached bool +} + +func (f *fakeVA) FetchIssueIDByIdentifier(_ context.Context, _ string) (string, error) { + return f.issueID, f.fetchErr +} +func (f *fakeVA) RequestFileUpload(_ context.Context, _, _ string, _ int) (*linear.FileUploadInfo, error) { + return f.uploadInfo, f.uploadReqErr +} +func (f *fakeVA) UploadFileToURL(_ context.Context, _ *linear.FileUploadInfo, _ string, _ int64, _ io.Reader) error { + return f.uploadFileErr +} +func (f *fakeVA) CreateAttachment(_ context.Context, _, _, _ string) error { + f.attached = true + return f.attachErr +} + func newTestServer(snap orchestrator.StateSnapshot) *httptest.Server { s := &Server{orch: &fakeOrch{snap: snap}} mux := http.NewServeMux() @@ -27,6 +56,28 @@ func newTestServer(snap orchestrator.StateSnapshot) *httptest.Server { return httptest.NewServer(mux) } +func newTestServerWithVA(snap orchestrator.StateSnapshot, va videoAttacher) *httptest.Server { + s := &Server{orch: &fakeOrch{snap: snap}, va: va} + mux := http.NewServeMux() + mux.HandleFunc("GET /", s.handleDashboard) + mux.HandleFunc("POST /api/v1/attach-video/{identifier}", s.handleAttachVideo) + return httptest.NewServer(mux) +} + +// buildVideoUpload creates a multipart form body containing a small fake video file. +func buildVideoUpload(t *testing.T) (*bytes.Buffer, string) { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("video", "recording.webm") + if err != nil { + t.Fatalf("create form file: %v", err) + } + fw.Write([]byte("fake-video-bytes")) //nolint:errcheck + mw.Close() + return &buf, mw.FormDataContentType() +} + func TestDashboardShowsTokenCounts(t *testing.T) { now := time.Now() snap := orchestrator.StateSnapshot{ @@ -128,3 +179,116 @@ func TestDashboardShowsIssueTitle(t *testing.T) { } } } + +func TestDashboardShowsRecordingUI(t *testing.T) { + ts := newTestServer(orchestrator.StateSnapshot{GeneratedAt: time.Now()}) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/") //nolint:noctx // test-only HTTP call + if err != nil { + t.Fatalf("GET /: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + html := string(body) + + for _, want := range []string{"rec-start", "rec-stop", "rec-download", "rec-attach", "getDisplayMedia", "MediaRecorder"} { + if !strings.Contains(html, want) { + t.Errorf("dashboard missing recording UI element %q", want) + } + } +} + +func TestAttachVideo_NoVA(t *testing.T) { + // Server with no videoAttacher should return 501. + s := &Server{orch: &fakeOrch{}, logger: nil} + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v1/attach-video/{identifier}", s.handleAttachVideo) + ts := httptest.NewServer(mux) + defer ts.Close() + + buf, ct := buildVideoUpload(t) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/attach-video/ZYX-75", buf) //nolint:noctx + req.Header.Set("Content-Type", ct) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotImplemented { + t.Errorf("expected 501, got %d", resp.StatusCode) + } +} + +func TestAttachVideo_Success(t *testing.T) { + va := &fakeVA{ + issueID: "uuid-abc", + uploadInfo: &linear.FileUploadInfo{ + UploadURL: "https://s3.example.com/upload", + AssetURL: "https://cdn.example.com/rec.webm", + }, + } + ts := newTestServerWithVA(orchestrator.StateSnapshot{GeneratedAt: time.Now()}, va) + defer ts.Close() + + buf, ct := buildVideoUpload(t) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/attach-video/ZYX-75", buf) //nolint:noctx + req.Header.Set("Content-Type", ct) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + if !va.attached { + t.Error("expected CreateAttachment to be called") + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "cdn.example.com") { + t.Errorf("response missing asset URL: %s", body) + } +} + +func TestAttachVideo_IssueNotFound(t *testing.T) { + va := &fakeVA{fetchErr: fmt.Errorf("linear_issue_not_found: no issue with identifier \"ZYX-99\"")} + ts := newTestServerWithVA(orchestrator.StateSnapshot{GeneratedAt: time.Now()}, va) + defer ts.Close() + + buf, ct := buildVideoUpload(t) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/attach-video/ZYX-99", buf) //nolint:noctx + req.Header.Set("Content-Type", ct) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } +} + +func TestAttachVideo_MissingVideoField(t *testing.T) { + va := &fakeVA{issueID: "uuid-abc"} + ts := newTestServerWithVA(orchestrator.StateSnapshot{GeneratedAt: time.Now()}, va) + defer ts.Close() + + // Send multipart without a "video" field. + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + mw.Close() + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/attach-video/ZYX-75", &buf) //nolint:noctx + req.Header.Set("Content-Type", mw.FormDataContentType()) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } +} diff --git a/main.go b/main.go index dc7f085..17fb6c5 100644 --- a/main.go +++ b/main.go @@ -138,7 +138,7 @@ func main() { // Start optional HTTP server if httpPort >= 0 { - srv := server.New(orch, httpPort, logger) + srv := server.New(orch, linearClient, httpPort, logger) if err := srv.Start(ctx); err != nil { logger.Error("failed to start HTTP server", "error", err) os.Exit(1)