Files
coder/coderd/httpapi/chatlabels_test.go
Kyle Carberry d4660d8a69 feat: add labels to chats (#23594)
## Summary

Adds a general-purpose `map[string]string` label system to chats, stored
as jsonb with a GIN index for efficient containment queries.

This is a standalone foundational feature that will be used by the
upcoming Automations feature for session identity (matching webhook
events to existing chats), replacing the need for bespoke session-key
tables.

## Changes

### Database
- **Migration 000451**: Adds `labels jsonb NOT NULL DEFAULT '{}'` column
to `chats` table with a GIN index (`idx_chats_labels`)
- **`InsertChat`**: Accepts labels on creation via `COALESCE(@labels,
'{}')`
- **`UpdateChatByID`**: Supports partial update —
`COALESCE(sqlc.narg('labels'), labels)` preserves existing labels when
NULL is passed
- **`GetChats`**: New `has_labels` filter using PostgreSQL `@>`
containment operator
- **`GetAuthorizedChats`**: Synced with generated `GetChats` (new column
scan + query param)

### API
- **Create chat** (`POST /chats`): Accepts optional `labels` field,
validated before creation
- **Update chat** (`PATCH /chats/{chat}`): Supports `labels` field for
atomic label replacement
- **List chats** (`GET /chats`): Supports `?label=key:value` query
parameters (multiple are AND-ed)

### SDK
- `Chat`, `CreateChatRequest`, `UpdateChatRequest`, `ListChatsOptions`
all gain `Labels` fields
- `UpdateChatRequest.Labels` is a pointer (`*map[string]string`) so
`nil` means "don't change" vs empty map means "clear all"

### Validation (`coderd/httpapi/labels.go`)
- Max 50 labels per chat
- Key: 1–64 chars, must match `[a-zA-Z0-9][a-zA-Z0-9._/-]*` (supports
namespaced keys like `github.repo`, `automation/pr-number`)
- Value: 1–256 chars
- 13 test cases covering all edge cases

### Chat runtime
- `chatd.CreateOptions` gains `Labels` field, threaded through to
`InsertChat`
- Existing `UpdateChatByID` callers (e.g., quickgen title updates) are
unaffected — NULL labels preserve existing values via COALESCE
2026-03-25 17:26:26 +00:00

192 lines
4.4 KiB
Go

package httpapi_test
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/httpapi"
)
func TestValidateChatLabels(t *testing.T) {
t.Parallel()
t.Run("NilMap", func(t *testing.T) {
t.Parallel()
errs := httpapi.ValidateChatLabels(nil)
require.Empty(t, errs)
})
t.Run("EmptyMap", func(t *testing.T) {
t.Parallel()
errs := httpapi.ValidateChatLabels(map[string]string{})
require.Empty(t, errs)
})
t.Run("ValidLabels", func(t *testing.T) {
t.Parallel()
labels := map[string]string{
"env": "production",
"github.repo": "coder/coder",
"automation/pr": "12345",
"team-backend": "core",
"version_number": "v1.2.3",
"A1.b2/c3-d4_e5": "mixed",
}
errs := httpapi.ValidateChatLabels(labels)
require.Empty(t, errs)
})
t.Run("TooManyLabels", func(t *testing.T) {
t.Parallel()
labels := make(map[string]string, 51)
for i := range 51 {
labels[strings.Repeat("k", i+1)] = "v"
}
errs := httpapi.ValidateChatLabels(labels)
require.NotEmpty(t, errs)
found := false
for _, e := range errs {
if strings.Contains(e.Detail, "too many labels") {
found = true
break
}
}
assert.True(t, found, "expected a 'too many labels' error")
})
t.Run("KeyTooLong", func(t *testing.T) {
t.Parallel()
longKey := strings.Repeat("a", 65)
labels := map[string]string{
longKey: "value",
}
errs := httpapi.ValidateChatLabels(labels)
require.NotEmpty(t, errs)
found := false
for _, e := range errs {
if strings.Contains(e.Detail, "exceeds maximum length of 64 bytes") {
found = true
break
}
}
assert.True(t, found, "expected a key-too-long error")
})
t.Run("ValueTooLong", func(t *testing.T) {
t.Parallel()
longValue := strings.Repeat("v", 257)
labels := map[string]string{
"key": longValue,
}
errs := httpapi.ValidateChatLabels(labels)
require.NotEmpty(t, errs)
found := false
for _, e := range errs {
if strings.Contains(e.Detail, "exceeds maximum length of 256 bytes") {
found = true
break
}
}
assert.True(t, found, "expected a value-too-long error")
})
t.Run("InvalidKeyWithSpaces", func(t *testing.T) {
t.Parallel()
labels := map[string]string{
"invalid key": "value",
}
errs := httpapi.ValidateChatLabels(labels)
require.NotEmpty(t, errs)
found := false
for _, e := range errs {
if strings.Contains(e.Detail, "contains invalid characters") {
found = true
break
}
}
assert.True(t, found, "expected an invalid-characters error for spaces")
})
t.Run("InvalidKeyWithSpecialChars", func(t *testing.T) {
t.Parallel()
labels := map[string]string{
"key@value": "value",
}
errs := httpapi.ValidateChatLabels(labels)
require.NotEmpty(t, errs)
found := false
for _, e := range errs {
if strings.Contains(e.Detail, "contains invalid characters") {
found = true
break
}
}
assert.True(t, found, "expected an invalid-characters error for special chars")
})
t.Run("KeyStartsWithNonAlphanumeric", func(t *testing.T) {
t.Parallel()
labels := map[string]string{
".dotfirst": "value",
"-dashfirst": "value",
"_underfirst": "value",
"/slashfirst": "value",
}
errs := httpapi.ValidateChatLabels(labels)
// Each of the four keys should produce an error.
require.Len(t, errs, 4)
for _, e := range errs {
assert.Contains(t, e.Detail, "contains invalid characters")
}
})
t.Run("EmptyKey", func(t *testing.T) {
t.Parallel()
labels := map[string]string{
"": "value",
}
errs := httpapi.ValidateChatLabels(labels)
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Detail, "must not be empty")
})
t.Run("EmptyValue", func(t *testing.T) {
t.Parallel()
labels := map[string]string{
"key": "",
}
errs := httpapi.ValidateChatLabels(labels)
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Detail, "must not be empty")
})
t.Run("AllFieldsAreLabels", func(t *testing.T) {
t.Parallel()
labels := map[string]string{
"bad key": "",
}
errs := httpapi.ValidateChatLabels(labels)
for _, e := range errs {
assert.Equal(t, "labels", e.Field)
}
})
t.Run("ExactlyAtLimits", func(t *testing.T) {
t.Parallel()
// Keys and values exactly at their limits should be valid.
labels := map[string]string{
strings.Repeat("a", 64): strings.Repeat("v", 256),
}
errs := httpapi.ValidateChatLabels(labels)
require.Empty(t, errs)
})
}