Files
coder/codersdk/chats_test.go
T
Michael Suchacz 6b8f820493 feat: remove native chat cost tracking in favor of AI Gateway cost data (#27330)
## Stack Context

This stack makes AI Gateway data and budgets the source of truth for AI
spend controls.

1. Re-back the per-chat cost endpoint with AI Gateway data (#27328,
merged).
2. Remove native chat usage limits (#27329, merged).
3. **This PR, now based on `main`:** remove native chat cost tracking
and its dedicated admin UI.

## Summary

Removes native per-message price calculation, model pricing fields, cost
persistence, aggregate cost queries, and admin cost API types. It also
deletes the Analytics and Spend pages plus their legacy redirects. The
AI Gateway-backed per-chat cost row and compact budget indicators
remain.

The spend documentation is renamed to `spend-management.md` and updated
for the remaining surfaces, group budget APIs, CSV export, upgrade
handling for native pricing and cost history, and the absence of a
deployment-wide spend dashboard. The per-chat cost API documents that
data follows AI Gateway retention and reports zero after all matching
requests are purged.

No schema is dropped in this release. `chat_messages.total_cost_micros`
remains nullable and unwritten so replicas from the previous release can
continue inserting messages during rolling upgrades. #27600 tracks
removal after the compatibility window.

> Mux prepared this PR on Mike's behalf.
2026-08-04 12:27:38 +02:00

673 lines
21 KiB
Go

package codersdk_test
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
)
func TestChatModelProviderOptions_MarshalJSON_UsesPlainProviderPayload(t *testing.T) {
t.Parallel()
sendReasoning := true
thinkingDisplay := "summarized"
raw, err := json.Marshal(codersdk.ChatModelProviderOptions{
Anthropic: &codersdk.ChatModelAnthropicProviderOptions{
SendReasoning: &sendReasoning,
ThinkingDisplay: &thinkingDisplay,
},
})
require.NoError(t, err)
require.NotContains(t, string(raw), `"type":"anthropic.options"`)
require.NotContains(t, string(raw), `"data":`)
require.Contains(t, string(raw), `"send_reasoning":true`)
require.Contains(t, string(raw), `"thinking_display":"summarized"`)
}
func TestChatModelProviderOptions_UnmarshalJSON_ParsesPlainProviderPayloads(t *testing.T) {
t.Parallel()
raw := []byte(`{
"anthropic": {
"send_reasoning": true,
"thinking_display": "summarized"
}
}`)
var decoded codersdk.ChatModelProviderOptions
err := json.Unmarshal(raw, &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.Anthropic)
require.NotNil(t, decoded.Anthropic.SendReasoning)
require.True(t, *decoded.Anthropic.SendReasoning)
require.NotNil(t, decoded.Anthropic.ThinkingDisplay)
require.Equal(t, "summarized", *decoded.Anthropic.ThinkingDisplay)
}
func TestChatErrorKind_JSONRoundTrip(t *testing.T) {
t.Parallel()
terminal := codersdk.ChatError{
Message: "limit reached",
Kind: codersdk.ChatErrorKindUsageLimit,
}
data, err := json.Marshal(terminal)
require.NoError(t, err)
require.Contains(t, string(data), `"kind":"usage_limit"`)
var decodedTerminal codersdk.ChatError
require.NoError(t, json.Unmarshal(data, &decodedTerminal))
require.Equal(t, codersdk.ChatErrorKindUsageLimit, decodedTerminal.Kind)
retry := codersdk.ChatStreamRetry{
Attempt: 1,
Error: "retrying",
Kind: codersdk.ChatErrorKindUsageLimit,
}
data, err = json.Marshal(retry)
require.NoError(t, err)
require.Contains(t, string(data), `"kind":"usage_limit"`)
var decodedRetry codersdk.ChatStreamRetry
require.NoError(t, json.Unmarshal(data, &decodedRetry))
require.Equal(t, codersdk.ChatErrorKindUsageLimit, decodedRetry.Kind)
}
func TestChatStreamEvent_JSONRoundTripIncludesResetTypesAndPartMetadata(t *testing.T) {
t.Parallel()
chatID := uuid.New()
events := []codersdk.ChatStreamEvent{
{Type: codersdk.ChatStreamEventTypePreviewReset, ChatID: chatID},
{Type: codersdk.ChatStreamEventTypeHistoryReset, ChatID: chatID},
{
Type: codersdk.ChatStreamEventTypeMessagePart,
ChatID: chatID,
MessagePart: &codersdk.ChatStreamMessagePart{
Role: codersdk.ChatMessageRoleAssistant,
Part: codersdk.ChatMessageText("partial"),
HistoryVersion: 12,
GenerationAttempt: 3,
Seq: 4,
},
},
}
data, err := json.Marshal(events)
require.NoError(t, err)
require.Contains(t, string(data), `"type":"preview_reset"`)
require.Contains(t, string(data), `"type":"history_reset"`)
require.Contains(t, string(data), `"history_version":12`)
require.Contains(t, string(data), `"generation_attempt":3`)
require.Contains(t, string(data), `"seq":4`)
var decoded []codersdk.ChatStreamEvent
require.NoError(t, json.Unmarshal(data, &decoded))
require.Equal(t, codersdk.ChatStreamEventTypePreviewReset, decoded[0].Type)
require.Equal(t, codersdk.ChatStreamEventTypeHistoryReset, decoded[1].Type)
require.Equal(t, int64(12), decoded[2].MessagePart.HistoryVersion)
require.Equal(t, int64(3), decoded[2].MessagePart.GenerationAttempt)
require.Equal(t, int64(4), decoded[2].MessagePart.Seq)
}
func TestChatMessagePart_StripInternal(t *testing.T) {
t.Parallel()
t.Run("StripsProviderMetadata", func(t *testing.T) {
t.Parallel()
part := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: "call-1",
ToolName: "some_tool",
Args: json.RawMessage(`{"key":"value"}`),
ProviderMetadata: json.RawMessage(`{"type":"ephemeral"}`),
}
part.StripInternal()
assert.Nil(t, part.ProviderMetadata)
// Public fields preserved.
assert.Equal(t, codersdk.ChatMessagePartTypeToolCall, part.Type)
assert.Equal(t, "call-1", part.ToolCallID)
assert.Equal(t, "some_tool", part.ToolName)
assert.JSONEq(t, `{"key":"value"}`, string(part.Args))
})
t.Run("StripsFileDataWhenFileIDSet", func(t *testing.T) {
t.Parallel()
id := uuid.New()
part := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeFile,
FileID: uuid.NullUUID{UUID: id, Valid: true},
MediaType: "image/png",
Data: []byte("binary-payload"),
}
part.StripInternal()
assert.Nil(t, part.Data)
assert.Equal(t, id, part.FileID.UUID)
assert.Equal(t, "image/png", part.MediaType)
})
t.Run("PreservesDataWhenNoFileID", func(t *testing.T) {
t.Parallel()
part := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeFile,
MediaType: "image/png",
Data: []byte("inline-data"),
}
part.StripInternal()
assert.Equal(t, []byte("inline-data"), part.Data)
})
t.Run("StripsContextFileContent", func(t *testing.T) {
t.Parallel()
agentID := uuid.New()
part := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: "/home/coder/AGENTS.md",
ContextFileContent: "large content",
ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true},
ContextFileOS: "linux",
ContextFileDirectory: "/home/coder/project",
ContextFileSkillMetaFile: "CUSTOM.md",
}
part.StripInternal()
// Internal fields stripped.
assert.Empty(t, part.ContextFileContent)
assert.Empty(t, part.ContextFileOS)
assert.Empty(t, part.ContextFileDirectory)
assert.Empty(t, part.ContextFileSkillMetaFile)
// Public fields preserved.
assert.Equal(t, "/home/coder/AGENTS.md", part.ContextFilePath)
assert.Equal(t, agentID, part.ContextFileAgentID.UUID)
assert.True(t, part.ContextFileAgentID.Valid)
})
t.Run("NoopOnCleanPart", func(t *testing.T) {
t.Parallel()
part := codersdk.ChatMessageText("hello")
part.StripInternal()
assert.Equal(t, "hello", part.Text)
assert.Equal(t, codersdk.ChatMessagePartTypeText, part.Type)
})
}
func TestChatModelReasoningEffortConfigEnumTags(t *testing.T) {
t.Parallel()
want := strings.Join(codersdk.ChatModelReasoningEffortValues(), ",")
typ := reflect.TypeOf(codersdk.ChatModelReasoningEffortConfig{})
for _, fieldName := range []string{"Default", "Max"} {
field, ok := typ.FieldByName(fieldName)
require.True(t, ok)
require.Equal(t, want, field.Tag.Get("enum"))
}
}
// TestChatMessagePartVariantTags validates the `variants` struct tags
// on ChatMessagePart fields. Every field must either declare variant
// membership or be explicitly excluded, and every known part type
// must appear in at least one tag.
//
// If this test fails, edit the variants struct tags on ChatMessagePart
// in codersdk/chats.go.
func TestChatMessagePartVariantTags(t *testing.T) {
t.Parallel()
const editHint = "edit the variants struct tags on ChatMessagePart in codersdk/chats.go"
// Fields intentionally excluded from all generated variants.
// If you add a new field to ChatMessagePart, either add a
// variants tag or add it here with a comment explaining why.
excludedFields := map[string]string{
"type": "discriminant, added automatically by codegen",
"provider_metadata": "internal only, stripped by db2sdk before API responses",
"context_file_content": "internal only, stripped before API responses (typescript:\"-\")",
"context_file_os": "internal only, used during prompt expansion (typescript:\"-\")",
"context_file_directory": "internal only, used during prompt expansion (typescript:\"-\")",
"skill_dir": "internal only, used by read_skill tools (typescript:\"-\")",
"context_file_skill_meta_file": "internal only, restored on subsequent turns (typescript:\"-\")",
}
// Part types intentionally excluded from all generated variants.
// If you add a new part type, either reference it in a variants
// tag or add it here with a reason.
excludedTypes := map[codersdk.ChatMessagePartType]string{
codersdk.ChatMessagePartTypeHookContext: "internal only, stripped from client-facing conversions by db2sdk",
}
knownTypes := make(map[codersdk.ChatMessagePartType]bool)
for _, pt := range codersdk.AllChatMessagePartTypes() {
knownTypes[pt] = true
}
// Parse all variants tags from the struct and validate them.
typ := reflect.TypeOf(codersdk.ChatMessagePart{})
coveredTypes := make(map[codersdk.ChatMessagePartType]bool)
for i := range typ.NumField() {
f := typ.Field(i)
jsonTag := f.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
jsonName, _, _ := strings.Cut(jsonTag, ",")
varTag := f.Tag.Get("variants")
if varTag == "" {
assert.Contains(t, excludedFields, jsonName,
"field %s (json:%q) has no variants tag and is not in excludedFields; %s",
f.Name, jsonName, editHint)
continue
}
assert.NotEqual(t, "type", jsonName,
"the discriminant field must not have a variants tag; %s", editHint)
for _, entry := range strings.Split(varTag, ",") {
typeLit := codersdk.ChatMessagePartType(strings.TrimSuffix(entry, "?"))
assert.True(t, knownTypes[typeLit],
"field %s variants tag references unknown type %q; %s",
f.Name, typeLit, editHint)
coveredTypes[typeLit] = true
}
}
// Every known type must appear in at least one variants tag
// unless it is intentionally excluded from client codegen.
for pt := range knownTypes {
if _, excluded := excludedTypes[pt]; excluded {
assert.False(t, coveredTypes[pt],
"ChatMessagePartType %q is in excludedTypes but referenced by a variants tag; %s", pt, editHint)
continue
}
assert.True(t, coveredTypes[pt],
"ChatMessagePartType %q is not referenced by any variants tag; %s", pt, editHint)
}
// Enforce the omitempty <-> variants invariant:
// required in any variant => must NOT have omitempty
// optional in all variants => MUST have omitempty
// See the struct comment on ChatMessagePart for rationale.
t.Run("omitempty must match variant optionality", func(t *testing.T) {
t.Parallel()
typ := reflect.TypeOf(codersdk.ChatMessagePart{})
for i := range typ.NumField() {
f := typ.Field(i)
varTag := f.Tag.Get("variants")
if varTag == "" {
continue
}
allOptional := true
for _, entry := range strings.Split(varTag, ",") {
if !strings.HasSuffix(entry, "?") {
allOptional = false
break
}
}
jsonTag := f.Tag.Get("json")
hasOmitEmpty := strings.Contains(jsonTag, "omitempty")
if !allOptional {
assert.False(t, hasOmitEmpty,
"field %s is required in at least one variant but has omitempty in its json tag; "+
"remove omitempty so Go does not silently drop the zero value that TypeScript expects to always be present",
f.Name)
} else {
assert.True(t, hasOmitEmpty,
"field %s is optional in all variants but is missing omitempty in its json tag; "+
"add omitempty to avoid sending zero values for fields the frontend does not expect",
f.Name)
}
}
})
}
func TestChatMessagePart_CreatedAt_JSON(t *testing.T) {
t.Parallel()
t.Run("RoundTrips", func(t *testing.T) {
t.Parallel()
ts := time.Date(2025, 6, 15, 12, 30, 0, 0, time.UTC)
part := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: "tc-1",
ToolName: "execute",
CreatedAt: &ts,
}
data, err := json.Marshal(part)
require.NoError(t, err)
require.Contains(t, string(data), `"created_at"`)
var decoded codersdk.ChatMessagePart
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.CreatedAt)
require.True(t, ts.Equal(*decoded.CreatedAt))
})
t.Run("OmittedWhenNil", func(t *testing.T) {
t.Parallel()
part := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: "tc-1",
ToolName: "execute",
}
data, err := json.Marshal(part)
require.NoError(t, err)
require.NotContains(t, string(data), `"created_at"`)
})
}
func TestChatMessagePart_ReasoningTimestamps_JSON(t *testing.T) {
t.Parallel()
t.Run("RoundTrips", func(t *testing.T) {
t.Parallel()
startedAt := time.Date(2025, 6, 15, 12, 30, 0, 0, time.UTC)
completedAt := startedAt.Add(2 * time.Second)
part := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeReasoning,
Text: "thinking out loud",
CreatedAt: &startedAt,
CompletedAt: &completedAt,
}
data, err := json.Marshal(part)
require.NoError(t, err)
require.Contains(t, string(data), `"created_at"`)
require.Contains(t, string(data), `"completed_at"`)
var decoded codersdk.ChatMessagePart
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.CreatedAt)
require.NotNil(t, decoded.CompletedAt)
require.True(t, startedAt.Equal(*decoded.CreatedAt))
require.True(t, completedAt.Equal(*decoded.CompletedAt))
})
t.Run("OmittedWhenNil", func(t *testing.T) {
t.Parallel()
part := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeReasoning,
Text: "thinking out loud",
}
data, err := json.Marshal(part)
require.NoError(t, err)
require.NotContains(t, string(data), `"created_at"`)
require.NotContains(t, string(data), `"completed_at"`)
})
t.Run("LegacyCreatedAtWithoutCompletedAt", func(t *testing.T) {
t.Parallel()
// CompletedAt is omitted on messages persisted before this
// feature shipped. Confirm round-trip leaves CompletedAt nil
// while preserving CreatedAt so legacy data does not break
// API consumers.
startedAt := time.Date(2025, 6, 15, 12, 30, 0, 0, time.UTC)
part := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeReasoning,
Text: "legacy reasoning",
CreatedAt: &startedAt,
}
data, err := json.Marshal(part)
require.NoError(t, err)
require.Contains(t, string(data), `"created_at"`)
require.NotContains(t, string(data), `"completed_at"`)
var decoded codersdk.ChatMessagePart
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.CreatedAt)
require.Nil(t, decoded.CompletedAt)
})
}
func TestChatModelCallConfig_UnmarshalStoredCost(t *testing.T) {
t.Parallel()
raw := []byte(`{
"temperature": 0.5,
"cost": {"input_price_per_million_tokens": "5"},
"provider_options": {"anthropic": {"thinking": {"budget_tokens": 1024}}}
}`)
var decoded codersdk.ChatModelCallConfig
require.NoError(t, json.Unmarshal(raw, &decoded))
require.NotNil(t, decoded.Temperature)
require.NoError(t, decoded.UnmarshalStrict(raw))
require.NotNil(t, decoded.Temperature)
// Configs predating the nested cost object stored the pricing keys at
// the top level (see migration 000435).
legacyTopLevel := []byte(`{
"temperature": 0.5,
"input_price_per_million_tokens": "5",
"output_price_per_million_tokens": "10",
"cache_read_price_per_million_tokens": "1",
"cache_write_price_per_million_tokens": "2"
}`)
require.NoError(t, decoded.UnmarshalStrict(legacyTopLevel))
require.NotNil(t, decoded.Temperature)
err := decoded.UnmarshalStrict([]byte(`{"provider_options": {"anthropic": {"bogus_setting": true}}}`))
require.ErrorContains(t, err, `unknown field "bogus_setting"`)
err = decoded.UnmarshalStrict([]byte(`{"temperature": 0.5} {"bogus_setting": true}`))
require.ErrorContains(t, err, "trailing data")
require.NoError(t, json.Unmarshal([]byte(`{"bogus_setting": true}`), &decoded))
}
func TestChatModelCallConfig_UseResponsesAPIRoundTrip(t *testing.T) {
t.Parallel()
var decoded codersdk.ChatModelCallConfig
err := decoded.UnmarshalStrict([]byte(`{"openai_config": {"use_responses_api": true}}`))
require.NoError(t, err)
require.NotNil(t, decoded.OpenAIConfig)
require.NotNil(t, decoded.OpenAIConfig.UseResponsesAPI)
require.True(t, *decoded.OpenAIConfig.UseResponsesAPI)
raw, err := json.Marshal(decoded)
require.NoError(t, err)
require.Contains(t, string(raw), `"use_responses_api":true`)
var unset codersdk.ChatModelCallConfig
require.NoError(t, unset.UnmarshalStrict([]byte(`{"openai_config": {}}`)))
require.Nil(t, unset.OpenAIConfig.UseResponsesAPI)
raw, err = json.Marshal(unset)
require.NoError(t, err)
require.NotContains(t, string(raw), "use_responses_api")
}
// TestChat_JSONRoundTrip verifies that every field of codersdk.Chat
// survives a JSON marshal/unmarshal cycle. This catches omitempty
// silently eating zero-ish values, struct tag typos, and similar
// serialization bugs in the pubsub path.
func TestChat_JSONRoundTrip(t *testing.T) {
t.Parallel()
now := time.Now().UTC().Truncate(time.Microsecond)
prState := "open"
prTitle := "test PR"
authorLogin := "testuser"
avatarURL := "https://example.com/avatar.png"
baseBranch := "main"
headBranch := "feature/test"
prNumber := int32(42)
commits := int32(3)
approved := true
reviewerCount := int32(2)
refreshedAt := now
staleAt := now.Add(time.Hour)
lastError := &codersdk.ChatError{
Message: "boom",
Detail: "provider detail",
Kind: codersdk.ChatErrorKindGeneric,
Provider: "openai",
Retryable: true,
StatusCode: 503,
}
prURL := "https://github.com/coder/coder/pull/42"
workspaceID := uuid.New()
buildID := uuid.New()
agentID := uuid.New()
parentChatID := uuid.New()
rootChatID := uuid.New()
original := codersdk.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
WorkspaceID: &workspaceID,
BuildID: &buildID,
AgentID: &agentID,
ParentChatID: &parentChatID,
RootChatID: &rootChatID,
LastModelConfigID: uuid.New(),
Title: "round-trip-test",
Status: codersdk.ChatStatusRunning,
LastError: lastError,
CreatedAt: now,
UpdatedAt: now,
Archived: true,
MCPServerIDs: []uuid.UUID{uuid.New()},
Labels: map[string]string{"env": "prod"},
DiffStatus: &codersdk.ChatDiffStatus{
ChatID: uuid.New(),
URL: &prURL,
PullRequestState: &prState,
PullRequestTitle: prTitle,
PullRequestDraft: true,
ChangesRequested: true,
Additions: 10,
Deletions: 5,
ChangedFiles: 3,
AuthorLogin: &authorLogin,
AuthorAvatarURL: &avatarURL,
BaseBranch: &baseBranch,
HeadBranch: &headBranch,
PRNumber: &prNumber,
Commits: &commits,
Approved: &approved,
ReviewerCount: &reviewerCount,
RefreshedAt: &refreshedAt,
StaleAt: &staleAt,
},
}
data, err := json.Marshal(original)
require.NoError(t, err)
var decoded codersdk.Chat
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
require.Equal(t, original, decoded)
}
func TestNewDynamicTool(t *testing.T) {
t.Parallel()
type testArgs struct {
Query string `json:"query"`
}
t.Run("CorrectSchema", func(t *testing.T) {
t.Parallel()
tool := codersdk.NewDynamicTool(
"search", "search things",
func(_ context.Context, args testArgs, _ codersdk.DynamicToolCall) (codersdk.DynamicToolResponse, error) {
return codersdk.DynamicToolResponse{Content: args.Query}, nil
},
)
require.Equal(t, "search", tool.Name)
require.Equal(t, "search things", tool.Description)
require.Contains(t, string(tool.InputSchema), `"query"`)
require.Contains(t, string(tool.InputSchema), `"string"`)
})
t.Run("HandlerReceivesArgs", func(t *testing.T) {
t.Parallel()
var received testArgs
tool := codersdk.NewDynamicTool(
"search", "search things",
func(_ context.Context, args testArgs, _ codersdk.DynamicToolCall) (codersdk.DynamicToolResponse, error) {
received = args
return codersdk.DynamicToolResponse{Content: "ok"}, nil
},
)
resp, err := tool.Handler(context.Background(), codersdk.DynamicToolCall{
Args: `{"query":"hello"}`,
})
require.NoError(t, err)
require.Equal(t, "ok", resp.Content)
require.Equal(t, "hello", received.Query)
})
t.Run("InvalidJSONArgs", func(t *testing.T) {
t.Parallel()
tool := codersdk.NewDynamicTool(
"search", "search things",
func(_ context.Context, args testArgs, _ codersdk.DynamicToolCall) (codersdk.DynamicToolResponse, error) {
return codersdk.DynamicToolResponse{Content: "should not reach"}, nil
},
)
resp, err := tool.Handler(context.Background(), codersdk.DynamicToolCall{
Args: "not-json",
})
require.NoError(t, err)
require.True(t, resp.IsError)
require.Contains(t, resp.Content, "invalid parameters")
})
}
//nolint:tparallel,paralleltest
func TestParseChatWorkspaceTTL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want time.Duration
wantErr bool
}{
{"Empty_ReturnsDefault", "", 0, false},
{"ValidDuration_Hours", "2h", 2 * time.Hour, false},
{"ValidDuration_HoursAndMinutes", "2h30m", 2*time.Hour + 30*time.Minute, false},
{"ValidDuration_Minutes", "90m", 90 * time.Minute, false},
{"Zero", "0s", 0, false},
{"Negative", "-1h", 0, true},
{"Invalid", "not-a-duration", 0, true},
{"LargeDuration", "720h", 720 * time.Hour, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := codersdk.ParseChatWorkspaceTTL(tc.input)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.want, got)
})
}
}