Files
lijiaoqiao/projects/ai-customer-service/internal/store/postgres/store_test.go
Your Name 687c4535f8 fix: P0-1 RateLimiter并发写安全 + P0-2工单操作错误码区分 + P1 rows.Close修复
P0-1 (limits.go): Allow()方法改为全程使用写锁保护counters map读写,避免RLock写入时的data race
P0-2 (ticket_workflow.go+ticket_handler.go): Assign/Resolve/Close操作先查询ticket存在性和状态,返回明确的CS_TICKET_4001/CS_TKT_4002/CS_TICKET_4092/CS_TICKET_4093错误码,handler根据错误前缀路由HTTP状态码
P1-1 (ticket_store.go): 移除GetStats中3处手动rows.Close(),只保留defer Close()
2026-05-01 20:56:25 +08:00

370 lines
8.9 KiB
Go

package postgres
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"testing"
"time"
"github.com/bridge/ai-customer-service/internal/domain/audit"
"github.com/bridge/ai-customer-service/internal/domain/session"
"github.com/bridge/ai-customer-service/internal/domain/ticket"
)
func getDSN() string {
return "host=localhost port=5434 user=ai_cs password=ai_cs_secret dbname=ai_customer_service sslmode=disable"
}
func uniqueID(prefix string) string {
b := make([]byte, 16)
rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
uuid := hex.EncodeToString(b)
return uuid[:8] + "-" + uuid[8:12] + "-" + uuid[12:16] + "-" + uuid[16:20] + "-" + uuid[20:]
}
func openDBForTest(t *testing.T) *sql.DB {
dsn := getDSN()
if dsn == "" {
t.Skip("AI_CS_POSTGRES_DSN not set")
}
db, err := Open(Config{
DSN: dsn,
MaxOpenConns: 5,
MaxIdleConns: 2,
ConnMaxLifetime: time.Second * 30,
})
if err != nil {
t.Fatalf("failed to open DB: %v", err)
}
return db
}
// --- TicketStore tests ---
func TestTicketStore_CreateAndGet(t *testing.T) {
db := openDBForTest(t)
defer db.Close()
sessionStore := NewSessionStore(db)
ticketStore := NewTicketStore(db)
ctx := context.Background()
now := time.Now().Truncate(time.Second)
// Create session first (FK constraint)
sess, err := sessionStore.GetOrCreate(ctx, "widget", uniqueID("user"), now)
if err != nil {
t.Fatalf("failed to create session: %v", err)
}
tkt := &ticket.Ticket{
ID: uniqueID("tick"),
SessionID: sess.ID,
UserID: "user-001",
Priority: ticket.PriorityP1,
Status: ticket.StatusOpen,
HandoffReason: "Test handoff",
AssignedTo: "agent-001",
ContextSnapshot: map[string]any{"key": "value"},
CreatedAt: now,
UpdatedAt: now,
}
if err := ticketStore.Create(ctx, tkt); err != nil {
t.Fatalf("Create failed: %v", err)
}
fetched, err := ticketStore.GetByID(ctx, tkt.ID)
if err != nil {
t.Fatalf("GetByID failed: %v", err)
}
if fetched.ID != tkt.ID {
t.Errorf("expected ID %s, got %s", tkt.ID, fetched.ID)
}
if fetched.SessionID != tkt.SessionID {
t.Errorf("expected SessionID %s, got %s", tkt.SessionID, fetched.SessionID)
}
if fetched.Priority != ticket.PriorityP1 {
t.Errorf("expected Priority P1, got %s", fetched.Priority)
}
if fetched.Status != ticket.StatusOpen {
t.Errorf("expected Status open, got %s", fetched.Status)
}
}
func TestTicketStore_GetStats(t *testing.T) {
db := openDBForTest(t)
defer db.Close()
store := NewTicketStore(db)
ctx := context.Background()
stats, err := store.GetStats(ctx)
if err != nil {
t.Fatalf("GetStats failed: %v", err)
}
if stats.Total < 0 {
t.Errorf("expected non-negative Total, got %d", stats.Total)
}
if stats.ByChannel == nil {
t.Error("expected non-nil ByChannel")
}
if stats.ByPriority == nil {
t.Error("expected non-nil ByPriority")
}
}
func TestTicketStore_Create_NilTicket(t *testing.T) {
store := NewTicketStore(nil)
err := store.Create(context.Background(), nil)
if err == nil {
t.Error("expected error for nil ticket")
}
}
func TestTicketStore_Create_NilDB(t *testing.T) {
store := NewTicketStore(nil)
err := store.Create(context.Background(), &ticket.Ticket{})
if err == nil {
t.Error("expected error for nil db")
}
}
func TestTicketStore_GetByID_NilDB(t *testing.T) {
store := NewTicketStore(nil)
_, err := store.GetByID(context.Background(), "any-id")
if err == nil {
t.Error("expected error for nil db")
}
}
func TestTicketStore_GetStats_NilDB(t *testing.T) {
store := NewTicketStore(nil)
_, err := store.GetStats(context.Background())
if err == nil {
t.Error("expected error for nil db")
}
}
// --- SessionStore tests ---
func TestSessionStore_GetOrCreate(t *testing.T) {
db := openDBForTest(t)
defer db.Close()
store := NewSessionStore(db)
ctx := context.Background()
now := time.Now()
openID := uniqueID("sess")
// First call creates
sess1, err := store.GetOrCreate(ctx, "widget", openID, now)
if err != nil {
t.Fatalf("GetOrCreate (create) failed: %v", err)
}
if sess1.Channel != "widget" {
t.Errorf("expected channel widget, got %s", sess1.Channel)
}
if sess1.OpenID != openID {
t.Errorf("expected openID %s, got %s", openID, sess1.OpenID)
}
// Second call returns existing
sess2, err := store.GetOrCreate(ctx, "widget", openID, now)
if err != nil {
t.Fatalf("GetOrCreate (get) failed: %v", err)
}
if sess2.ID != sess1.ID {
t.Errorf("expected same ID on second call, got %s vs %s", sess2.ID, sess1.ID)
}
}
func TestSessionStore_GetOrCreate_NilDB(t *testing.T) {
store := NewSessionStore(nil)
_, err := store.GetOrCreate(context.Background(), "widget", "any", time.Now())
if err == nil {
t.Error("expected error for nil db")
}
}
func TestSessionStore_GetByID(t *testing.T) {
db := openDBForTest(t)
defer db.Close()
store := NewSessionStore(db)
ctx := context.Background()
now := time.Now()
openID := uniqueID("sess")
created, err := store.GetOrCreate(ctx, "widget", openID, now)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
fetched, err := store.GetByID(ctx, created.ID)
if err != nil {
t.Fatalf("GetByID failed: %v", err)
}
if fetched.ID != created.ID {
t.Errorf("expected ID %s, got %s", created.ID, fetched.ID)
}
}
func TestSessionStore_GetByID_NilDB(t *testing.T) {
store := NewSessionStore(nil)
_, err := store.GetByID(context.Background(), "any-id")
if err == nil {
t.Error("expected error for nil db")
}
}
func TestSessionStore_Save(t *testing.T) {
db := openDBForTest(t)
defer db.Close()
store := NewSessionStore(db)
ctx := context.Background()
now := time.Now()
openID := uniqueID("sess")
sess, err := store.GetOrCreate(ctx, "widget", openID, now)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
sess.Status = session.StatusProcessing
sess.TurnCount = 5
if err := store.Save(ctx, sess); err != nil {
t.Fatalf("Save failed: %v", err)
}
fetched, err := store.GetByID(ctx, sess.ID)
if err != nil {
t.Fatalf("GetByID after Save failed: %v", err)
}
if fetched.Status != session.StatusProcessing {
t.Errorf("expected status processing, got %s", fetched.Status)
}
if fetched.TurnCount != 5 {
t.Errorf("expected turncount 5, got %d", fetched.TurnCount)
}
}
func TestSessionStore_Save_NilDB(t *testing.T) {
store := NewSessionStore(nil)
err := store.Save(context.Background(), &session.Session{})
if err == nil {
t.Error("expected error for nil db")
}
}
// --- AuditStore tests ---
func TestAuditStore_Add(t *testing.T) {
db := openDBForTest(t)
defer db.Close()
store := NewAuditStore(db)
ctx := context.Background()
now := time.Now().Truncate(time.Second)
event := audit.Event{
ID: uniqueID("audit"),
SessionID: uniqueID("sess"),
TicketID: "",
Type: "session",
Action: "message",
Channel: "widget",
OpenID: "ou_test",
ActorID: "agent-001",
SourceIP: "10.0.0.1",
Payload: map[string]any{"content": "hello world"},
BeforeState: map[string]any{"status": "idle"},
AfterState: map[string]any{"status": "processing"},
CreatedAt: now,
}
if err := store.Add(ctx, event); err != nil {
t.Fatalf("Add failed: %v", err)
}
}
func TestAuditStore_Add_NilDB(t *testing.T) {
store := NewAuditStore(nil)
err := store.Add(context.Background(), audit.Event{Type: "test"})
if err == nil {
t.Error("expected error for nil db")
}
}
func TestAuditStore_Add_TicketScoped(t *testing.T) {
db := openDBForTest(t)
defer db.Close()
store := NewAuditStore(db)
ctx := context.Background()
now := time.Now().Truncate(time.Second)
event := audit.Event{
ID: uniqueID("audit"),
TicketID: uniqueID("tick"),
Type: "ticket",
Action: "resolve",
OpenID: "ou_test2",
ActorID: "agent-002",
BeforeState: map[string]any{"status": "open"},
AfterState: map[string]any{"status": "resolved"},
CreatedAt: now,
}
if err := store.Add(ctx, event); err != nil {
t.Fatalf("Add ticket-scoped event failed: %v", err)
}
}
func TestAuditStore_Add_SystemActor(t *testing.T) {
db := openDBForTest(t)
defer db.Close()
store := NewAuditStore(db)
ctx := context.Background()
// Event with no ActorID and no OpenID -> defaults to "system"
event := audit.Event{
ID: uniqueID("audit"),
SessionID: uniqueID("sess"),
Type: "session",
Action: "create",
CreatedAt: time.Now().Truncate(time.Second),
}
if err := store.Add(ctx, event); err != nil {
t.Fatalf("Add system actor event failed: %v", err)
}
}
func TestAuditStore_Add_EmptyAction(t *testing.T) {
db := openDBForTest(t)
defer db.Close()
store := NewAuditStore(db)
ctx := context.Background()
// Empty action should default to "update"
event := audit.Event{
ID: uniqueID("audit"),
SessionID: uniqueID("sess"),
Type: "session",
CreatedAt: time.Now().Truncate(time.Second),
}
if err := store.Add(ctx, event); err != nil {
t.Fatalf("Add with empty action failed: %v", err)
}
}