feat(companies): align avatar form payloads

This commit is contained in:
2026-06-06 06:54:48 +08:00
parent d241925b6a
commit 0aa0470ac9
4 changed files with 176 additions and 11 deletions
+87 -6
View File
@@ -1,8 +1,10 @@
package v1
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -33,7 +35,7 @@ func (h *CompanyHandler) List(c *gin.Context) {
return
}
pg := pagination.Parse(c)
pg := parseCompanyPagination(c)
sort := c.DefaultQuery("sort", "")
companies, total, err := h.svc.List(c.Request.Context(), accountID, pg.Offset, pg.PerPage, sort)
@@ -55,7 +57,7 @@ func (h *CompanyHandler) Search(c *gin.Context) {
return
}
pg := pagination.Parse(c)
pg := parseCompanyPagination(c)
query := c.DefaultQuery("q", "")
if query == "" {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Specify search string with parameter q"})
@@ -108,7 +110,7 @@ func (h *CompanyHandler) Create(c *gin.Context) {
}
var req service.CreateCompanyRequest
if err := c.ShouldBindJSON(&req); err != nil {
if err := bindCompanyRequest(c, &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
@@ -138,7 +140,7 @@ func (h *CompanyHandler) Update(c *gin.Context) {
}
var req service.UpdateCompanyRequest
if err := c.ShouldBindJSON(&req); err != nil {
if err := bindCompanyRequest(c, &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
@@ -239,7 +241,7 @@ func (h *CompanyHandler) ListContacts(c *gin.Context) {
return
}
pg := pagination.Parse(c)
pg := parseCompanyPagination(c)
contacts, total, svcErr := h.svc.ListContacts(c.Request.Context(), uint(companyID), accountID, pg.Offset, pg.PerPage)
if svcErr != nil {
@@ -292,7 +294,7 @@ func (h *CompanyHandler) SearchContacts(c *gin.Context) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Specify search string with parameter q"})
return
}
pg := pagination.Parse(c)
pg := parseCompanyPagination(c)
contacts, total, svcErr := h.svc.SearchContacts(c.Request.Context(), uint(companyID), accountID, query, pg.Offset, pg.PerPage)
if svcErr != nil {
handleServiceError(c, svcErr)
@@ -471,3 +473,82 @@ func parseCompanyContactID(c *gin.Context) (uint, error) {
}
return req.ContactID, nil
}
type companyRequest interface {
*service.CreateCompanyRequest | *service.UpdateCompanyRequest
}
func parseCompanyPagination(c *gin.Context) pagination.Params {
pg := pagination.Parse(c)
pg.PerPage = service.CompanyResultsPerPage
pg.Offset = (pg.Page - 1) * pg.PerPage
return pg
}
func bindCompanyRequest[T companyRequest](c *gin.Context, req T) error {
if !strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
return c.ShouldBindJSON(req)
}
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
return err
}
name := companyFormValue(c, "name")
description := companyFormValue(c, "description")
websiteURL := companyFormValue(c, "website_url")
faviconURL := companyFormValue(c, "favicon_url")
domain := companyFormValue(c, "domain")
if file, err := c.FormFile("company[avatar]"); err == nil && file != nil {
faviconURL = file.Filename
}
customAttributes := companyFormJSON(c, "custom_attributes")
switch r := any(req).(type) {
case *service.CreateCompanyRequest:
r.Name = name
r.Description = description
r.WebsiteURL = websiteURL
r.FaviconURL = faviconURL
r.Domain = domain
r.CustomAttributes = customAttributes
case *service.UpdateCompanyRequest:
r.Name = name
r.Description = description
r.WebsiteURL = websiteURL
r.FaviconURL = faviconURL
r.Domain = domain
r.CustomAttributes = customAttributes
}
return nil
}
func companyFormValue(c *gin.Context, key string) string {
if value := c.PostForm("company[" + key + "]"); value != "" {
return value
}
return c.PostForm(key)
}
func companyFormJSON(c *gin.Context, key string) []byte {
if c.Request.MultipartForm == nil {
return nil
}
if value := companyFormValue(c, key); value != "" {
if json.Valid([]byte(value)) {
return []byte(value)
}
}
prefix := "company[" + key + "]["
attrs := map[string]any{}
for formKey, values := range c.Request.MultipartForm.Value {
if !strings.HasPrefix(formKey, prefix) || !strings.HasSuffix(formKey, "]") || len(values) == 0 {
continue
}
attrKey := strings.TrimSuffix(strings.TrimPrefix(formKey, prefix), "]")
attrs[attrKey] = values[0]
}
if len(attrs) == 0 {
return nil
}
data, _ := json.Marshal(attrs)
return data
}
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
@@ -153,6 +154,26 @@ func (s *CompanyHandlerTestSuite) makeRequest(method, path string, body interfac
return w
}
func (s *CompanyHandlerTestSuite) makeMultipartRequest(method, path string, fields map[string]string, files map[string]string) *httptest.ResponseRecorder {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for key, value := range fields {
s.Require().NoError(writer.WriteField(key, value))
}
for key, filename := range files {
part, err := writer.CreateFormFile(key, filename)
s.Require().NoError(err)
_, err = part.Write([]byte("avatar-bytes"))
s.Require().NoError(err)
}
s.Require().NoError(writer.Close())
req, _ := http.NewRequest(method, path, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
return w
}
// ========== List ==========
func (s *CompanyHandlerTestSuite) TestList_Empty() {
@@ -184,6 +205,22 @@ func (s *CompanyHandlerTestSuite) TestList_WithCompanies() {
assert.Equal(s.T(), float64(2), resp["meta"].(map[string]interface{})["total_count"])
}
func (s *CompanyHandlerTestSuite) TestList_IgnoresPerPage() {
companyRepo := repository.NewCompanyRepo(s.db)
for _, name := range []string{"ListCorp1", "ListCorp2", "ListCorp3"} {
s.Require().NoError(companyRepo.Create(context.Background(), &model.Company{AccountID: s.accountID, Name: name}))
}
w := s.makeRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/companies/?page=1&per_page=1", s.accountID), nil)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].([]interface{})
assert.Len(s.T(), payload, 3)
assert.Equal(s.T(), float64(3), resp["meta"].(map[string]interface{})["total_count"])
}
// ========== Create ==========
func (s *CompanyHandlerTestSuite) TestCreate_Success() {
@@ -204,6 +241,29 @@ func (s *CompanyHandlerTestSuite) TestCreate_Success() {
assert.Equal(s.T(), "NewCorp", companyData["name"])
}
func (s *CompanyHandlerTestSuite) TestCreate_MultipartAvatar() {
w := s.makeMultipartRequest(
"POST",
fmt.Sprintf("/api/v1/accounts/%d/companies/", s.accountID),
map[string]string{
"company[name]": "AvatarCorp",
"company[domain]": "avatar.example",
"company[custom_attributes][segment]": "enterprise",
},
map[string]string{"company[avatar]": "avatar.png"},
)
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
companyData := resp["payload"].(map[string]interface{})
assert.Equal(s.T(), "AvatarCorp", companyData["name"])
assert.Equal(s.T(), "avatar.example", companyData["domain"])
assert.Equal(s.T(), "avatar.png", companyData["avatar_url"])
attrs := companyData["custom_attributes"].(map[string]interface{})
assert.Equal(s.T(), "enterprise", attrs["segment"])
}
func (s *CompanyHandlerTestSuite) TestCreate_ValidationError() {
body := map[string]interface{}{
"name": "", // required field
@@ -276,6 +336,26 @@ func (s *CompanyHandlerTestSuite) TestUpdate_Success() {
assert.Equal(s.T(), "UpdateCorpUpdated", companyData["name"])
}
func (s *CompanyHandlerTestSuite) TestUpdate_MultipartAvatar() {
companyRepo := repository.NewCompanyRepo(s.db)
company := &model.Company{AccountID: s.accountID, Name: "AvatarUpdateCorp"}
s.Require().NoError(companyRepo.Create(context.Background(), company))
w := s.makeMultipartRequest(
"PATCH",
fmt.Sprintf("/api/v1/accounts/%d/companies/%d", s.accountID, company.ID),
map[string]string{"company[name]": "AvatarUpdateCorp", "company[domain]": "updated-avatar.example"},
map[string]string{"company[avatar]": "updated-avatar.png"},
)
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
companyData := resp["payload"].(map[string]interface{})
assert.Equal(s.T(), "updated-avatar.example", companyData["domain"])
assert.Equal(s.T(), "updated-avatar.png", companyData["avatar_url"])
}
func (s *CompanyHandlerTestSuite) TestUpdate_NotFound() {
body := map[string]interface{}{
"name": "NonexistentCorp",