mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 07:34:25 +02:00
* feat(skills): search runtime local skills * feat(skills): highlight matched substrings in runtime local skill search Reuse the shared HighlightText (the same component the global search command uses) to highlight matched substrings in a result's name, provider, description, and path, so styling stays consistent across the app. Narrow the search to the fields the row actually renders and drop `key`, so every match maps to something visible. While a query is active, lift the description's 2-line clamp so a match past the first two lines stays on screen instead of being clipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): zero unread for archived chat sessions across all badges (MUL-4360) Archiving a chat session flips status but deliberately does not advance last_read_at, and ListAllChatSessionsByCreator counted unread unconditionally. So an archived session that had unread replies kept reporting has_unread=true / unread_count>0 — a stuck badge the user can never clear (archived sessions are read-only and hidden from history, so there is no mark-as-read entry). MUL-4372 fixed only the quick-chat FAB surface; the sidebar Chat tab badge and the chat-window "other unread" header still counted it. Fix at the source: derive unread_count = 0 for status='archived' rows in ListAllChatSessionsByCreator. Because has_unread is server-derived as unread_count > 0, and all surfaces (FAB, sidebar via countUnreadChatMessages, chat-window header, and mobile) read this one payload, every badge drops archived sessions with no per-surface filter. last_read_at is left untouched so unarchiving restores the true unread state. Installed desktop clients benefit without an app update. Also zero unread optimistically in the archive mutation so no badge counts a just-archived session in the frame before the refetch lands (FAB already filtered archived; this keeps sidebar/header consistent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): stop auto-mark-read from clearing a transiently-active session on mount (MUL-4360) The chat page persists `activeSessionId`, so on a bare `/chat` navigation it restores the last-open session as active for one frame before its URL→store effect (which runs AFTER useChatController's effects, since the hook is called first) clears it back to null. The auto-mark-read effect fired in that gap and marked the restored-but-never-opened session read — its unread badge vanished though the right pane still showed "select a chat" and the user never opened it. This is why the sidebar count dropped (e.g. 2 → 1) just by entering the tab. Defer the read by a tick and cancel it on cleanup: a session that is only momentarily active (restored on mount, then cleared) has its pending read cancelled when activeSessionId changes; only a session that stays active past the tick — a real select, deep link, or refresh — is marked read. A live-store re-check in the timer is a belt-and-suspenders guard. Adds the previously-missing auto-mark-read coverage: a stable-active session is read after the tick; a momentarily-active one is not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: coderbaozi <YHbaozi1988@163.com> Co-authored-by: abun <103836393+coderbaozi@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
868 lines
32 KiB
Go
868 lines
32 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"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 ",
|
|
"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")
|
|
}
|
|
if sendResp.TaskID == "" {
|
|
t.Fatal("expected non-empty task_id in send response")
|
|
}
|
|
|
|
var messageTaskID string
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT COALESCE(task_id::text, '') FROM chat_message WHERE id = $1`,
|
|
sendResp.MessageID,
|
|
).Scan(&messageTaskID); err != nil {
|
|
t.Fatalf("query chat message task id: %v", err)
|
|
}
|
|
if messageTaskID != sendResp.TaskID {
|
|
t.Fatalf("chat message task_id mismatch: want %s, got %s", sendResp.TaskID, messageTaskID)
|
|
}
|
|
|
|
// 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_ArchivedAgent verifies that sending to a session whose
|
|
// agent was archived is rejected with 409 BEFORE any message is persisted.
|
|
// EnqueueChatTask rejects an archived agent, but only after CreateChatMessage;
|
|
// without the handler's preflight a stale client would leave an orphan user
|
|
// message with no task or reply.
|
|
func TestSendChatMessage_ArchivedAgent(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatArchivedAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
// Archive the agent out from under the (stale) client.
|
|
if _, err := testPool.Exec(
|
|
context.Background(),
|
|
`UPDATE agent SET archived_at = now() WHERE id = $1`,
|
|
agentID,
|
|
); err != nil {
|
|
t.Fatalf("archive agent: %v", err)
|
|
}
|
|
|
|
sendReq := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{
|
|
"content": "still there?",
|
|
})
|
|
sendReq = withURLParam(sendReq, "sessionId", sessionID)
|
|
sendReq = withChatTestWorkspaceCtx(t, sendReq)
|
|
sendW := httptest.NewRecorder()
|
|
testHandler.SendChatMessage(sendW, sendReq)
|
|
|
|
if sendW.Code != http.StatusConflict {
|
|
t.Fatalf("SendChatMessage to archived agent: expected 409, got %d: %s", sendW.Code, sendW.Body.String())
|
|
}
|
|
|
|
// The rejected send must not have persisted an orphan user message.
|
|
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 messages: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected no chat_message rows after rejected send, got %d", count)
|
|
}
|
|
}
|
|
|
|
// TestSendChatMessage_LinksUnattachedAttachments verifies the new compose
|
|
// path: upload creates a workspace-scoped unattached attachment, and chat send
|
|
// binds it to both the session and the user message.
|
|
func TestSendChatMessage_LinksUnattachedAttachments(t *testing.T) {
|
|
origStorage := testHandler.Storage
|
|
testHandler.Storage = &mockStorage{}
|
|
defer func() { testHandler.Storage = origStorage }()
|
|
|
|
agentID := createHandlerTestAgent(t, "ChatSendUnattachedAttachAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, _ := writer.CreateFormFile("file", "send-unattached.png")
|
|
part.Write([]byte("\x89PNG\r\n\x1a\nbytes"))
|
|
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)
|
|
})
|
|
if uploadResp.ChatSessionID != nil {
|
|
t.Fatalf("pre-send chat_session_id should be nil, got %v", *uploadResp.ChatSessionID)
|
|
}
|
|
if uploadResp.ChatMessageID != nil {
|
|
t.Fatalf("pre-send chat_message_id should be nil, got %v", *uploadResp.ChatMessageID)
|
|
}
|
|
|
|
sendReq := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{
|
|
"content": "look at this ",
|
|
"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)
|
|
}
|
|
|
|
var dbSessionID, dbMessageID *string
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT chat_session_id::text, chat_message_id::text FROM attachment WHERE id = $1`,
|
|
attachmentID,
|
|
).Scan(&dbSessionID, &dbMessageID); err != nil {
|
|
t.Fatalf("query attachment: %v", err)
|
|
}
|
|
if dbSessionID == nil || *dbSessionID != sessionID {
|
|
t.Fatalf("chat_session_id mismatch: want %s, got %v", sessionID, dbSessionID)
|
|
}
|
|
if dbMessageID == nil || *dbMessageID != sendResp.MessageID {
|
|
t.Fatalf("chat_message_id mismatch: want %s, got %v", 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)
|
|
}
|
|
}
|
|
|
|
// TestSetChatSessionPinned_TogglesPin confirms PATCH /pin stamps pinned_at on
|
|
// pin and clears it on unpin, returns the new state, and does not bump
|
|
// updated_at (pinning is a list-ordering preference, not activity).
|
|
func TestSetChatSessionPinned_TogglesPin(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatPinAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
var updatedBefore time.Time
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT updated_at FROM chat_session WHERE id = $1`,
|
|
sessionID,
|
|
).Scan(&updatedBefore); err != nil {
|
|
t.Fatalf("query updated_at: %v", err)
|
|
}
|
|
|
|
pin := func(pinned bool) ChatSessionResponse {
|
|
req := newRequest("PATCH", "/api/chat/sessions/"+sessionID+"/pin", map[string]any{
|
|
"pinned": pinned,
|
|
})
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.SetChatSessionPinned(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SetChatSessionPinned(%v): expected 200, got %d: %s", pinned, w.Code, w.Body.String())
|
|
}
|
|
var resp ChatSessionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode pin: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// Pin.
|
|
if resp := pin(true); !resp.Pinned {
|
|
t.Fatalf("pin=true response Pinned: want true, got false")
|
|
}
|
|
var pinnedAt *time.Time
|
|
var updatedAfter time.Time
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT pinned_at, updated_at FROM chat_session WHERE id = $1`,
|
|
sessionID,
|
|
).Scan(&pinnedAt, &updatedAfter); err != nil {
|
|
t.Fatalf("query pinned_at: %v", err)
|
|
}
|
|
if pinnedAt == nil {
|
|
t.Fatalf("pinned_at: want non-null after pin, got null")
|
|
}
|
|
if !updatedAfter.Equal(updatedBefore) {
|
|
t.Fatalf("updated_at must not change on pin: before %v, after %v", updatedBefore, updatedAfter)
|
|
}
|
|
|
|
// Unpin.
|
|
if resp := pin(false); resp.Pinned {
|
|
t.Fatalf("pin=false response Pinned: want false, got true")
|
|
}
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT pinned_at FROM chat_session WHERE id = $1`,
|
|
sessionID,
|
|
).Scan(&pinnedAt); err != nil {
|
|
t.Fatalf("query pinned_at after unpin: %v", err)
|
|
}
|
|
if pinnedAt != nil {
|
|
t.Fatalf("pinned_at: want null after unpin, got %v", *pinnedAt)
|
|
}
|
|
}
|
|
|
|
// TestSetChatSessionArchived_TogglesStatus archives then unarchives a session,
|
|
// asserting the response + DB status column flip and that updated_at is bumped
|
|
// (so the row re-sorts in whichever list it lands).
|
|
func TestSetChatSessionArchived_TogglesStatus(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatArchiveAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
archive := func(archived bool) ChatSessionResponse {
|
|
req := newRequest("PATCH", "/api/chat/sessions/"+sessionID+"/archive", map[string]any{
|
|
"archived": archived,
|
|
})
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.SetChatSessionArchived(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SetChatSessionArchived(%v): expected 200, got %d: %s", archived, w.Code, w.Body.String())
|
|
}
|
|
var resp ChatSessionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode archive: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
dbStatus := func() string {
|
|
var status string
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT status FROM chat_session WHERE id = $1`,
|
|
sessionID,
|
|
).Scan(&status); err != nil {
|
|
t.Fatalf("query status: %v", err)
|
|
}
|
|
return status
|
|
}
|
|
|
|
// Archive.
|
|
if resp := archive(true); resp.Status != "archived" {
|
|
t.Fatalf("archive=true response Status: want archived, got %q", resp.Status)
|
|
}
|
|
if got := dbStatus(); got != "archived" {
|
|
t.Fatalf("db status after archive: want archived, got %q", got)
|
|
}
|
|
|
|
// Unarchive restores it to active.
|
|
if resp := archive(false); resp.Status != "active" {
|
|
t.Fatalf("archive=false response Status: want active, got %q", resp.Status)
|
|
}
|
|
if got := dbStatus(); got != "active" {
|
|
t.Fatalf("db status after unarchive: want active, got %q", got)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
func fetchChatMessagesPageForTest(t *testing.T, sessionID string, params url.Values) ChatMessagesPageResponse {
|
|
t.Helper()
|
|
target := "/api/chat/sessions/" + sessionID + "/messages/page"
|
|
if encoded := params.Encode(); encoded != "" {
|
|
target += "?" + encoded
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, target, nil)
|
|
req.Header.Set("X-User-ID", testUserID)
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListChatMessagesPage(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListChatMessagesPage: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var page ChatMessagesPageResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
|
|
t.Fatalf("decode page messages: %v", err)
|
|
}
|
|
return page
|
|
}
|
|
|
|
func TestListChatMessagesPage_UsesCursorWithoutChangingLegacyList(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatCursorPaginationAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
for i, content := range []string{"oldest", "middle", "newest"} {
|
|
_, err := testPool.Exec(
|
|
context.Background(),
|
|
`INSERT INTO chat_message (chat_session_id, role, content, created_at)
|
|
VALUES ($1, 'user', $2, timestamp '2026-01-01 00:00:00' + ($3::int * interval '1 second'))`,
|
|
sessionID,
|
|
content,
|
|
i,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert chat message %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
legacyReq := httptest.NewRequest(http.MethodGet, "/api/chat/sessions/"+sessionID+"/messages", nil)
|
|
legacyReq.Header.Set("X-User-ID", testUserID)
|
|
legacyReq = withURLParam(legacyReq, "sessionId", sessionID)
|
|
legacyReq = withChatTestWorkspaceCtx(t, legacyReq)
|
|
legacyW := httptest.NewRecorder()
|
|
testHandler.ListChatMessages(legacyW, legacyReq)
|
|
if legacyW.Code != http.StatusOK {
|
|
t.Fatalf("ListChatMessages: expected 200, got %d: %s", legacyW.Code, legacyW.Body.String())
|
|
}
|
|
var legacy []ChatMessageResponse
|
|
if err := json.Unmarshal(legacyW.Body.Bytes(), &legacy); err != nil {
|
|
t.Fatalf("decode legacy messages: %v", err)
|
|
}
|
|
if len(legacy) != 3 || legacy[0].Content != "oldest" || legacy[2].Content != "newest" {
|
|
t.Fatalf("legacy messages = %#v", legacy)
|
|
}
|
|
|
|
latest := fetchChatMessagesPageForTest(t, sessionID, url.Values{"limit": {"2"}})
|
|
if latest.Limit != 2 || !latest.HasMore || latest.NextCursor == nil {
|
|
t.Fatalf("latest page metadata = %#v", latest)
|
|
}
|
|
if len(latest.Messages) != 2 || latest.Messages[0].Content != "middle" || latest.Messages[1].Content != "newest" {
|
|
t.Fatalf("latest page messages = %#v", latest)
|
|
}
|
|
|
|
older := fetchChatMessagesPageForTest(t, sessionID, url.Values{
|
|
"limit": {"2"},
|
|
"before_created_at": {latest.NextCursor.CreatedAt},
|
|
"before_id": {latest.NextCursor.ID},
|
|
})
|
|
if older.HasMore || older.NextCursor != nil {
|
|
t.Fatalf("older page metadata = %#v", older)
|
|
}
|
|
if len(older.Messages) != 1 || older.Messages[0].Content != "oldest" {
|
|
t.Fatalf("older page messages = %#v", older)
|
|
}
|
|
}
|
|
|
|
func TestListChatMessagesPage_CursorTieBreaksSameTimestampWithoutDupesOrGaps(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatCursorTieBreakAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
contents := []string{"a", "b", "c", "d", "e"}
|
|
for _, content := range contents {
|
|
_, err := testPool.Exec(
|
|
context.Background(),
|
|
`INSERT INTO chat_message (chat_session_id, role, content, created_at)
|
|
VALUES ($1, 'user', $2, timestamp '2026-01-01 00:00:00')`,
|
|
sessionID,
|
|
content,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert chat message %q: %v", content, err)
|
|
}
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
var ordered []string
|
|
params := url.Values{"limit": {"2"}}
|
|
for {
|
|
page := fetchChatMessagesPageForTest(t, sessionID, params)
|
|
for _, msg := range page.Messages {
|
|
if seen[msg.ID] {
|
|
t.Fatalf("duplicate message id %s across cursor pages", msg.ID)
|
|
}
|
|
seen[msg.ID] = true
|
|
ordered = append(ordered, msg.Content)
|
|
}
|
|
if !page.HasMore {
|
|
if page.NextCursor != nil {
|
|
t.Fatalf("terminal page has next cursor: %#v", page.NextCursor)
|
|
}
|
|
break
|
|
}
|
|
if page.NextCursor == nil {
|
|
t.Fatalf("has_more page missing next cursor: %#v", page)
|
|
}
|
|
params = url.Values{
|
|
"limit": {"2"},
|
|
"before_created_at": {page.NextCursor.CreatedAt},
|
|
"before_id": {page.NextCursor.ID},
|
|
}
|
|
}
|
|
|
|
if len(ordered) != len(contents) {
|
|
t.Fatalf("expected %d messages across pages, got %d: %v", len(contents), len(ordered), ordered)
|
|
}
|
|
// Pages are newest-window first and chronological within each page. With all
|
|
// timestamps equal, the id tie-break must still produce a deterministic,
|
|
// gap-free traversal.
|
|
for _, content := range contents {
|
|
found := false
|
|
for _, got := range ordered {
|
|
if got == content {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("missing content %q across cursor pages: %v", content, ordered)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListChatMessagesPage_RejectsInvalidLimit(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatPaginationBadLimitAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/chat/sessions/"+sessionID+"/messages/page?limit=0", nil)
|
|
req.Header.Set("X-User-ID", testUserID)
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListChatMessagesPage(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("ListChatMessagesPage invalid limit: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestDeleteChatSession_PrunesChannelRows verifies the application-layer
|
|
// replacement for the channel_* chat_session-FK cascade (MUL-3515 §4): deleting a
|
|
// chat session prunes BOTH its channel_chat_session_binding and its
|
|
// channel_outbound_card_message rows in the same tx that deletes the session row.
|
|
// Both are keyed by chat_session_id with no FK and no reaper, so a miss leaves a
|
|
// permanent orphan (Elon's follow-up on #4810).
|
|
func TestDeleteChatSession_PrunesChannelRows(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatDeleteBindingAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
ctx := context.Background()
|
|
|
|
const appID = "cli_chat_delete_binding"
|
|
const channelChatID = "oc_chat_delete_binding"
|
|
const cardMsgID = "om_chat_delete_card"
|
|
|
|
// channel_* rows have no FK to chat_session/workspace (MUL-3515 §4), so
|
|
// they outlive the helper's chat_session cleanup; clear by deterministic
|
|
// key before and after.
|
|
cleanChannel := func() {
|
|
_, _ = testPool.Exec(context.Background(),
|
|
`DELETE FROM channel_chat_session_binding WHERE channel_chat_id = $1`, channelChatID)
|
|
_, _ = testPool.Exec(context.Background(),
|
|
`DELETE FROM channel_outbound_card_message WHERE channel_card_message_id = $1`, cardMsgID)
|
|
_, _ = testPool.Exec(context.Background(),
|
|
`DELETE FROM channel_installation WHERE channel_type = 'feishu' AND config->>'app_id' = $1`, appID)
|
|
}
|
|
cleanChannel()
|
|
t.Cleanup(cleanChannel)
|
|
|
|
var installID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO channel_installation (workspace_id, agent_id, channel_type, config, installer_user_id)
|
|
VALUES ($1, $2, 'feishu', jsonb_build_object('app_id', $3::text), $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, appID, testUserID).Scan(&installID); err != nil {
|
|
t.Fatalf("insert channel_installation: %v", err)
|
|
}
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO channel_chat_session_binding (chat_session_id, installation_id, channel_type, channel_chat_id, chat_type)
|
|
VALUES ($1, $2, 'feishu', $3, 'p2p')
|
|
`, sessionID, installID, channelChatID); err != nil {
|
|
t.Fatalf("insert channel_chat_session_binding: %v", err)
|
|
}
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO channel_outbound_card_message (chat_session_id, channel_type, channel_chat_id, channel_card_message_id, status)
|
|
VALUES ($1, 'feishu', $2, $3, 'final')
|
|
`, sessionID, channelChatID, cardMsgID); err != nil {
|
|
t.Fatalf("insert channel_outbound_card_message: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/chat/sessions/"+sessionID, nil)
|
|
req.Header.Set("X-User-ID", testUserID)
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.DeleteChatSession(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteChatSession: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var bindingExists bool
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT EXISTS (SELECT 1 FROM channel_chat_session_binding WHERE channel_chat_id = $1)`, channelChatID).Scan(&bindingExists); err != nil {
|
|
t.Fatalf("query chat session binding: %v", err)
|
|
}
|
|
if bindingExists {
|
|
t.Fatal("deleted chat session's channel_chat_session_binding was not pruned")
|
|
}
|
|
|
|
var cardExists bool
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT EXISTS (SELECT 1 FROM channel_outbound_card_message WHERE channel_card_message_id = $1)`, cardMsgID).Scan(&cardExists); err != nil {
|
|
t.Fatalf("query outbound card message: %v", err)
|
|
}
|
|
if cardExists {
|
|
t.Fatal("deleted chat session's channel_outbound_card_message was not pruned (no reaper would ever collect it)")
|
|
}
|
|
}
|
|
|
|
// TestSetChatSessionArchived_ClearsChannelBinding verifies the archive path
|
|
// severs the external-channel link (MUL-4372): the channel engine resolves
|
|
// inbound Feishu/Slack traffic through channel_chat_session_binding without
|
|
// checking session status, so an archived-but-still-bound session kept
|
|
// accumulating agent replies and a stuck, uncleared unread badge. Archiving must
|
|
// drop the binding so the next inbound message forks a fresh session; unarchive
|
|
// must NOT recreate it (a later session may already own the channel).
|
|
func TestSetChatSessionArchived_ClearsChannelBinding(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatArchiveBindingAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
ctx := context.Background()
|
|
|
|
const appID = "cli_chat_archive_binding"
|
|
const channelChatID = "oc_chat_archive_binding"
|
|
|
|
// channel_* rows have no FK to chat_session/workspace (MUL-3515 §4), so they
|
|
// outlive the helper's chat_session cleanup; clear by deterministic key.
|
|
cleanChannel := func() {
|
|
_, _ = testPool.Exec(context.Background(),
|
|
`DELETE FROM channel_chat_session_binding WHERE channel_chat_id = $1`, channelChatID)
|
|
_, _ = testPool.Exec(context.Background(),
|
|
`DELETE FROM channel_installation WHERE channel_type = 'feishu' AND config->>'app_id' = $1`, appID)
|
|
}
|
|
cleanChannel()
|
|
t.Cleanup(cleanChannel)
|
|
|
|
var installID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO channel_installation (workspace_id, agent_id, channel_type, config, installer_user_id)
|
|
VALUES ($1, $2, 'feishu', jsonb_build_object('app_id', $3::text), $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, appID, testUserID).Scan(&installID); err != nil {
|
|
t.Fatalf("insert channel_installation: %v", err)
|
|
}
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO channel_chat_session_binding (chat_session_id, installation_id, channel_type, channel_chat_id, chat_type)
|
|
VALUES ($1, $2, 'feishu', $3, 'p2p')
|
|
`, sessionID, installID, channelChatID); err != nil {
|
|
t.Fatalf("insert channel_chat_session_binding: %v", err)
|
|
}
|
|
|
|
archive := func(archived bool) {
|
|
t.Helper()
|
|
payload := `{"archived":false}`
|
|
if archived {
|
|
payload = `{"archived":true}`
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/sessions/"+sessionID+"/archive", bytes.NewReader([]byte(payload)))
|
|
req.Header.Set("X-User-ID", testUserID)
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.SetChatSessionArchived(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SetChatSessionArchived(%v): expected 200, got %d: %s", archived, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
bindingExists := func() bool {
|
|
t.Helper()
|
|
var exists bool
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT EXISTS (SELECT 1 FROM channel_chat_session_binding WHERE channel_chat_id = $1)`, channelChatID).Scan(&exists); err != nil {
|
|
t.Fatalf("query chat session binding: %v", err)
|
|
}
|
|
return exists
|
|
}
|
|
|
|
// Archive → binding must be gone so future channel traffic cannot revive
|
|
// this now read-only session.
|
|
archive(true)
|
|
if bindingExists() {
|
|
t.Fatal("archived chat session's channel_chat_session_binding was not cleared")
|
|
}
|
|
|
|
var status string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT status FROM chat_session WHERE id = $1`, sessionID).Scan(&status); err != nil {
|
|
t.Fatalf("query chat session status: %v", err)
|
|
}
|
|
if status != "archived" {
|
|
t.Fatalf("expected chat session status 'archived', got %q", status)
|
|
}
|
|
|
|
// Unarchive → must NOT recreate/steal the binding. A later inbound message
|
|
// may already have forked a new session that owns the channel now.
|
|
archive(false)
|
|
if bindingExists() {
|
|
t.Fatal("unarchive recreated the channel_chat_session_binding; it must not restore or steal the channel")
|
|
}
|
|
}
|
|
|
|
// TestListChatSessions_ArchivedSessionReportsZeroUnread pins the MUL-4360 fix:
|
|
// ListAllChatSessionsByCreator forces unread_count/has_unread to 0 for archived
|
|
// rows even when assistant messages sit past the read cursor, so a stuck unread
|
|
// badge cannot survive on any surface (FAB, sidebar Chat tab, chat-window
|
|
// header). Because archiving deliberately does NOT advance last_read_at,
|
|
// unarchiving must restore the session's true unread count.
|
|
func TestListChatSessions_ArchivedSessionReportsZeroUnread(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatArchivedUnreadAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
ctx := context.Background()
|
|
|
|
// An assistant reply the user never read.
|
|
if _, err := testPool.Exec(ctx,
|
|
`INSERT INTO chat_message (chat_session_id, role, content) VALUES ($1, 'assistant', 'unread reply')`,
|
|
sessionID); err != nil {
|
|
t.Fatalf("insert assistant message: %v", err)
|
|
}
|
|
// Pin the read cursor firmly before that message so it counts as unread
|
|
// regardless of insert-time clock skew (last_read_at defaults to now()).
|
|
if _, err := testPool.Exec(ctx,
|
|
`UPDATE chat_session SET last_read_at = 'epoch' WHERE id = $1`, sessionID); err != nil {
|
|
t.Fatalf("reset last_read_at: %v", err)
|
|
}
|
|
|
|
unreadOf := func() (int, bool) {
|
|
t.Helper()
|
|
req := newRequest("GET", "/api/chat/sessions?status=all", nil)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListChatSessions(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListChatSessions: 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 list: %v", err)
|
|
}
|
|
for _, s := range resp {
|
|
if s.ID == sessionID {
|
|
return s.UnreadCount, s.HasUnread
|
|
}
|
|
}
|
|
t.Fatalf("session %s not found in list of %d", sessionID, len(resp))
|
|
return 0, false
|
|
}
|
|
|
|
archive := func(archived bool) {
|
|
t.Helper()
|
|
req := newRequest("PATCH", "/api/chat/sessions/"+sessionID+"/archive", map[string]any{"archived": archived})
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.SetChatSessionArchived(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SetChatSessionArchived(%v): expected 200, got %d: %s", archived, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Active baseline: the unread reply counts (guards against a regression that
|
|
// would zero unread for live sessions too).
|
|
if n, has := unreadOf(); n != 1 || !has {
|
|
t.Fatalf("active session unread: want count=1 has=true, got count=%d has=%v", n, has)
|
|
}
|
|
|
|
// Archived: unread forced to 0 even though the message still sits past the cursor.
|
|
archive(true)
|
|
if n, has := unreadOf(); n != 0 || has {
|
|
t.Fatalf("archived session unread: want count=0 has=false, got count=%d has=%v", n, has)
|
|
}
|
|
|
|
// Unarchive restores the true unread state (last_read_at was never touched).
|
|
archive(false)
|
|
if n, has := unreadOf(); n != 1 || !has {
|
|
t.Fatalf("unarchived session unread: want count=1 has=true, got count=%d has=%v", n, has)
|
|
}
|
|
}
|