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"]) }