Files
agentic-coding-workflow/internal/chat/repository_test.go
T

340 lines
11 KiB
Go

//go:build integration
package chat
import (
"context"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
tcpg "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/yan1h/agent-coding-workflow/internal/infra/db"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// setupRepo 起一个临时 postgres 容器、跑迁移、注入 user / llm_endpoint / llm_model
// 行作外键基础,返回 Repository 与 pool、uid、eid、mid。
func setupRepo(t *testing.T) (Repository, *pgxpool.Pool, uuid.UUID, uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
container, err := tcpg.Run(ctx, "postgres:16-alpine",
tcpg.WithDatabase("acw"), tcpg.WithUsername("acw"), tcpg.WithPassword("acw"))
require.NoError(t, err)
t.Cleanup(func() { _ = container.Terminate(ctx) })
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
require.NoError(t, db.Migrate(ctx, dsn, "file://../../migrations"))
pool, err := db.NewPool(ctx, dsn)
require.NoError(t, err)
t.Cleanup(pool.Close)
uid := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO users (id,email,password_hash,display_name,is_admin,created_at,updated_at)
VALUES ($1,'a@b.c','x','admin',true,now(),now())`, uid)
require.NoError(t, err)
eid := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO llm_endpoints (id,provider,display_name,base_url,api_key_encrypted,created_by)
VALUES ($1,'anthropic','test','https://example.com','\x',$2)`, eid, uid)
require.NoError(t, err)
mid := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO llm_models (id,endpoint_id,model_id,display_name,capabilities,context_window,max_output_tokens)
VALUES ($1,$2,'claude','Claude','{}',200000,8192)`, mid, eid)
require.NoError(t, err)
return NewRepository(pool), pool, uid, eid, mid
}
// ===== 1. Conversation CRUD =====
func TestRepo_Conversation_CRUD(t *testing.T) {
t.Parallel()
repo, _, uid, _, mid := setupRepo(t)
ctx := context.Background()
cid := uuid.New()
conv, err := repo.InsertConversation(ctx, InsertConversationParams{
ID: cid, UserID: uid, ModelID: mid, SystemPrompt: "be helpful",
})
require.NoError(t, err)
require.Equal(t, cid, conv.ID)
require.Equal(t, "be helpful", conv.SystemPrompt)
got, err := repo.GetConversation(ctx, cid)
require.NoError(t, err)
require.Equal(t, cid, got.ID)
require.NoError(t, repo.SoftDeleteConversation(ctx, cid))
_, err = repo.GetConversation(ctx, cid)
var ae *errs.AppError
require.ErrorAs(t, err, &ae)
require.Equal(t, errs.CodeChatConversationNotFound, ae.Code)
}
// ===== 2. Messages: pending unique index =====
func TestRepo_Messages_PendingUniqueIndex(t *testing.T) {
t.Parallel()
repo, _, uid, _, mid := setupRepo(t)
ctx := context.Background()
cid := uuid.New()
_, err := repo.InsertConversation(ctx, InsertConversationParams{
ID: cid, UserID: uid, ModelID: mid, SystemPrompt: "",
})
require.NoError(t, err)
// first pending assistant message — should succeed
_, err = repo.InsertMessage(ctx, cid, RoleAssistant, "", StatusPending)
require.NoError(t, err)
// second pending assistant message in same conv — must fail with CodeChatConcurrentMessage
_, err = repo.InsertMessage(ctx, cid, RoleAssistant, "", StatusPending)
require.Error(t, err)
var ae *errs.AppError
require.ErrorAs(t, err, &ae)
require.Equal(t, errs.CodeChatConcurrentMessage, ae.Code)
require.True(t, strings.Contains(ae.Message, "another message is already streaming"))
}
// ===== 3. Message status transitions =====
func TestRepo_Messages_StatusTransitions(t *testing.T) {
t.Parallel()
repo, _, uid, _, mid := setupRepo(t)
ctx := context.Background()
cid := uuid.New()
_, err := repo.InsertConversation(ctx, InsertConversationParams{
ID: cid, UserID: uid, ModelID: mid, SystemPrompt: "",
})
require.NoError(t, err)
msg, err := repo.InsertMessage(ctx, cid, RoleAssistant, "", StatusPending)
require.NoError(t, err)
require.Equal(t, StatusPending, msg.Status)
pt := int32(10)
ct := int32(20)
require.NoError(t, repo.UpdateMessageOK(ctx, UpdateMessageOKParams{
ID: msg.ID,
Content: "hello",
PromptTokens: &pt,
CompletionTokens: &ct,
}))
updated, err := repo.GetMessage(ctx, msg.ID)
require.NoError(t, err)
require.Equal(t, StatusOK, updated.Status)
require.Equal(t, "hello", updated.Content)
require.NotNil(t, updated.PromptTokens)
require.Equal(t, 10, *updated.PromptTokens)
}
// ===== 4. Attachments orphan lifecycle =====
func TestRepo_Attachments_OrphanLifecycle(t *testing.T) {
t.Parallel()
repo, _, uid, _, mid := setupRepo(t)
ctx := context.Background()
aid := uuid.New()
att, err := repo.InsertAttachment(ctx, InsertAttachmentParams{
ID: aid, UserID: uid,
Filename: "file.txt", MimeType: "text/plain",
SizeBytes: 42, SHA256: "abc", StoragePath: "/tmp/file",
})
require.NoError(t, err)
require.Nil(t, att.MessageID, "newly inserted attachment must be orphan")
// create conversation + message to attach to
cid := uuid.New()
_, err = repo.InsertConversation(ctx, InsertConversationParams{
ID: cid, UserID: uid, ModelID: mid, SystemPrompt: "",
})
require.NoError(t, err)
msg, err := repo.InsertMessage(ctx, cid, RoleUser, "hello", StatusOK)
require.NoError(t, err)
require.NoError(t, repo.AttachToMessage(ctx, []uuid.UUID{aid}, msg.ID))
atts, err := repo.ListAttachmentsForMessage(ctx, msg.ID)
require.NoError(t, err)
require.Len(t, atts, 1)
require.Equal(t, aid, atts[0].ID)
require.NotNil(t, atts[0].MessageID)
require.Equal(t, msg.ID, *atts[0].MessageID)
}
// ===== 5. Templates scope filtering =====
func TestRepo_Templates_ScopeFiltering(t *testing.T) {
t.Parallel()
repo, pool, uid, _, _ := setupRepo(t)
ctx := context.Background()
// seed user B
uidB := uuid.New()
_, err := pool.Exec(ctx, `INSERT INTO users (id,email,password_hash,display_name,is_admin,created_at,updated_at)
VALUES ($1,'b@b.c','x','B',false,now(),now())`, uidB)
require.NoError(t, err)
// system template (no owner)
_, err = repo.InsertPromptTemplate(ctx, InsertTemplateParams{
ID: uuid.New(), Name: "sys", Content: "system", Scope: TemplateScopeSystem,
})
require.NoError(t, err)
// user A template
_, err = repo.InsertPromptTemplate(ctx, InsertTemplateParams{
ID: uuid.New(), Name: "tmpl-a", Content: "for a", Scope: TemplateScopeUser, OwnerID: &uid,
})
require.NoError(t, err)
// user B template
_, err = repo.InsertPromptTemplate(ctx, InsertTemplateParams{
ID: uuid.New(), Name: "tmpl-b", Content: "for b", Scope: TemplateScopeUser, OwnerID: &uidB,
})
require.NoError(t, err)
templates, err := repo.ListPromptTemplatesForUser(ctx, uid)
require.NoError(t, err)
require.Len(t, templates, 2, "user A should see system + own template only")
names := make([]string, len(templates))
for i, tmpl := range templates {
names[i] = tmpl.Name
}
require.Contains(t, names, "sys")
require.Contains(t, names, "tmpl-a")
require.NotContains(t, names, "tmpl-b")
}
// ===== 6. Endpoints CRUD =====
func TestRepo_Endpoints_CRUD(t *testing.T) {
t.Parallel()
repo, _, uid, _, _ := setupRepo(t)
ctx := context.Background()
eid := uuid.New()
ep, err := repo.InsertLLMEndpoint(ctx, InsertEndpointParams{
ID: eid, Provider: ProviderOpenAI, DisplayName: "OpenAI",
BaseURL: "https://api.openai.com", APIKeyEncrypted: []byte("key"), CreatedBy: uid,
})
require.NoError(t, err)
require.Equal(t, eid, ep.ID)
require.Equal(t, ProviderOpenAI, ep.Provider)
newName := "OpenAI Updated"
require.NoError(t, repo.UpdateLLMEndpoint(ctx, eid, UpdateEndpointInput{
DisplayName: &newName,
}))
got, err := repo.GetLLMEndpoint(ctx, eid)
require.NoError(t, err)
require.Equal(t, "OpenAI Updated", got.DisplayName)
require.NoError(t, repo.DeleteLLMEndpoint(ctx, eid))
_, err = repo.GetLLMEndpoint(ctx, eid)
var ae *errs.AppError
require.ErrorAs(t, err, &ae)
require.Equal(t, errs.CodeChatEndpointNotFound, ae.Code)
}
// ===== 7. Models: title generator unique constraint =====
func TestRepo_Models_TitleGeneratorUnique(t *testing.T) {
t.Parallel()
repo, _, _, eid, _ := setupRepo(t)
ctx := context.Background()
// first title-generator model
_, err := repo.InsertLLMModel(ctx, InsertModelParams{
ID: uuid.New(), EndpointID: eid, ModelID: "m1", DisplayName: "M1",
Capabilities: ModelCapabilities{}, ContextWindow: 4096, MaxOutputTokens: 1024,
IsTitleGenerator: true, Enabled: true,
})
require.NoError(t, err)
// second title-generator model — must fail with CodeConflict
_, err = repo.InsertLLMModel(ctx, InsertModelParams{
ID: uuid.New(), EndpointID: eid, ModelID: "m2", DisplayName: "M2",
Capabilities: ModelCapabilities{}, ContextWindow: 4096, MaxOutputTokens: 1024,
IsTitleGenerator: true, Enabled: true,
})
require.Error(t, err)
var ae *errs.AppError
require.ErrorAs(t, err, &ae)
require.Equal(t, errs.CodeConflict, ae.Code)
}
// ===== 8. Usage summarize by day =====
func TestRepo_Usage_SummarizeByDay(t *testing.T) {
t.Parallel()
repo, _, uid, eid, mid := setupRepo(t)
ctx := context.Background()
cid := uuid.New()
_, err := repo.InsertConversation(ctx, InsertConversationParams{
ID: cid, UserID: uid, ModelID: mid, SystemPrompt: "",
})
require.NoError(t, err)
msg, err := repo.InsertMessage(ctx, cid, RoleUser, "hi", StatusOK)
require.NoError(t, err)
// insert 3 usage records
for i := 0; i < 3; i++ {
require.NoError(t, repo.InsertUsage(ctx, UsageRecord{
ConversationID: cid, MessageID: msg.ID, UserID: uid,
EndpointID: eid, ModelID: mid,
PromptTokens: 10, CompletionTokens: 20, ThinkingTokens: 0,
CostUSD: 0.001,
}))
}
from := time.Now().UTC().Truncate(24 * time.Hour)
to := from.Add(24 * time.Hour)
rows, err := repo.SummarizeUsageByDay(ctx, from, to)
require.NoError(t, err)
require.Len(t, rows, 1)
require.Equal(t, 30, rows[0].PromptTokens)
require.Equal(t, 60, rows[0].CompletionTokens)
}
// ===== 9. Tx rollback undoes insert =====
func TestRepo_Tx_RollbackUndoesInsert(t *testing.T) {
t.Parallel()
repo, _, uid, _, mid := setupRepo(t)
ctx := context.Background()
txRepo, tx, err := repo.WithTx(ctx)
require.NoError(t, err)
cid := uuid.New()
_, err = txRepo.InsertConversation(ctx, InsertConversationParams{
ID: cid, UserID: uid, ModelID: mid, SystemPrompt: "rollback me",
})
require.NoError(t, err)
// can read within same tx
got, err := txRepo.GetConversation(ctx, cid)
require.NoError(t, err)
require.Equal(t, cid, got.ID)
// rollback
require.NoError(t, tx.Rollback(ctx))
// after rollback, reading via original repo should return not found
_, err = repo.GetConversation(ctx, cid)
var ae *errs.AppError
require.ErrorAs(t, err, &ae)
require.Equal(t, errs.CodeChatConversationNotFound, ae.Code)
}