Files
gochat/backend/internal/handler/api/v1/upload_handler_test.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

468 lines
19 KiB
Go

package v1
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
)
// mockUploadService implements a mock for UploadService handler tests.
// We can't easily mock UploadService because it's a concrete type, not an interface.
// Instead, we test the handler layer by checking HTTP status codes and response shapes.
func setupUploadHandlerRouter(h *UploadHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
// Account upload route
api := r.Group("/api/v1/accounts/:account_id")
api.POST("/upload", h.Upload)
// Account direct upload route
api.POST("/direct_uploads", h.AccountDirectUpload)
api.POST("/conversations/:conversation_id/direct_uploads", h.ConversationDirectUpload)
api.PUT("/conversations/:conversation_id/direct_uploads/:upload_uuid", h.CompleteConversationDirectUpload)
// Widget direct upload route
widget := r.Group("/widget")
widget.POST("/direct_uploads", h.DirectUpload)
widget.PUT("/direct_uploads/:upload_uuid", h.CompleteWidgetDirectUpload)
chatwootWidget := r.Group("/api/v1/widget")
chatwootWidget.POST("/direct_uploads", h.DirectUpload)
chatwootWidget.PUT("/direct_uploads/:upload_uuid", h.CompleteWidgetDirectUpload)
return r
}
// makeMultipartUploadBody creates a multipart form body with a file field.
func makeMultipartUploadBody(filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
return makeMultipartUploadBodyWithField("file", filename, content)
}
func makeMultipartUploadBodyWithField(fieldName, filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
body = &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile(fieldName, filename)
if err != nil {
return nil, "", err
}
part.Write(content)
writer.Close()
return body, writer.FormDataContentType(), nil
}
func TestUploadHandler_Upload_NoFile(t *testing.T) {
// Create handler with nil service — we only test validation before service call
h := &UploadHandler{svc: nil}
r := setupUploadHandlerRouter(h)
// Send request without file (no multipart body)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", nil)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Set account_id in context (simulating middleware)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
}
func TestUploadHandler_Upload_ChatwootAttachmentFieldRawPayload(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
body, contentType, err := makeMultipartUploadBodyWithField("attachment", "macro.png", []byte("fake png"))
require.NoError(t, err)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(t, payload, "success")
assert.NotEmpty(t, payload["file_url"])
assert.NotEmpty(t, payload["blob_id"])
assert.Equal(t, payload["blob_id"], payload["blob_key"])
}
func TestUploadHandler_Upload_ChatwootExternalURLRawPayload(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("external image"))
}))
defer server.Close()
body := strings.NewReader(`{"external_url":"` + server.URL + `/image.png"}`)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(t, payload, "success")
assert.NotEmpty(t, payload["file_url"])
assert.NotEmpty(t, payload["blob_id"])
}
func TestUploadHandler_DirectUpload_NoFile(t *testing.T) {
// Create handler with nil service — we only test validation before service call
h := &UploadHandler{svc: nil}
r := setupUploadHandlerRouter(h)
req, _ := http.NewRequest("POST", "/widget/direct_uploads", nil)
req.Header.Set("Content-Type", "multipart/form-data")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestUploadHandler_WidgetActiveStorageDirectUploadFlow(t *testing.T) {
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.Account{},
&model.Inbox{},
&model.Contact{},
&model.ContactInbox{},
&model.DirectUpload{},
))
account := &model.Account{Name: "Widget Upload Org", Status: "active"}
require.NoError(t, db.Create(account).Error)
channelConfig, err := json.Marshal(service.WebWidgetConfig{WebsiteToken: "upload_ws_token"})
require.NoError(t, err)
inbox := &model.Inbox{AccountID: account.ID, Name: "Upload Widget", ChannelType: "web_widget", Enabled: true, ChannelConfig: string(channelConfig)}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Uploader"}
require.NoError(t, db.Create(contact).Error)
contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "upload_pubsub_token"}
require.NoError(t, db.Create(contactInbox).Error)
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{
Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20},
}).WithWidgetAuth(repository.NewInboxRepo(db), repository.NewContactInboxRepo(db))
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
metadataBody, err := json.Marshal(map[string]any{
"blob": map[string]any{
"filename": "visitor.png",
"byte_size": 11,
"checksum": "checksum-token",
"content_type": "image/png",
"metadata": map[string]any{"identified": true},
},
})
require.NoError(t, err)
wCreate := httptest.NewRecorder()
reqCreate, _ := http.NewRequest("POST", "/api/v1/widget/direct_uploads?website_token=upload_ws_token", bytes.NewReader(metadataBody))
reqCreate.Header.Set("Content-Type", "application/json")
reqCreate.Header.Set("X-Auth-Token", "upload_pubsub_token")
router.ServeHTTP(wCreate, reqCreate)
require.Equal(t, http.StatusOK, wCreate.Code)
var createResp map[string]any
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
signedID, ok := createResp["signed_id"].(string)
require.True(t, ok)
require.NotEmpty(t, signedID)
assert.Equal(t, "visitor.png", createResp["filename"])
directUpload := createResp["direct_upload"].(map[string]any)
assert.Equal(t, "/api/v1/widget/direct_uploads/"+signedID, directUpload["url"])
assert.Equal(t, map[string]any{"Content-Type": "image/png"}, directUpload["headers"])
assert.Equal(t, "gochat_local", createResp["service_name"])
assert.Equal(t, float64(11), createResp["byte_size"])
assert.Equal(t, "checksum-token", createResp["checksum"])
assert.Equal(t, true, createResp["metadata"].(map[string]any)["identified"])
assert.NotEmpty(t, createResp["key"])
wPut := httptest.NewRecorder()
reqPut, _ := http.NewRequest("PUT", directUpload["url"].(string), bytes.NewReader([]byte("hello image")))
reqPut.Header.Set("Content-Type", "image/png")
router.ServeHTTP(wPut, reqPut)
require.Equal(t, http.StatusOK, wPut.Code)
var completeResp map[string]any
require.NoError(t, json.Unmarshal(wPut.Body.Bytes(), &completeResp))
require.Equal(t, true, completeResp["success"])
completeData := completeResp["data"].(map[string]any)
assert.Equal(t, signedID, completeData["upload_uuid"])
assert.Equal(t, "visitor.png", completeData["original_name"])
assert.Equal(t, "image", completeData["file_type"])
assert.Equal(t, "image/png", completeData["mime_type"])
assert.Equal(t, float64(11), completeData["file_size"])
var upload model.DirectUpload
require.NoError(t, db.Where("upload_uuid = ?", signedID).First(&upload).Error)
assert.Equal(t, account.ID, upload.AccountID)
storedBytes, err := os.ReadFile(filepath.Join(tmpDir, "widget_direct", signedID+".png"))
require.NoError(t, err)
assert.Equal(t, []byte("hello image"), storedBytes)
}
func TestUploadHandler_ConversationActiveStorageDirectUploadFlow(t *testing.T) {
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.Account{},
&model.Inbox{},
&model.Contact{},
&model.Conversation{},
&model.DirectUpload{},
))
account := &model.Account{Name: "Conversation Upload Org", Status: "active"}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "Conversation Upload Inbox", ChannelType: "web_widget", Enabled: true}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Composer"}
require.NoError(t, db.Create(contact).Error)
displayID := uint(44)
conversation := &model.Conversation{
AccountID: account.ID,
InboxID: inbox.ID,
ContactID: contact.ID,
DisplayID: &displayID,
Status: "open",
ChannelType: "web_widget",
Channel: "web_widget",
}
require.NoError(t, db.Create(conversation).Error)
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{
Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20},
}).WithConversationRepo(repository.NewConversationRepo(db))
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
metadataBody, err := json.Marshal(map[string]any{
"blob": map[string]any{
"filename": "agent-note.pdf",
"byte_size": 12,
"checksum": "pdf-checksum",
"content_type": "application/pdf",
"metadata": map[string]any{"identified": true},
},
})
require.NoError(t, err)
createPath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/conversations/44/direct_uploads"
wCreate := httptest.NewRecorder()
reqCreate, _ := http.NewRequest("POST", createPath, bytes.NewReader(metadataBody))
reqCreate.Header.Set("Content-Type", "application/json")
router.ServeHTTP(wCreate, reqCreate)
require.Equal(t, http.StatusOK, wCreate.Code)
var createResp map[string]any
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
signedID, ok := createResp["signed_id"].(string)
require.True(t, ok)
require.NotEmpty(t, signedID)
assert.Equal(t, "agent-note.pdf", createResp["filename"])
directUpload := createResp["direct_upload"].(map[string]any)
assert.Equal(t, createPath+"/"+signedID, directUpload["url"])
assert.Equal(t, map[string]any{"Content-Type": "application/pdf"}, directUpload["headers"])
assert.Equal(t, "gochat_local", createResp["service_name"])
assert.Equal(t, float64(12), createResp["byte_size"])
assert.Equal(t, "pdf-checksum", createResp["checksum"])
assert.NotEmpty(t, createResp["key"])
wPut := httptest.NewRecorder()
reqPut, _ := http.NewRequest("PUT", directUpload["url"].(string), bytes.NewReader([]byte("hello report")))
reqPut.Header.Set("Content-Type", "application/pdf")
router.ServeHTTP(wPut, reqPut)
require.Equal(t, http.StatusOK, wPut.Code)
var completeResp map[string]any
require.NoError(t, json.Unmarshal(wPut.Body.Bytes(), &completeResp))
require.Equal(t, true, completeResp["success"])
completeData := completeResp["data"].(map[string]any)
assert.Equal(t, signedID, completeData["upload_uuid"])
assert.Equal(t, "agent-note.pdf", completeData["original_name"])
assert.Equal(t, "file", completeData["file_type"])
assert.Equal(t, "application/pdf", completeData["mime_type"])
assert.Equal(t, float64(12), completeData["file_size"])
var upload model.DirectUpload
require.NoError(t, db.Where("upload_uuid = ?", signedID).First(&upload).Error)
assert.Equal(t, account.ID, upload.AccountID)
assert.Equal(t, model.DirectUploadSourceAccount, upload.Source)
storedBytes, err := os.ReadFile(filepath.Join(tmpDir, "account", strconv.FormatUint(uint64(account.ID), 10), signedID+".pdf"))
require.NoError(t, err)
assert.Equal(t, []byte("hello report"), storedBytes)
}
func TestUploadHandler_ConversationActiveStorageDirectUploadRejectsUnsupportedMIME(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.Account{},
&model.Inbox{},
&model.Contact{},
&model.Conversation{},
&model.DirectUpload{},
))
account := &model.Account{Name: "Conversation Upload Validation Org", Status: "active"}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "Conversation Upload Validation Inbox", ChannelType: "web_widget", Enabled: true}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Composer Validation"}
require.NoError(t, db.Create(contact).Error)
displayID := uint(45)
conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
require.NoError(t, db.Create(conversation).Error)
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: t.TempDir(), MaxFileSize: 50 << 20}}).WithConversationRepo(repository.NewConversationRepo(db))
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
metadataBody, err := json.Marshal(map[string]any{
"blob": map[string]any{
"filename": "malware.exe",
"byte_size": 12,
"checksum": "bad-checksum",
"content_type": "application/x-msdownload",
},
})
require.NoError(t, err)
w := httptest.NewRecorder()
path := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/conversations/45/direct_uploads"
req, _ := http.NewRequest("POST", path, bytes.NewReader(metadataBody))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.Equal(t, false, payload["success"])
assert.Equal(t, "VALIDATION_ERROR", payload["error"].(map[string]any)["code"])
assert.Contains(t, payload["error"].(map[string]any)["message"], "unsupported file type")
}
func TestUploadHandler_AccountDirectUpload_NoFile(t *testing.T) {
// Create handler with nil service — we only test validation before service call
h := &UploadHandler{svc: nil}
r := setupUploadHandlerRouter(h)
// Send request without file (no multipart body)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/direct_uploads", nil)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestUploadHandler_AccountDirectUpload_NoAccountID(t *testing.T) {
// Create handler with nil service — we only test validation before service call
h := &UploadHandler{svc: nil}
r := setupUploadHandlerRouter(h)
// Use a non-numeric account_id so getAccountID returns 0
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/direct_uploads", nil)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestUploadHandler_AccountDirectUpload_WithFileButNilService(t *testing.T) {
// Create handler with nil service — request passes validation but service call panics.
// This test verifies that account_id extraction + file extraction work correctly
// before the service call. In real integration tests, we use a real service.
h := &UploadHandler{svc: nil}
r := setupUploadHandlerRouter(h)
body, contentType, err := makeMultipartUploadBody("test.png", []byte("fake png"))
assert.NoError(t, err)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/direct_uploads", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
// This will panic because svc is nil, but it proves validation passes.
// We recover the panic and verify the request got past the validation checks.
defer func() {
if r := recover(); r != nil {
// Expected: nil service causes panic after validation passes
t.Logf("Recovered expected panic from nil service: %v", r)
}
}()
r.ServeHTTP(w, req)
}
func TestUploadHandler_ResponseStructure(t *testing.T) {
// Verify UploadResponse DTO structure matches expected JSON keys
resp := service.UploadResponse{
UploadID: 1,
UploadUUID: "abc-123",
OriginalName: "test.png",
FileType: "image",
MimeType: "image/png",
FileSize: 1024,
FileURL: "/uploads/account/1/test.png",
ThumbURL: "/uploads/account/1/test.png",
Status: "pending",
}
data, err := json.Marshal(resp)
assert.NoError(t, err)
var parsed map[string]interface{}
assert.NoError(t, json.Unmarshal(data, &parsed))
assert.Equal(t, float64(1), parsed["upload_id"])
assert.Equal(t, "abc-123", parsed["upload_uuid"])
assert.Equal(t, "test.png", parsed["original_name"])
assert.Equal(t, "image", parsed["file_type"])
assert.Equal(t, "image/png", parsed["mime_type"])
assert.Equal(t, float64(1024), parsed["file_size"])
assert.Equal(t, "/uploads/account/1/test.png", parsed["file_url"])
assert.Equal(t, "/uploads/account/1/test.png", parsed["thumb_url"])
assert.Equal(t, "pending", parsed["status"])
}