Files
coder/coderd/x/chatd/store_chat_attachment_test.go
T
Ethan cc4e04afde feat(site): display file attachments in chat UI (#24281)
Renders the durable file attachments introduced in #24280 in the chat
interface. Without this, attachments were stored and served correctly
but the UI showed raw file parts with no previews or download UX.

Every attachment gets a download affordance, split into three rendering
tiers:

- **Images** — thumbnail with a hover/focus overlay containing a
download link. `onFocusCapture`/`onBlurCapture` with
`contains(relatedTarget)` keeps the overlay open while tabbing between
the image and its download link.
- **Text-like files** (`text/*`, `application/json`) — expandable
preview button with loading + error-with-retry states and the same
download overlay. Preview fetches throw a typed
`FetchTextAttachmentError` with a `.status` field instead of a
stringly-typed error.
- **Everything else** — compact `FileCard` with extension badge,
filename, and download link.

User-side and assistant-side rendering now share `AttachmentBlocks.tsx`
(`AttachmentPreviewFrame`, `TextAttachmentButton`,
`ImageAttachmentButton`, `FileCard`, plus
`getAttachmentHref`/`getAttachmentName`) instead of two near-duplicate
implementations. The text-attachment overlay anchors to the preview
surface so the download button stays pinned even when a loading/error
status line widens the row below.

`ComputerRenderer` detects when a screenshot was stored as a durable
attachment (`attachment_file_id`) and suppresses the stale base64
rendering — the screenshot appears as a proper file part instead.
`ToolLabel` shows the attached filename for `attach_file` tool calls.

Storybook coverage in `ConversationTimeline.stories.tsx` was expanded to
cover every tier (single/multiple images, inline + file-id text, JSON,
download-only files, fetch-failure retry, mixed attachments + file
references) with play-function assertions.

<img width="811" height="150" alt="image"
src="https://github.com/user-attachments/assets/27c71081-3502-4e80-92a7-d8adf1ff9323"
/>



## Cleanup

Per Mathias' post-merge suggestion on #24280, this PR also relocates
`coderd/chatfiles` → `coderd/x/chatfiles` so the durable-attachment
helpers live beside the rest of the `chatd` experimental surface.

Closes CODAGT-91
2026-04-22 20:11:53 +10:00

268 lines
9.2 KiB
Go

package chatd //nolint:testpackage
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/coderd/x/chatfiles"
"github.com/coder/coder/v2/codersdk"
)
func TestStoreChatAttachment_Success(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
tx := dbmock.NewMockStore(ctrl)
server := &Server{db: db}
chatID := uuid.New()
ownerID := uuid.New()
workspaceID := uuid.New()
orgID := uuid.New()
fileID := uuid.New()
chatSnapshot := database.Chat{
ID: chatID,
OwnerID: ownerID,
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}
expectStoreChatAttachmentTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatFileParams{})).DoAndReturn(
func(_ context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
require.Equal(t, ownerID, arg.OwnerID)
require.Equal(t, orgID, arg.OrganizationID)
require.Equal(t, "build.log", arg.Name)
require.Equal(t, "text/plain", arg.Mimetype)
require.Equal(t, []byte("build output"), arg.Data)
return database.InsertChatFileRow{ID: fileID}, nil
},
)
tx.EXPECT().LinkChatFiles(gomock.Any(), database.LinkChatFilesParams{
ChatID: chatID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{fileID},
}).Return(int32(0), nil)
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "build.log", "build.log", []byte("build output"))
require.NoError(t, err)
require.Equal(t, chattool.AttachmentMetadata{
FileID: fileID,
MediaType: "text/plain",
Name: "build.log",
}, attachment)
}
func TestStoreChatAttachment_UsesDetectNameForClassification(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
tx := dbmock.NewMockStore(ctrl)
server := &Server{db: db}
chatID := uuid.New()
ownerID := uuid.New()
workspaceID := uuid.New()
orgID := uuid.New()
fileID := uuid.New()
chatSnapshot := database.Chat{
ID: chatID,
OwnerID: ownerID,
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}
expectStoreChatAttachmentTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatFileParams{})).DoAndReturn(
func(_ context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
require.Equal(t, "payload.txt", arg.Name)
require.Equal(t, "application/json", arg.Mimetype)
return database.InsertChatFileRow{ID: fileID}, nil
},
)
tx.EXPECT().LinkChatFiles(gomock.Any(), database.LinkChatFilesParams{
ChatID: chatID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{fileID},
}).Return(int32(0), nil)
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "payload.txt", "report.json", []byte(`{"ok":true}`))
require.NoError(t, err)
require.Equal(t, "payload.txt", attachment.Name)
require.Equal(t, "application/json", attachment.MediaType)
}
func TestStoreChatAttachment_RejectsUnsupportedStoredFileTypeBeforeDBWork(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
server := &Server{db: db}
chatSnapshot := database.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
}
attachment, err := server.storeChatAttachment(
context.Background(),
chatSnapshot,
"evil.svg",
"evil.svg",
[]byte(`<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`),
)
require.ErrorIs(t, err, chatfiles.ErrUnsupportedStoredFileType)
require.ErrorContains(t, err, "image/svg+xml")
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
}
func TestStoreChatAttachment_NoWorkspace(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
server := &Server{db: db}
attachment, err := server.storeChatAttachment(context.Background(), database.Chat{}, "build.log", "build.log", []byte("build output"))
require.ErrorContains(t, err, "no workspace is associated")
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
}
func TestStoreChatAttachment_WorkspaceLookupError(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
tx := dbmock.NewMockStore(ctrl)
server := &Server{db: db}
workspaceID := uuid.New()
chatSnapshot := database.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}
expectStoreChatAttachmentTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{}, context.DeadlineExceeded)
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "build.log", "build.log", []byte("build output"))
require.ErrorContains(t, err, "resolve workspace")
require.ErrorIs(t, err, context.DeadlineExceeded)
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
}
func TestStoreChatAttachment_InsertError(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
tx := dbmock.NewMockStore(ctrl)
server := &Server{db: db}
workspaceID := uuid.New()
chatSnapshot := database.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}
expectStoreChatAttachmentTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: uuid.New()}, nil)
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.Any()).Return(database.InsertChatFileRow{}, context.DeadlineExceeded)
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "build.log", "build.log", []byte("build output"))
require.ErrorContains(t, err, "insert chat file")
require.ErrorIs(t, err, context.DeadlineExceeded)
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
}
func TestStoreChatAttachment_StrictCapError(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
tx := dbmock.NewMockStore(ctrl)
server := &Server{db: db}
chatID := uuid.New()
ownerID := uuid.New()
workspaceID := uuid.New()
orgID := uuid.New()
fileID := uuid.New()
chatSnapshot := database.Chat{
ID: chatID,
OwnerID: ownerID,
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}
expectStoreChatAttachmentTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatFileParams{})).Return(database.InsertChatFileRow{ID: fileID}, nil)
tx.EXPECT().LinkChatFiles(gomock.Any(), database.LinkChatFilesParams{
ChatID: chatID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{fileID},
}).Return(int32(1), nil)
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "build.log", "build.log", []byte("build output"))
require.ErrorContains(t, err, "chat already has the maximum of 20 linked files")
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
}
func TestStoreChatAttachment_LinkError(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
tx := dbmock.NewMockStore(ctrl)
server := &Server{db: db}
chatID := uuid.New()
ownerID := uuid.New()
workspaceID := uuid.New()
orgID := uuid.New()
fileID := uuid.New()
chatSnapshot := database.Chat{
ID: chatID,
OwnerID: ownerID,
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}
expectStoreChatAttachmentTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.Any()).Return(database.InsertChatFileRow{ID: fileID}, nil)
tx.EXPECT().LinkChatFiles(gomock.Any(), database.LinkChatFilesParams{
ChatID: chatID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{fileID},
}).Return(int32(0), context.DeadlineExceeded)
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "build.log", "build.log", []byte("build output"))
require.ErrorContains(t, err, "link chat file")
require.ErrorIs(t, err, context.DeadlineExceeded)
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
}
func expectStoreChatAttachmentTx(t *testing.T, db, tx *dbmock.MockStore) {
t.Helper()
db.EXPECT().InTx(gomock.Any(), gomock.AssignableToTypeOf(&database.TxOptions{})).DoAndReturn(
func(fn func(database.Store) error, opts *database.TxOptions) error {
require.NotNil(t, opts)
require.Equal(t, "store_chat_attachment", opts.TxIdentifier)
return fn(tx)
},
)
}