Files
multica/server/internal/handler/chat_test.go
Naiyuan Qing 86aa5199fc feat(chat): support attachments & images in chat input (#2445)
* docs(plans): chat attachment & image support implementation plan

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(db): add chat_session_id/chat_message_id to attachment

Co-authored-by: multica-agent <github@multica.ai>

* feat(db): sqlc — chat_session_id on CreateAttachment + LinkAttachmentsToChatMessage

Co-authored-by: multica-agent <github@multica.ai>

* feat(file): upload-file accepts chat_session_id form field

Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): SendChatMessage links uploaded attachments to the new message

Co-authored-by: multica-agent <github@multica.ai>

* feat(api): uploadFile accepts chatSessionId; sendChatMessage accepts attachmentIds

Co-authored-by: multica-agent <github@multica.ai>

* feat(core): useFileUpload supports chatSessionId context

Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): support paste/drag/upload attachments in chat input

Co-authored-by: multica-agent <github@multica.ai>

* test(e2e): chat input attachment upload + send round-trip

Co-authored-by: multica-agent <github@multica.ai>

* chore(chat): keep lazy-created session title empty so untitled fallback localizes

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address review — dedupe ensureSession + parse upload response

- chat-window: cache in-flight createSession promise in a ref so a file drop
  followed by a quick send no longer spawns two sessions (and orphans the
  attachment on the losing one).
- Attachment type + EMPTY_ATTACHMENT + AttachmentResponseSchema: include the
  new chat_session_id / chat_message_id fields the server now returns.
- uploadFile: route the response through parseWithFallback so a malformed
  body returns EMPTY_ATTACHMENT instead of an undefined-keyed Attachment,
  matching the API boundary rule.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address PR #2445 review — test ctx, send gating, attachment surface

1. Backend test was 400ing because the handler reads workspace from
   middleware-injected ctx, and `newRequest` only sets the header. Helper
   `withChatTestWorkspaceCtx` mirrors the agent-access-test pattern and
   loads the member row + SetMemberContext before invoking the handler.

2. Attachment metadata now flows end-to-end:
   - new sqlc `ListAttachmentsByChatMessageIDs` (batch lookup, mirrors the
     comment-side query)
   - `chatMessageToResponse` takes `attachments` and `ChatMessageResponse`
     surfaces them — same shape as CommentResponse
   - `ListChatMessages` loads them via a new `groupChatMessageAttachments`
     helper so the chat bubble can render file cards
   - daemon claim path pulls `ListAttachmentsByChatMessage` for the latest
     user message and ships `ChatMessageAttachments` to the daemon
   - `buildChatPrompt` lists id+filename+content_type and instructs the
     agent to `multica attachment download <id>` — fixes the private-CDN
     expiring-URL problem where the markdown URL would have expired by
     the time the agent acts
   - TS `ChatMessage` gains an optional `attachments` field

3. Chat composer now blocks send while uploads are in flight:
   - `pendingUploads` counter increments in handleUpload, SubmitButton
     uses it to disable
   - handleSend also gates on `editorRef.current.hasActiveUploads()` to
     catch the Mod+Enter path that bypasses the button
   - new vitest covers the "drop large file → immediate send" scenario
     where attachment id would otherwise be silently dropped

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* chore: drop implementation plan doc

Process artefact, not something the repo needs to keep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 10:57:54 +08:00

141 lines
5.1 KiB
Go

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)
}
}
// 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)
}
}