diff --git a/README.md b/README.md index e5e43187..4c08af10 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,12 @@ gitlink-cli repo +create -n my-project -d "Project description" # Fork a repository gitlink-cli repo +fork --owner Gitlink --repo forgeplus + +# Upload a file attachment +gitlink-cli repo +upload --owner Gitlink --repo forgeplus -f ./document.pdf -d "Project documentation" + +# Upload a file with container metadata +gitlink-cli repo +upload -f ./image.png --container-id 12345 --container-type Project ``` ### Webhook Management diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d8..83770415 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -266,6 +266,12 @@ gitlink-cli repo +create -n my-project -d "项目描述" # Fork 仓库 gitlink-cli repo +fork --owner Gitlink --repo forgeplus + +# 上传文件附件 +gitlink-cli repo +upload --owner Gitlink --repo forgeplus -f ./document.pdf -d "项目文档" + +# 上传文件并指定归属模型 +gitlink-cli repo +upload -f ./image.png --container-id 12345 --container-type Project ``` ### Webhook 管理 diff --git a/doc/changes/repo-upload-shortcut.md b/doc/changes/repo-upload-shortcut.md new file mode 100644 index 00000000..6d36a97d --- /dev/null +++ b/doc/changes/repo-upload-shortcut.md @@ -0,0 +1,71 @@ +# Repo Upload Shortcut + +## 中文说明 + +### 变更内容 + +- 新增 `repo +upload` 命令,支持通过 `multipart/form-data` 上传文件到 `/api/attachments.json`。 +- Client 层新增 `Upload()` 方法,支持文件字段和额外表单字段。 +- RuntimeContext 新增 `UploadFile()` 便捷方法。 +- 支持 `--file`(必需)、`--description`、`--container-id`、`--container-type` 参数。 +- 更新 README、README.zh-CN 和变更说明。 + +### 命令 + +| 命令 | 用途 | +|---------|---------| +| `gitlink-cli repo +upload` | 上传文件附件 | + +### 参数 + +| 参数 | 简写 | 必需 | 说明 | +|------|-------|----------|-------------| +| `--file` | `-f` | 是 | 要上传的文件路径 | +| `--description` | `-d` | 否 | 上传文件的描述 | +| `--container-id` | | 否 | 上传文件归属模型 ID | +| `--container-type` | | 否 | 上传文件归属模型类型 | + +### 使用示例 + +```bash +# 上传文件 +gitlink-cli repo +upload --file ./example.txt + +# 上传文件并添加描述 +gitlink-cli repo +upload -f ./document.pdf -d "项目文档" + +# 上传文件并指定归属 +gitlink-cli repo +upload -f ./image.png \ + --container-id 12345 \ + --container-type Project +``` + +### API 响应 + +```json +{ + "id": "f5838d8f-451b-4793-a0f2-0278430e8207", + "title": "example.txt", + "filesize": "12 B", + "is_pdf": false, + "url": "http://example.com/file.txt", + "created_on": "2026-06-23 14:40", + "content_type": "text/plain" +} +``` + +### 测试 + +- `TestRepoUploadShortcut`: 验证 multipart 请求、文件字段和 description。 +- `TestRepoUploadWithContainerFields`: 验证 container_id 和 container_type 字段。 +- `TestRepoUploadMissingFile`: 验证文件不存在时的错误处理。 +- `TestRepoUploadHTTPError`: 验证 HTTP 500 错误传播。 +- `TestClientUploadMultipart`: 验证 Client.Upload() 的 multipart 构建。 +- `TestClientUploadHTTPError`: 验证 HTTP 403 时返回 APIError。 +- `TestClientUploadJSONSuffix`: 验证 `.json` 后缀自动追加。 + +### 验证 + +- `go build ./...` +- `go test ./shortcuts/repo/ ./internal/client/` +- `go run . repo +upload --help` diff --git a/internal/client/client.go b/internal/client/client.go index 1fb9d80e..8bb805ff 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -3,8 +3,10 @@ package client import ( "bytes" "encoding/json" + "errors" "fmt" "io" + "mime/multipart" "net/http" "net/url" "strings" @@ -14,6 +16,20 @@ import ( "github.com/gitlink-org/gitlink-cli/internal/output" ) +const ( + // defaultMaxUploadSize is the default maximum file size (in bytes) accepted + // by Upload. Multipart data is fully buffered in memory; exceeding this + // limit returns ErrUploadTooLarge instead of risking an OOM. + defaultMaxUploadSize = 50 << 20 // 50 MB +) + +var ( + // MaxUploadSize is the maximum file size for Upload. Overridable in tests. + MaxUploadSize = int64(defaultMaxUploadSize) + + ErrUploadTooLarge = errors.New("file too large to upload") +) + type Client struct { HTTP *http.Client BaseURL string @@ -99,12 +115,8 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } // Check HTTP-level errors - if resp.StatusCode >= 400 { - return nil, &APIError{ - StatusCode: resp.StatusCode, - Code: resp.StatusCode, - Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))), - } + if err := httpError(respData, resp.StatusCode); err != nil { + return nil, err } // Parse JSON @@ -116,29 +128,8 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o // Check GitLink error-in-body pattern // Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."} - var bodyCode float64 - var bodyMsg string - if status, ok := raw["status"]; ok { - switch v := status.(type) { - case float64: - bodyCode = v - case int: - bodyCode = float64(v) - } - bodyMsg, _ = raw["message"].(string) - } else if code, ok := raw["code"]; ok { - switch v := code.(type) { - case float64: - bodyCode = v - case int: - bodyCode = float64(v) - } - bodyMsg, _ = raw["msg"].(string) - if bodyMsg == "" { - bodyMsg, _ = raw["message"].(string) - } - } - if bodyCode != 0 && bodyCode != 200 && bodyCode != 201 && bodyCode != 204 && bodyCode != 1 { + bodyCode, bodyMsg := extractBodyErrorCode(raw) + if bodyCode != 0 && !isSuccessCode(bodyCode) { suggestion := suggestFix(int(bodyCode)) return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{ StatusCode: int(bodyCode), @@ -218,6 +209,144 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) return c.Do("DELETE", path, nil, query) } +// Upload sends a multipart/form-data POST request (file upload). +// fields may be nil. +// Files larger than MaxUploadSize (50 MB) are rejected to avoid OOM. +func (c *Client) Upload(path string, fieldName, fileName string, fileReader io.Reader, fields map[string]string) (*output.Envelope, error) { + path = normalizeAPIPath(c.BaseURL, path) + if shouldAppendJSONSuffix(path) { + path += ".json" + } + fullURL := c.BaseURL + path + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + + // File part must be added first when fields are present so the server + // sees the file under the expected part name. + if fileReader != nil && fieldName != "" { + part, err := mw.CreateFormFile(fieldName, fileName) + if err != nil { + return nil, err + } + n, err := io.Copy(part, io.LimitReader(fileReader, MaxUploadSize+1)) + if err != nil { + return nil, err + } + if n > MaxUploadSize { + return nil, fmt.Errorf("%w: %d bytes exceeds maximum %d MB", ErrUploadTooLarge, n, MaxUploadSize>>20) + } + } + for k, v := range fields { + if err := mw.WriteField(k, v); err != nil { + return nil, err + } + } + if err := mw.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", fullURL, &buf) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + + if c.Debug { + fmt.Printf("→ POST %s (multipart)\n", fullURL) + } + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if c.Debug { + fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)])) + } + + if err := httpError(respData, resp.StatusCode); err != nil { + return nil, err + } + + var raw map[string]interface{} + if err := json.Unmarshal(respData, &raw); err != nil { + return output.SuccessEnvelope(string(respData), nil), nil + } + + bodyCode, bodyMsg := extractBodyErrorCode(raw) + if bodyCode != 0 && !isSuccessCode(bodyCode) { + suggestion := suggestFix(int(bodyCode)) + return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{ + StatusCode: int(bodyCode), + Code: int(bodyCode), + Message: bodyMsg, + } + } + + return output.SuccessEnvelope(raw, nil), nil +} + +// extractBodyErrorCode extracts a numeric error code and message from a parsed JSON +// response body. It understands both {"status":N, "message":"..."} (GitLink core API) +// and {"code":N, "msg":"..."} (gateway API) patterns. Returns (0, "") if no error +// code is present in the body or the parsed code is not a number. +func extractBodyErrorCode(raw map[string]interface{}) (code float64, msg string) { + if status, ok := raw["status"]; ok { + switch v := status.(type) { + case float64: + code = v + case int: + code = float64(v) + case json.Number: + code, _ = v.Float64() + } + msg, _ = raw["message"].(string) + return + } + if gateCode, ok := raw["code"]; ok { + switch v := gateCode.(type) { + case float64: + code = v + case int: + code = float64(v) + case json.Number: + code, _ = v.Float64() + } + msg, _ = raw["msg"].(string) + if msg == "" { + msg, _ = raw["message"].(string) + } + return + } + return 0, "" +} + +// isSuccessCode reports whether a body-level status/code value represents a +// successful response (i.e. it should NOT be treated as an API error). +func isSuccessCode(code float64) bool { + return code == 0 || code == 200 || code == 201 || code == 204 || code == 1 +} + +// httpError builds an APIError for non-2xx HTTP responses. Returns nil when +// the status code does not indicate an error. +func httpError(body []byte, statusCode int) *APIError { + if statusCode < 400 { + return nil + } + return &APIError{ + StatusCode: statusCode, + Code: statusCode, + Message: fmt.Sprintf("HTTP %d: %s", statusCode, strings.TrimSpace(string(body))), + } +} + func suggestFix(code int) string { switch code { case 401: diff --git a/internal/client/client_test.go b/internal/client/client_test.go index c46226be..5720e57a 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -2,11 +2,14 @@ package client import ( "encoding/json" + "errors" + "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" + "strings" "testing" ) @@ -579,3 +582,140 @@ func TestShouldAppendJSONSuffixSkipsWikiOpenPaths(t *testing.T) { } } } + +func TestClientUploadMultipart(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/attachments.json" { + t.Fatalf("expected /api/attachments.json, got %s", r.URL.Path) + } + ct := r.Header.Get("Content-Type") + if !strings.Contains(ct, "multipart/form-data") { + t.Fatalf("expected multipart/form-data, got %s", ct) + } + if err := r.ParseMultipartForm(32 << 20); err != nil { + t.Fatalf("parse multipart: %v", err) + } + files := r.MultipartForm.File["file"] + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + if files[0].Filename != "hello.txt" { + t.Fatalf("expected filename hello.txt, got %s", files[0].Filename) + } + fh, err := files[0].Open() + if err != nil { + t.Fatalf("open uploaded file: %v", err) + } + defer fh.Close() + data, _ := io.ReadAll(fh) + if string(data) != "hello world" { + t.Fatalf("file content = %q, want %q", string(data), "hello world") + } + if desc := r.FormValue("description"); desc != "test file" { + t.Fatalf("description = %q, want %q", desc, "test file") + } + if cid := r.FormValue("container_id"); cid != "42" { + t.Fatalf("container_id = %q, want %q", cid, "42") + } + if ctype := r.FormValue("container_type"); ctype != "Project" { + t.Fatalf("container_type = %q, want %q", ctype, "Project") + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"id":"abc-123","title":"hello.txt","filesize":"11 B","is_pdf":false,"url":"http://example.com/hello.txt","created_on":"2026-06-23 14:40","content_type":"text/plain"}`)) + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + reader := strings.NewReader("hello world") + fields := map[string]string{ + "description": "test file", + "container_id": "42", + "container_type": "Project", + } + env, err := c.Upload("/api/attachments", "file", "hello.txt", reader, fields) + if err != nil { + t.Fatalf("upload failed: %v", err) + } + if !env.OK { + t.Fatal("expected OK=true") + } +} + +func TestClientUploadHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"status":403,"message":"forbidden"}`)) + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + reader := strings.NewReader("data") + _, err := c.Upload("/api/attachments", "file", "test.txt", reader, nil) + if err == nil { + t.Fatal("expected error for HTTP 403") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 403 { + t.Fatalf("expected status 403, got %d", apiErr.StatusCode) + } +} + +func TestClientUploadJSONSuffix(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/attachments.json" { + t.Fatalf("expected .json suffix, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"id":"ok"}`)) + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + reader := strings.NewReader("data") + _, err := c.Upload("/api/attachments", "file", "test.txt", reader, nil) + if err != nil { + t.Fatalf("upload failed: %v", err) + } +} + +func TestClientUploadSizeLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"id":"ok"}`)) + })) + defer server.Close() + + // Files within the limit should succeed. + origSize := MaxUploadSize + MaxUploadSize = 100 + defer func() { MaxUploadSize = origSize }() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + + // 50 bytes — within limit + t.Run("within limit", func(t *testing.T) { + reader := strings.NewReader(strings.Repeat("x", 50)) + _, err := c.Upload("/api/attachments", "file", "small.txt", reader, nil) + if err != nil { + t.Fatalf("small file should succeed, got: %v", err) + } + }) + + // 150 bytes — exceeds limit + t.Run("exceeds limit", func(t *testing.T) { + reader := strings.NewReader(strings.Repeat("x", 150)) + _, err := c.Upload("/api/attachments", "file", "large.txt", reader, nil) + if err == nil { + t.Fatal("expected error for oversized file") + } + if !errors.Is(err, ErrUploadTooLarge) { + t.Fatalf("expected ErrUploadTooLarge, got: %v", err) + } + }) +} diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 07393951..49460d41 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -88,6 +88,8 @@ "cmd.repo.list.short": "List repositories for a user or organization", "cmd.repo.short": "Repository operations", "cmd.repo.tree.short": "List repository files and directories", + "cmd.repo.upload.short": "Upload a file attachment", + "cmd.repo.upload.long": "Upload a file to the repository as an attachment. Optional fields: description, container-id, container-type.", "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", "cmd.root.short": "GitLink CLI - command-line tool for GitLink", "cmd.search.repos.short": "Search repositories", @@ -214,6 +216,10 @@ "flag.repo.private": "Make repository private (true/false)", "flag.repo.tree.path": "Directory path to list (default: repository root)", "flag.repo.tree.ref": "Branch, tag, or commit ref", + "flag.repo.upload.file": "Path to the file to upload", + "flag.repo.upload.description": "Description for the uploaded file", + "flag.repo.upload.container_id": "ID of the model the file belongs to", + "flag.repo.upload.container_type": "Type of the model the file belongs to", "flag.search.keyword": "Search keyword", "flag.sort_by": "Sort field", "flag.sort_direction": "Sort direction: asc, desc", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4df..d3145a1d 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -214,6 +214,10 @@ "flag.repo.private": "设为私有仓库(true/false)", "flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)", "flag.repo.tree.ref": "分支、标签或提交引用", + "flag.repo.upload.file": "要上传的文件路径", + "flag.repo.upload.description": "上传文件的描述", + "flag.repo.upload.container_id": "上传文件归属模型 ID", + "flag.repo.upload.container_type": "上传文件归属模型类型", "flag.search.keyword": "搜索关键词", "flag.sort_by": "排序字段", "flag.sort_direction": "排序方向:asc、desc", diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 87a052ae..825218dd 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/url" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" @@ -126,3 +127,9 @@ func (ctx *RuntimeContext) RequireArg(name string) (string, error) { } return v, nil } + +// UploadFile uploads a file via multipart/form-data POST. +// fields may be nil. +func (ctx *RuntimeContext) UploadFile(path, fieldName, fileName string, fileReader io.Reader, fields map[string]string) (*output.Envelope, error) { + return ctx.Client.Upload(path, fieldName, fileName, fileReader, fields) +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 06774a6e..b74711a1 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -3,9 +3,12 @@ package repo import ( "fmt" "net/url" + "os" + "path/filepath" "strconv" "strings" + "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -253,9 +256,55 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "upload", + Description: tr.T("cmd.repo.upload.short"), + Long: tr.T("cmd.repo.upload.long"), + Flags: []common.Flag{ + {Name: "file", Short: "f", Usage: tr.T("flag.repo.upload.file"), Required: true}, + {Name: "description", Short: "d", Usage: tr.T("flag.repo.upload.description")}, + {Name: "container-id", Usage: tr.T("flag.repo.upload.container_id")}, + {Name: "container-type", Usage: tr.T("flag.repo.upload.container_type")}, + }, + Run: runRepoUpload, + }, } } +func runRepoUpload(ctx *common.RuntimeContext) error { + filePath := ctx.Arg("file") + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("open file: %w", err) + } + defer f.Close() + + // Check file size upfront to avoid buffering huge files in memory. + if fi, err := f.Stat(); err == nil { + if fi.Size() > client.MaxUploadSize { + return fmt.Errorf("%w: %d bytes exceeds maximum %d MB", + client.ErrUploadTooLarge, fi.Size(), client.MaxUploadSize>>20) + } + } + + fields := map[string]string{} + if v := ctx.Arg("description"); v != "" { + fields["description"] = v + } + if v := ctx.Arg("container-id"); v != "" { + fields["container_id"] = v + } + if v := ctx.Arg("container-type"); v != "" { + fields["container_type"] = v + } + + env, err := ctx.UploadFile("/api/attachments", "file", filepath.Base(filePath), f, fields) + if err != nil { + return err + } + return ctx.Output(env) +} + func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { if len(translators) > 0 && translators[0] != nil { return translators[0] diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 5c5f34b5..2a6ec7fe 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "strings" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -597,6 +599,129 @@ func TestRepoDeleteHTTPError(t *testing.T) { } } +// --- upload --- + +func TestRepoUploadShortcut(t *testing.T) { + tmpFile, err := os.CreateTemp("", "upload-test-*.txt") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + if _, err := tmpFile.WriteString("test content"); err != nil { + t.Fatalf("write temp file: %v", err) + } + tmpFile.Close() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/attachments.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + contentType := r.Header.Get("Content-Type") + if !strings.Contains(contentType, "multipart/form-data") { + t.Fatalf("expected multipart/form-data, got %s", contentType) + } + if err := r.ParseMultipartForm(32 << 20); err != nil { + t.Fatalf("parse multipart: %v", err) + } + if files := r.MultipartForm.File["file"]; len(files) == 0 { + t.Fatal("missing file field") + } + if desc := r.FormValue("description"); desc != "test description" { + t.Fatalf("description = %q, want %q", desc, "test description") + } + writeJSON(t, w, map[string]interface{}{ + "id": "f5838d8f-451b-4793-a0f2-0278430e8207", + "title": "upload-test.txt", + "filesize": "12 B", + "is_pdf": false, + "url": "http://example.com/file.txt", + "created_on": "2026-06-23 14:40", + "content_type": "text/plain", + }) + })) + defer server.Close() + + err = runShortcut(t, server, "upload", map[string]string{ + "file": tmpFile.Name(), + "description": "test description", + }) + if err != nil { + t.Fatalf("upload failed: %v", err) + } +} + +func TestRepoUploadWithContainerFields(t *testing.T) { + tmpFile, err := os.CreateTemp("", "upload-container-*.txt") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.WriteString("content") + tmpFile.Close() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(32 << 20); err != nil { + t.Fatalf("parse multipart: %v", err) + } + if cid := r.FormValue("container_id"); cid != "12345" { + t.Fatalf("container_id = %q, want %q", cid, "12345") + } + if ctype := r.FormValue("container_type"); ctype != "Project" { + t.Fatalf("container_type = %q, want %q", ctype, "Project") + } + writeJSON(t, w, map[string]interface{}{"id": "test-id"}) + })) + defer server.Close() + + err = runShortcut(t, server, "upload", map[string]string{ + "file": tmpFile.Name(), + "container-id": "12345", + "container-type": "Project", + }) + if err != nil { + t.Fatalf("upload with container fields failed: %v", err) + } +} + +func TestRepoUploadMissingFile(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not call API when file is missing") + })) + defer server.Close() + + err := runShortcut(t, server, "upload", map[string]string{ + "file": "/nonexistent/file.txt", + }) + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestRepoUploadHTTPError(t *testing.T) { + tmpFile, err := os.CreateTemp("", "upload-error-*.txt") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.WriteString("content") + tmpFile.Close() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + })) + defer server.Close() + + err = runShortcut(t, server, "upload", map[string]string{ + "file": tmpFile.Name(), + }) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + func TestRepoCreateGetUserHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeText(t, w, http.StatusInternalServerError, "server error")