From cb02d545bd7bca824418cff38cf3b825fb66945e Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:46:30 -0500 Subject: [PATCH] feat: implement files endpoint --- README.md | 99 +++++++++ client.go | 77 +++++++ files.go | 212 +++++++++++++++++++ files_test.go | 575 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 963 insertions(+) create mode 100644 files.go create mode 100644 files_test.go diff --git a/README.md b/README.md index d971d9b..2dd148a 100644 --- a/README.md +++ b/README.md @@ -380,4 +380,103 @@ for report, err := range client.Reports.ListAll(context.TODO(), 1) { } fmt.Printf("Retrieved Report: %+v\n", report) } +``` + +### Files + +#### Get File Info + +```go +import ( + "context" + "fmt" + "github.com/StevanFreeborn/onspring-api-sdk-go/onspring" +) + +client := onspring.NewClient("your-api-key") + +fileInfo, err := client.Files.GetInfo(context.TODO(), 1, 2, 3) + +if err != nil { + fmt.Printf("Error: %v\n", err) +} else { + fmt.Printf("File Name: %s\n", fileInfo.Name) + fmt.Printf("Content Type: %s\n", fileInfo.ContentType) +} +``` + +#### Get File Content + +```go +import ( + "context" + "fmt" + "os" + "github.com/StevanFreeborn/onspring-api-sdk-go/onspring" +) + +client := onspring.NewClient("your-api-key") + +fileContent, err := client.Files.GetContent(context.TODO(), 1, 2, 3) + +if err != nil { + fmt.Printf("Error: %v\n", err) +} else { + fmt.Printf("File Name: %s\n", fileContent.FileName) + fmt.Printf("Content Type: %s\n", fileContent.ContentType) + os.WriteFile(fileContent.FileName, fileContent.Data, 0644) +} +``` + +#### Delete File + +```go +import ( + "context" + "fmt" + "github.com/StevanFreeborn/onspring-api-sdk-go/onspring" +) + +client := onspring.NewClient("your-api-key") + +err := client.Files.Delete(context.TODO(), 1, 2, 3) + +if err != nil { + fmt.Printf("Error: %v\n", err) +} else { + fmt.Println("File deleted successfully!") +} +``` + +#### Save File + +```go +import ( + "context" + "fmt" + "os" + "github.com/StevanFreeborn/onspring-api-sdk-go/onspring" +) + +client := onspring.NewClient("your-api-key") + +file, _ := os.Open("document.pdf") +defer file.Close() + +saveReq := onspring.SaveFileRequest{ + RecordId: 1, + FieldId: 2, + FileName: "document.pdf", + FileContents: file, + Notes: "Uploaded via API", + ModifiedDate: "2024-01-01T00:00:00Z", +} + +response, err := client.Files.Save(context.TODO(), saveReq) + +if err != nil { + fmt.Printf("Error: %v\n", err) +} else { + fmt.Printf("Saved File Id: %d\n", response.Id) +} diff --git a/client.go b/client.go index c1051c0..9439469 100644 --- a/client.go +++ b/client.go @@ -51,6 +51,8 @@ type Client struct { Lists *ListsEndpoint // Reports provides access to the reports within an Onspring instance. Reports *ReportsEndpoint + // Files provides access to the files within an Onspring instance. + Files *FilesEndpoint } // NewClient creates a new Onspring API client with the provided API key. @@ -88,6 +90,7 @@ func NewClient(apiKey string, opts ...ClientOption) *Client { c.Fields = &FieldsEndpoint{client: c} c.Lists = &ListsEndpoint{client: c} c.Reports = &ReportsEndpoint{client: c} + c.Files = &FilesEndpoint{client: c} return c } @@ -149,6 +152,80 @@ func (c *Client) doWithJsonResponse(req *http.Request, v any) error { return json.NewDecoder(resp.Body).Decode(v) } +// doWithBytesResponse executes an HTTP request and returns the raw response bytes and headers. +// It performs the HTTP call, checks the response status code, and reads +// the response body into a byte slice. +// +// Parameters: +// - req: The HTTP request to execute +// +// Returns: +// - []byte: The raw response body bytes +// - http.Header: The response headers +// - error: nil if the request succeeds, or an error if the request fails +// or returns a non-2xx status code +func (c *Client) doWithBytesResponse(req *http.Request) ([]byte, http.Header, error) { + resp, err := c.httpClient.Do(req) + + if err != nil { + return nil, nil, err + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, nil, c.handleAPIError(resp) + } + + data, err := io.ReadAll(resp.Body) + + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + + return data, resp.Header, nil +} + +// newMultipartRequest creates a new multipart/form-data HTTP request for the Onspring API. +// It constructs the full URL, sets required authentication headers, +// and prepares the multipart form data with the provided context. +// +// Parameters: +// - ctx: The context for the request +// - path: The API endpoint path +// - body: The request body as a reader (should be a multipart form body) +// - contentType: The content type header value (including boundary) +// +// Returns: +// - *http.Request: The prepared HTTP request +// - error: An error if the context is nil or request creation fails +func (c *Client) newMultipartRequest(ctx context.Context, path string, body io.Reader, contentType string) (*http.Request, error) { + if ctx == nil { + return nil, fmt.Errorf("context must not be nil") + } + + fullURL := fmt.Sprintf("%s/%s", strings.TrimRight(c.baseURL, "/"), strings.TrimLeft(path, "/")) + validUrl, urlParsingError := url.Parse(fullURL) + + if urlParsingError != nil { + return nil, fmt.Errorf("failed to parse the request url: %w", urlParsingError) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, validUrl.String(), body) + + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set(defaultAPIKeyHeader, c.apiKey) + req.Header.Set(defaultAPIVersionHeader, c.apiVersion) + req.Header.Set("Content-Type", contentType) + + return req, nil +} + // handleAPIError processes error responses from the Onspring API. // It attempts to decode the error message from the response body. // If decoding fails, it falls back to using the HTTP status text. diff --git a/files.go b/files.go new file mode 100644 index 0000000..8354f3a --- /dev/null +++ b/files.go @@ -0,0 +1,212 @@ +package onspring + +import ( + "bytes" + "context" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "strconv" +) + +const ( + filesPath = "/files" +) + +// FilesEndpoint provides access to files in an Onspring instance. +type FilesEndpoint struct { + client *Client +} + +// FileInfo represents the metadata of a file in Onspring. +type FileInfo struct { + Type string `json:"type"` + ContentType string `json:"contentType"` + Name string `json:"name"` + CreatedDate string `json:"createdDate"` + ModifiedDate string `json:"modifiedDate"` + Owner string `json:"owner"` + Notes string `json:"notes"` + FileHref string `json:"fileHref"` +} + +// FileContent represents the content of a downloaded file. +type FileContent struct { + FileName string + ContentType string + Data []byte +} + +// SaveFileRequest represents a request to upload a file. +type SaveFileRequest struct { + RecordId int + FieldId int + Notes string + ModifiedDate string + FileName string + FileContents io.Reader +} + +// SaveFileResponse represents the response for saving a file. +type SaveFileResponse struct { + Id int `json:"id"` +} + +func fileBasePath(recordId, fieldId, fileId int) string { + return fmt.Sprintf("%s/recordId/%d/fieldId/%d/fileId/%d", filesPath, recordId, fieldId, fileId) +} + +// GetInfo retrieves a file's metadata from the Onspring API. +// +// Parameters: +// - ctx: The context for the request +// - recordId: The id of the record +// - fieldId: The id of the field +// - fileId: The id of the file +// +// Returns: +// - FileInfo: The file's metadata +// - error: An error if the request fails +func (f *FilesEndpoint) GetInfo(ctx context.Context, recordId, fieldId, fileId int) (FileInfo, error) { + path := fileBasePath(recordId, fieldId, fileId) + req, requestCreationErr := f.client.newRequest(ctx, http.MethodGet, path, nil, nil) + + var fileInfo FileInfo + + if requestCreationErr != nil { + return fileInfo, requestCreationErr + } + + responseErr := f.client.doWithJsonResponse(req, &fileInfo) + + if responseErr != nil { + return fileInfo, responseErr + } + + return fileInfo, nil +} + +// GetContent retrieves a file's content from the Onspring API. +// +// Parameters: +// - ctx: The context for the request +// - recordId: The id of the record +// - fieldId: The id of the field +// - fileId: The id of the file +// +// Returns: +// - FileContent: The file's content including name, content type, and data +// - error: An error if the request fails +func (f *FilesEndpoint) GetContent(ctx context.Context, recordId, fieldId, fileId int) (FileContent, error) { + path := fmt.Sprintf("%s/file", fileBasePath(recordId, fieldId, fileId)) + req, requestCreationErr := f.client.newRequest(ctx, http.MethodGet, path, nil, nil) + + var fileContent FileContent + + if requestCreationErr != nil { + return fileContent, requestCreationErr + } + + data, headers, responseErr := f.client.doWithBytesResponse(req) + + if responseErr != nil { + return fileContent, responseErr + } + + fileContent.Data = data + fileContent.ContentType = headers.Get("Content-Type") + + contentDisposition := headers.Get("Content-Disposition") + + if contentDisposition != "" { + _, params, err := mime.ParseMediaType(contentDisposition) + + if err == nil { + fileContent.FileName = params["filename"] + } + } + + return fileContent, nil +} + +// Delete removes a file from the Onspring API. +// +// Parameters: +// - ctx: The context for the request +// - recordId: The id of the record +// - fieldId: The id of the field +// - fileId: The id of the file +// +// Returns: +// - error: An error if the request fails +func (f *FilesEndpoint) Delete(ctx context.Context, recordId, fieldId, fileId int) error { + path := fileBasePath(recordId, fieldId, fileId) + req, requestCreationErr := f.client.newRequest(ctx, http.MethodDelete, path, nil, nil) + + if requestCreationErr != nil { + return requestCreationErr + } + + return f.client.do(req) +} + +// Save uploads a file to the Onspring API. +// +// Parameters: +// - ctx: The context for the request +// - saveReq: The file upload request containing record id, field id, file name, contents, and optional metadata +// +// Returns: +// - SaveFileResponse: The response containing the saved file's id +// - error: An error if the request fails +func (f *FilesEndpoint) Save(ctx context.Context, saveReq SaveFileRequest) (SaveFileResponse, error) { + var response SaveFileResponse + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + _ = writer.WriteField("RecordId", strconv.Itoa(saveReq.RecordId)) + _ = writer.WriteField("FieldId", strconv.Itoa(saveReq.FieldId)) + + if saveReq.Notes != "" { + _ = writer.WriteField("Notes", saveReq.Notes) + } + + if saveReq.ModifiedDate != "" { + _ = writer.WriteField("ModifiedDate", saveReq.ModifiedDate) + } + + filePart, err := writer.CreateFormFile("File", saveReq.FileName) + + if err != nil { + return response, fmt.Errorf("failed to create form file: %w", err) + } + + _, err = io.Copy(filePart, saveReq.FileContents) + + if err != nil { + return response, fmt.Errorf("failed to copy file contents: %w", err) + } + + err = writer.Close() + + if err != nil { + return response, fmt.Errorf("failed to close multipart writer: %w", err) + } + + req, requestCreationErr := f.client.newMultipartRequest(ctx, filesPath, body, writer.FormDataContentType()) + + if requestCreationErr != nil { + return response, requestCreationErr + } + + responseErr := f.client.doWithJsonResponse(req, &response) + + if responseErr != nil { + return response, responseErr + } + + return response, nil +} diff --git a/files_test.go b/files_test.go new file mode 100644 index 0000000..2255810 --- /dev/null +++ b/files_test.go @@ -0,0 +1,575 @@ +package onspring_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/StevanFreeborn/onspring-api-sdk-go" +) + +func TestFiles(t *testing.T) { + t.Run("GetInfo", func(t *testing.T) { + t.Run("it should return an error if context is nil", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + var nilContext context.Context = nil + + _, err := client.Files.GetInfo(nilContext, 1, 1, 1) + + if err == nil { + t.Errorf("Expected error for nil context, got nil") + } + }) + + t.Run("it should return an error if context is canceled", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + ctx, cancel := context.WithCancel(t.Context()) + + cancel() + + _, err := client.Files.GetInfo(ctx, 1, 1, 1) + + if err == nil { + t.Errorf("Expected error for canceled context, got nil") + } + }) + + t.Run("it should return an error if encounters a network error", func(t *testing.T) { + client := onspring.NewClient( + "test-api-key", + onspring.WithBaseURL("http://invalid-url"), + onspring.WithHTTPClient(&http.Client{Transport: &ErrorTransport{}}), + ) + + _, err := client.Files.GetInfo(t.Context(), 1, 1, 1) + + if err == nil { + t.Errorf("Expected network error, got nil") + } + }) + + t.Run("it should return an error if create a request fails", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + invalidClient := onspring.NewClient( + "test-api-key", + onspring.WithBaseURL("http://[::1]:namedport"), + onspring.WithHTTPClient(client.HTTPClient()), + ) + + _, err := invalidClient.Files.GetInfo(t.Context(), 1, 1, 1) + + if err == nil { + t.Errorf("Expected request creation error, got nil") + } + }) + + t.Run("it should return an error if the endpoint returns a non-200 status code", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + _, err := client.Files.GetInfo(t.Context(), 1, 1, 1) + + if err == nil { + t.Errorf("Expected error, got nil") + } + }) + + t.Run("it should perform a GET request to the correct endpoint and return file info", func(t *testing.T) { + recordId := 1 + fieldId := 2 + fileId := 3 + + expectedFileInfo := onspring.FileInfo{ + Type: "Attachment", + ContentType: "application/pdf", + Name: "test.pdf", + CreatedDate: "2024-01-01T00:00:00Z", + ModifiedDate: "2024-01-02T00:00:00Z", + Owner: "admin", + Notes: "Test notes", + FileHref: "https://api.onspring.com/files/1/2/3/file", + } + + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("Expected GET method, got %s", r.Method) + } + + expectedPath := fmt.Sprintf("/files/recordId/%d/fieldId/%d/fileId/%d", recordId, fieldId, fileId) + + if r.URL.Path != expectedPath { + t.Errorf("Expected %s endpoint, got %s", expectedPath, r.URL.Path) + } + + jsonData, _ := json.Marshal(expectedFileInfo) + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jsonData) + }) + + fileInfo, err := client.Files.GetInfo(t.Context(), recordId, fieldId, fileId) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if !reflect.DeepEqual(expectedFileInfo, fileInfo) { + t.Errorf("Expected %v but got %v", expectedFileInfo, fileInfo) + } + }) + }) + + t.Run("GetContent", func(t *testing.T) { + t.Run("it should return an error if context is nil", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + var nilContext context.Context = nil + + _, err := client.Files.GetContent(nilContext, 1, 1, 1) + + if err == nil { + t.Errorf("Expected error for nil context, got nil") + } + }) + + t.Run("it should return an error if context is canceled", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + ctx, cancel := context.WithCancel(t.Context()) + + cancel() + + _, err := client.Files.GetContent(ctx, 1, 1, 1) + + if err == nil { + t.Errorf("Expected error for canceled context, got nil") + } + }) + + t.Run("it should return an error if encounters a network error", func(t *testing.T) { + client := onspring.NewClient( + "test-api-key", + onspring.WithBaseURL("http://invalid-url"), + onspring.WithHTTPClient(&http.Client{Transport: &ErrorTransport{}}), + ) + + _, err := client.Files.GetContent(t.Context(), 1, 1, 1) + + if err == nil { + t.Errorf("Expected network error, got nil") + } + }) + + t.Run("it should return an error if create a request fails", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + invalidClient := onspring.NewClient( + "test-api-key", + onspring.WithBaseURL("http://[::1]:namedport"), + onspring.WithHTTPClient(client.HTTPClient()), + ) + + _, err := invalidClient.Files.GetContent(t.Context(), 1, 1, 1) + + if err == nil { + t.Errorf("Expected request creation error, got nil") + } + }) + + t.Run("it should return an error if the endpoint returns a non-200 status code", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + _, err := client.Files.GetContent(t.Context(), 1, 1, 1) + + if err == nil { + t.Errorf("Expected error, got nil") + } + }) + + t.Run("it should perform a GET request to the correct endpoint and return file content", func(t *testing.T) { + recordId := 1 + fieldId := 2 + fileId := 3 + expectedData := []byte("file content here") + expectedContentType := "application/pdf" + expectedFileName := "test.pdf" + + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("Expected GET method, got %s", r.Method) + } + + expectedPath := fmt.Sprintf("/files/recordId/%d/fieldId/%d/fileId/%d/file", recordId, fieldId, fileId) + + if r.URL.Path != expectedPath { + t.Errorf("Expected %s endpoint, got %s", expectedPath, r.URL.Path) + } + + w.Header().Set("Content-Type", expectedContentType) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, expectedFileName)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(expectedData) + }) + + fileContent, err := client.Files.GetContent(t.Context(), recordId, fieldId, fileId) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if fileContent.FileName != expectedFileName { + t.Errorf("Expected file name %s but got %s", expectedFileName, fileContent.FileName) + } + + if fileContent.ContentType != expectedContentType { + t.Errorf("Expected content type %s but got %s", expectedContentType, fileContent.ContentType) + } + + if !bytes.Equal(expectedData, fileContent.Data) { + t.Errorf("Expected data %v but got %v", expectedData, fileContent.Data) + } + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("it should return an error if context is nil", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + var nilContext context.Context = nil + + err := client.Files.Delete(nilContext, 1, 1, 1) + + if err == nil { + t.Errorf("Expected error for nil context, got nil") + } + }) + + t.Run("it should return an error if context is canceled", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + ctx, cancel := context.WithCancel(t.Context()) + + cancel() + + err := client.Files.Delete(ctx, 1, 1, 1) + + if err == nil { + t.Errorf("Expected error for canceled context, got nil") + } + }) + + t.Run("it should return an error if encounters a network error", func(t *testing.T) { + client := onspring.NewClient( + "test-api-key", + onspring.WithBaseURL("http://invalid-url"), + onspring.WithHTTPClient(&http.Client{Transport: &ErrorTransport{}}), + ) + + err := client.Files.Delete(t.Context(), 1, 1, 1) + + if err == nil { + t.Errorf("Expected network error, got nil") + } + }) + + t.Run("it should return an error if create a request fails", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + invalidClient := onspring.NewClient( + "test-api-key", + onspring.WithBaseURL("http://[::1]:namedport"), + onspring.WithHTTPClient(client.HTTPClient()), + ) + + err := invalidClient.Files.Delete(t.Context(), 1, 1, 1) + + if err == nil { + t.Errorf("Expected request creation error, got nil") + } + }) + + t.Run("it should return an error if the endpoint returns a non-2xx status code", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + err := client.Files.Delete(t.Context(), 1, 1, 1) + + if err == nil { + t.Errorf("Expected error, got nil") + } + }) + + t.Run("it should perform a DELETE request to the correct endpoint and return no error if receives 204 status code", func(t *testing.T) { + recordId := 1 + fieldId := 2 + fileId := 3 + + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("Expected DELETE method, got %s", r.Method) + } + + expectedPath := fmt.Sprintf("/files/recordId/%d/fieldId/%d/fileId/%d", recordId, fieldId, fileId) + + if r.URL.Path != expectedPath { + t.Errorf("Expected %s endpoint, got %s", expectedPath, r.URL.Path) + } + + w.WriteHeader(http.StatusNoContent) + }) + + err := client.Files.Delete(t.Context(), recordId, fieldId, fileId) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + }) + + t.Run("Save", func(t *testing.T) { + t.Run("it should return an error if context is nil", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + }) + + var nilContext context.Context = nil + + saveReq := onspring.SaveFileRequest{ + RecordId: 1, + FieldId: 1, + FileName: "test.pdf", + FileContents: strings.NewReader("file content"), + } + + _, err := client.Files.Save(nilContext, saveReq) + + if err == nil { + t.Errorf("Expected error for nil context, got nil") + } + }) + + t.Run("it should return an error if context is canceled", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + }) + + ctx, cancel := context.WithCancel(t.Context()) + + cancel() + + saveReq := onspring.SaveFileRequest{ + RecordId: 1, + FieldId: 1, + FileName: "test.pdf", + FileContents: strings.NewReader("file content"), + } + + _, err := client.Files.Save(ctx, saveReq) + + if err == nil { + t.Errorf("Expected error for canceled context, got nil") + } + }) + + t.Run("it should return an error if encounters a network error", func(t *testing.T) { + client := onspring.NewClient( + "test-api-key", + onspring.WithBaseURL("http://invalid-url"), + onspring.WithHTTPClient(&http.Client{Transport: &ErrorTransport{}}), + ) + + saveReq := onspring.SaveFileRequest{ + RecordId: 1, + FieldId: 1, + FileName: "test.pdf", + FileContents: strings.NewReader("file content"), + } + + _, err := client.Files.Save(t.Context(), saveReq) + + if err == nil { + t.Errorf("Expected network error, got nil") + } + }) + + t.Run("it should return an error if create a request fails", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + }) + + invalidClient := onspring.NewClient( + "test-api-key", + onspring.WithBaseURL("http://[::1]:namedport"), + onspring.WithHTTPClient(client.HTTPClient()), + ) + + saveReq := onspring.SaveFileRequest{ + RecordId: 1, + FieldId: 1, + FileName: "test.pdf", + FileContents: strings.NewReader("file content"), + } + + _, err := invalidClient.Files.Save(t.Context(), saveReq) + + if err == nil { + t.Errorf("Expected request creation error, got nil") + } + }) + + t.Run("it should return an error if the endpoint returns a non-2xx status code", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + saveReq := onspring.SaveFileRequest{ + RecordId: 1, + FieldId: 1, + FileName: "test.pdf", + FileContents: strings.NewReader("file content"), + } + + _, err := client.Files.Save(t.Context(), saveReq) + + if err == nil { + t.Errorf("Expected error, got nil") + } + }) + + t.Run("it should perform a POST request to the /files endpoint with multipart form data and return a response when successful", func(t *testing.T) { + expectedRecordId := 1 + expectedFieldId := 2 + expectedNotes := "Test notes" + expectedModifiedDate := "2024-01-01T00:00:00Z" + expectedFileName := "test.pdf" + expectedFileContent := "file content here" + + expectedResponse := onspring.SaveFileResponse{ + Id: 42, + } + + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("Expected POST method, got %s", r.Method) + } + + if r.URL.Path != "/files" { + t.Errorf("Expected /files endpoint, got %s", r.URL.Path) + } + + contentType := r.Header.Get("Content-Type") + + if !strings.HasPrefix(contentType, "multipart/form-data") { + t.Errorf("Expected multipart/form-data content type, got %s", contentType) + } + + err := r.ParseMultipartForm(10 << 20) + + if err != nil { + t.Errorf("Expected to parse multipart form, but got error: %v", err) + } + + recordId := r.FormValue("RecordId") + + if recordId != fmt.Sprintf("%d", expectedRecordId) { + t.Errorf("Expected RecordId %d but got %s", expectedRecordId, recordId) + } + + fieldId := r.FormValue("FieldId") + + if fieldId != fmt.Sprintf("%d", expectedFieldId) { + t.Errorf("Expected FieldId %d but got %s", expectedFieldId, fieldId) + } + + notes := r.FormValue("Notes") + + if notes != expectedNotes { + t.Errorf("Expected Notes %s but got %s", expectedNotes, notes) + } + + modifiedDate := r.FormValue("ModifiedDate") + + if modifiedDate != expectedModifiedDate { + t.Errorf("Expected ModifiedDate %s but got %s", expectedModifiedDate, modifiedDate) + } + + file, header, err := r.FormFile("File") + + if err != nil { + t.Errorf("Expected to get file from form, but got error: %v", err) + } + + defer func() { + _ = file.Close() + }() + + if header.Filename != expectedFileName { + t.Errorf("Expected file name %s but got %s", expectedFileName, header.Filename) + } + + fileBytes, _ := io.ReadAll(file) + + if string(fileBytes) != expectedFileContent { + t.Errorf("Expected file content %s but got %s", expectedFileContent, string(fileBytes)) + } + + jsonData, _ := json.Marshal(expectedResponse) + + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jsonData) + }) + + saveReq := onspring.SaveFileRequest{ + RecordId: expectedRecordId, + FieldId: expectedFieldId, + Notes: expectedNotes, + ModifiedDate: expectedModifiedDate, + FileName: expectedFileName, + FileContents: strings.NewReader(expectedFileContent), + } + + response, err := client.Files.Save(t.Context(), saveReq) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if !reflect.DeepEqual(expectedResponse, response) { + t.Errorf("Expected %v but got %v", expectedResponse, response) + } + }) + }) +}