package handler import ( "bytes" "context" "encoding/json" "mime/multipart" "net/http" "net/http/httptest" "testing" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" ) // withChatTestWorkspaceCtx injects the workspace+member context that the // real chi middleware chain would normally set. SendChatMessage (and most // other chat handlers) read workspace ID from ctxWorkspaceID; without this // the test harness, which calls handlers directly, gets "invalid workspace // id" on the parseUUIDOrBadRequest call inside SendChatMessage. func withChatTestWorkspaceCtx(t *testing.T, req *http.Request) *http.Request { t.Helper() memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{ UserID: util.MustParseUUID(testUserID), WorkspaceID: util.MustParseUUID(testWorkspaceID), }) if err != nil { t.Fatalf("load test member row: %v", err) } return req.WithContext(middleware.SetMemberContext(req.Context(), testWorkspaceID, memberRow)) } // TestSendChatMessage_LinksAttachments verifies that attachments uploaded // against a chat_session (chat_message_id NULL) are back-filled with the // message_id when SendChatMessage receives the matching attachment_ids. func TestSendChatMessage_LinksAttachments(t *testing.T) { origStorage := testHandler.Storage testHandler.Storage = &mockStorage{} defer func() { testHandler.Storage = origStorage }() agentID := createHandlerTestAgent(t, "ChatSendAttachAgent", []byte("[]")) sessionID := createHandlerTestChatSession(t, agentID) // 1. Upload a file against the chat session. var body bytes.Buffer writer := multipart.NewWriter(&body) part, _ := writer.CreateFormFile("file", "send-link.png") part.Write([]byte("\x89PNG\r\n\x1a\nbytes")) writer.WriteField("chat_session_id", sessionID) writer.Close() uploadReq := httptest.NewRequest("POST", "/api/upload-file", &body) uploadReq.Header.Set("Content-Type", writer.FormDataContentType()) uploadReq.Header.Set("X-User-ID", testUserID) uploadReq.Header.Set("X-Workspace-ID", testWorkspaceID) uploadW := httptest.NewRecorder() testHandler.UploadFile(uploadW, uploadReq) if uploadW.Code != http.StatusOK { t.Fatalf("upload precondition: %d %s", uploadW.Code, uploadW.Body.String()) } var uploadResp AttachmentResponse if err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResp); err != nil { t.Fatalf("decode upload: %v", err) } attachmentID := uploadResp.ID t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, attachmentID) }) // 2. Send a chat message that references the attachment. sendReq := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{ "content": "look at this ![](" + uploadResp.URL + ")", "attachment_ids": []string{attachmentID}, }) sendReq = withURLParam(sendReq, "sessionId", sessionID) sendReq = withChatTestWorkspaceCtx(t, sendReq) sendW := httptest.NewRecorder() testHandler.SendChatMessage(sendW, sendReq) if sendW.Code != http.StatusCreated { t.Fatalf("SendChatMessage: expected 201, got %d: %s", sendW.Code, sendW.Body.String()) } var sendResp SendChatMessageResponse if err := json.Unmarshal(sendW.Body.Bytes(), &sendResp); err != nil { t.Fatalf("decode send: %v", err) } if sendResp.MessageID == "" { t.Fatal("expected non-empty message_id in send response") } // 3. Verify the attachment row now points at the new message. var dbMessageID *string if err := testPool.QueryRow( context.Background(), `SELECT chat_message_id::text FROM attachment WHERE id = $1`, attachmentID, ).Scan(&dbMessageID); err != nil { t.Fatalf("query attachment: %v", err) } if dbMessageID == nil { t.Fatal("chat_message_id is still NULL after send") } if *dbMessageID != sendResp.MessageID { t.Fatalf("chat_message_id mismatch: want %s, got %s", sendResp.MessageID, *dbMessageID) } } // TestUpdateChatSession_RenamesTitle confirms PATCH writes the new title, // returns the updated row, and the server-side row reflects it. func TestUpdateChatSession_RenamesTitle(t *testing.T) { agentID := createHandlerTestAgent(t, "ChatRenameAgent", []byte("[]")) sessionID := createHandlerTestChatSession(t, agentID) req := newRequest("PATCH", "/api/chat/sessions/"+sessionID, map[string]any{ "title": " Renamed Session ", }) req = withURLParam(req, "sessionId", sessionID) req = withChatTestWorkspaceCtx(t, req) w := httptest.NewRecorder() testHandler.UpdateChatSession(w, req) if w.Code != http.StatusOK { t.Fatalf("UpdateChatSession: expected 200, got %d: %s", w.Code, w.Body.String()) } var resp ChatSessionResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode update: %v", err) } if resp.Title != "Renamed Session" { t.Fatalf("response title: want %q, got %q", "Renamed Session", resp.Title) } var dbTitle string if err := testPool.QueryRow( context.Background(), `SELECT title FROM chat_session WHERE id = $1`, sessionID, ).Scan(&dbTitle); err != nil { t.Fatalf("query chat_session: %v", err) } if dbTitle != "Renamed Session" { t.Fatalf("db title: want %q, got %q", "Renamed Session", dbTitle) } } // TestUpdateChatSession_RejectsBlank refuses an empty/whitespace title with 400. // (Untitled is a render-side fallback, not a stored value.) func TestUpdateChatSession_RejectsBlank(t *testing.T) { agentID := createHandlerTestAgent(t, "ChatRenameBlankAgent", []byte("[]")) sessionID := createHandlerTestChatSession(t, agentID) req := newRequest("PATCH", "/api/chat/sessions/"+sessionID, map[string]any{ "title": " ", }) req = withURLParam(req, "sessionId", sessionID) req = withChatTestWorkspaceCtx(t, req) w := httptest.NewRecorder() testHandler.UpdateChatSession(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("UpdateChatSession blank: expected 400, got %d: %s", w.Code, w.Body.String()) } } // TestSendChatMessage_InvalidAttachmentIDs rejects malformed UUIDs in // attachment_ids with 400 before any side effects (no message row created). func TestSendChatMessage_InvalidAttachmentIDs(t *testing.T) { agentID := createHandlerTestAgent(t, "ChatBadAttachAgent", []byte("[]")) sessionID := createHandlerTestChatSession(t, agentID) req := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{ "content": "hi", "attachment_ids": []string{"not-a-uuid"}, }) req = withURLParam(req, "sessionId", sessionID) req = withChatTestWorkspaceCtx(t, req) w := httptest.NewRecorder() testHandler.SendChatMessage(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("SendChatMessage with bad attachment id: expected 400, got %d: %s", w.Code, w.Body.String()) } // Confirm no message row was created. var count int if err := testPool.QueryRow( context.Background(), `SELECT count(*) FROM chat_message WHERE chat_session_id = $1`, sessionID, ).Scan(&count); err != nil { t.Fatalf("count chat_message: %v", err) } if count != 0 { t.Fatalf("expected 0 chat_message rows after rejected send, got %d", count) } }